Overview
WhatsApp uses a custom binary protocol for all communication between clients and servers. This format is significantly more compact than JSON or XML and optimized for mobile network conditions. The protocol encodes messages as nodes - hierarchical structures with tags, attributes, and content. All nodes are serialized to binary format before encryption and transmission.Architecture
The binary protocol implementation is inwacore/binary/, a platform-agnostic crate:
Node Structure
Node Definition
A node represents a protocol message or message component:tag field uses Cow<'static, str> so that known protocol tags (like "message", "iq", "receipt") are borrowed as zero-allocation static references from the token dictionary, while unknown tags fall back to an owned String.
Location: wacore/binary/src/node.rs:459
Attributes
Attributes are stored as key-value pairs with specialized value types:Node.tag, attribute keys use Cow<'static, str> so that common protocol attribute names (like "id", "type", "to", "from") reference static memory from the token dictionary rather than allocating on the heap.
AttrsVec is a SmallVec with inline capacity 2. Nodes carrying ≤2 attributes — the common per-recipient fanout shapes (to with 1 attr, enc with 2) — keep their attributes on the stack alongside the node with no heap allocation. Nodes with ≥3 attributes spill to the heap as before. See Inline attribute storage for performance numbers.
NodeValue API
NodeValue provides exactly two methods for accessing the underlying value, regardless of variant:
as_str() when you need the value as text, and to_jid() when you need a structured JID:
NodeValue also implements PartialEq<str> for zero-allocation comparisons — the Jid variant compares byte-by-byte against the formatted string without allocating.
Location: wacore/binary/src/node.rs:39-58
Why Jid as a separate type?
JIDs (Jabber IDs) like 15551234567@s.whatsapp.net appear frequently in the protocol. Storing them as structured data avoids repeated parsing/formatting overhead:
For the AD-capable servers (
Pn, Lid, Hosted, HostedLid), the wire form spells the server as a leading domain byte on AD_JID (see AD_JID below). The decoder resolves that byte into server. It does not also copy the byte into agent.Earlier versions kept a redundant copy in agent. A JID decoded off the wire got agent set to the domain byte. The same JID parsed from text got agent set to 0, even though Display rendered both the same way. PartialEq/Hash were derived at the time, so those two JIDs compared unequal and could hash to different values.agent now stays 0 for these servers on both paths. A wire-decoded JID and a text-parsed JID therefore compare equal and hash the same. But PartialEq/Hash no longer rely on that invariant holding everywhere. Jid and JidRef implement both by hand now, routed through a private module-level identity_agent(server, agent) helper. That helper reads as 0 on any server where Server::renders_agent() is false, regardless of what the raw field holds. This closes a gap the decoder fix alone didn’t: swap_pn_lid_namespace and similar code can still carry a nonzero agent across a namespace conversion. Equality treats the result the same as the clean JID either way. agent is only ever identity-relevant for Bot/Interop, which do render it.integrator is not normalized the same way. It is folded into identity unconditionally, matching is_same_chat_as, since the field is never set outside Interop in practice. A separate public method, Jid::identity_agent(&self), wraps that same private helper. Code building its own key over a JID — sorting, deduplicating, indexing — can call it to apply the identical rule the hand-written PartialEq/Hash use, instead of reading jid.agent directly.user field uses CompactString (re-exported from compact_str) instead of String. CompactString stores short strings inline (up to 24 bytes on 64-bit platforms) without heap allocation, which benefits typical phone numbers and user identifiers. The library re-exports it as wacore_binary::CompactString and whatsapp_rust::CompactString for convenience. CompactString implements From<&str>, From<String>, and Deref<Target = str>, so it works as a drop-in replacement in most contexts — but code that relied on Jid.user being a String (e.g., passing it to functions expecting &String or calling String-specific methods) may need updating.
Server enum
Theserver field is a Server enum (#[repr(u8)]) that maps to the wire protocol’s AD_JID domain type. This replaces the previous Cow<'static, str> string representation, eliminating all heap allocation for server identifiers and enabling match-based dispatch instead of string comparisons:
Server implements Display (returns the wire string like "s.whatsapp.net"), as_str() for zero-cost string access, TryFrom<&str> for parsing, Serialize/Deserialize (as the wire string), and PartialEq<str> / PartialEq<&str> for backward-compatible string comparisons:
If you previously compared
jid.server to string constants like "s.whatsapp.net", the PartialEq<str> impl on Server preserves backward compatibility. However, match on the enum variant is preferred for exhaustiveness checking and performance.Server::parse_known(s: &str) -> Option<Self> is an allocation-free alternative to TryFrom<&str>: it returns None for an unknown suffix instead of building a JidError::InvalidFormat (and the String message inside it). TryFrom is implemented on top of it, so the two always agree:
parse_known when you only need an Option. This is useful for classifying short strings that might not be JIDs, such as an email address containing @. Use try_from when you need a descriptive error.
String constants are still available for backward compatibility and use in non-JID contexts:
Typed constructors
Convenience constructors avoid specifying the server directly:Borrowing types
For zero-allocation lookups and comparisons, the protocol also provides:JidRef<'a>— a borrowing version ofJidwhereuserisNodeStr<'a>(borrowed or inline) andserveris theServerenum (alreadyCopy). Used for zero-copy decoded JIDs inNodeRefattributesDeviceKey<'a>— a lightweight key containing(&'a str, &'a str, u16)for user/server/device, used forHashSetlookups without cloning
wacore/binary/src/node.rs:10-112, wacore/binary/src/jid.rs
JidExt trait
TheJidExt trait provides type-checking methods on JIDs. It is implemented for Jid, JidRef, and other borrowing types so you can inspect a JID’s server type without string comparisons:
The trait also exposes basic accessor methods (
user() -> &str, server() -> Server, device() -> u16, integrator() -> u16) that work uniformly across owned and borrowed JID types. Additional helper methods is_pn() and is_lid() are available directly on Jid for the most common server checks.
Location: wacore/binary/src/jid.rs:304-363
NodeBuilder API
TheNodeBuilder provides a fluent chaining API for constructing nodes. All setter methods consume and return Self:
Available methods
The
new and attr methods accept &'static str for tags and keys, which creates Cow::Borrowed values on the owned Node with zero heap allocation. For rare cases where the tag is computed at runtime, use new_dynamic.
Location: wacore/binary/src/builder.rs
jid_attr vs attr
Thejid_attr method stores JIDs as NodeValue::Jid(jid) directly in the attribute map, avoiding the allocation cost of jid.to_string(). Use jid_attr for JID-valued attributes like to, from, and participant on hot paths:
Conditional chaining
Uselet mut builder with reassignment for conditional attributes:
Token Dictionary
The protocol uses a token dictionary to compress common strings into single bytes.Token Types
wacore/binary/src/token.rs
Unified token lookup
Both single-byte and double-byte tokens are resolved by a single compile-time hashifytiny_map, generated by a build script from tokens.json. The dispatch uses a length-bucketed strategy: match key.len() first, then compare only discriminator bytes—no full-key hash. A single call to index_of_token resolves any known protocol string:
TokenKind enum distinguishes single-byte from double-byte tokens:
- Protocol tags (“message”, “iq”, “presence”)
- Common attributes (“id”, “type”, “to”, “from”)
- Frequent values (“text”, “chat”, “available”)
wacore/binary/src/token.rs
Encoding Process
Marshal Functions
wacore/binary/src/marshal.rs:31-76
The format byte
Everymarshal* function above writes one byte before the node bytes: a format byte. It’s a flag with a single defined bit, and it’s the whole reason a decoded buffer is one byte shorter than the marshal output it came from.
unpack/unpack_bytes strip the format byte from a received frame — decompressing the node bytes behind it when FORMAT_COMPRESSED is set — before the decoder ever sees them; this is what the receive path calls ahead of OwnedNodeRef::new. pack is the inverse: given node bytes, such as OwnedNodeRef::backing_bytes(), it prefixes FORMAT_PLAIN and returns a buffer shaped like marshal output — the form a send path such as Client::send_raw_bytes accepts.
check_plain_payload is the shape check a send path runs before touching a caller-supplied buffer: it accepts only FORMAT_PLAIN followed by at least one node byte, and rejects anything else with a named reason rather than forwarding it to the socket for the peer to reject by hanging up. FORMAT_COMPRESSED is refused here too — it’s a legitimate inbound frame, but nothing any marshal* function writes, so a caller that only ever handles our own output holds a buffer it did not build if it sees one.
unmarshal_packed_ref(data: &[u8]) -> Result<NodeRef<'_>> (in marshal.rs) decodes a packed payload directly — format byte plus node bytes, exactly what marshal produces — sharing check_plain_payload with the send-side check. It only accepts the uncompressed form, since the returned NodeRef borrows from data and decompressed bytes would have nowhere to live; unmarshal_ref is still the function for node bytes alone (post-unpack, or what OwnedNodeRef::backing_bytes() holds).
Forwarding or replaying a received stanza therefore goes through pack(&node_ref.backing_bytes()), not backing_bytes() alone — see OwnedNodeRef::backing_bytes below and Client::send_raw_bytes.
Location: wacore/binary/src/util.rs, wacore/binary/src/marshal.rs
Encoding Strategy
The encoder uses multiple strategies based on data characteristics:wacore/binary/src/encoder.rs:227-237
Packed Encoding
Nibble and hex packing share one code path,write_packed_bytes: an ASCII→nibble lookup table (NIBBLE_ENC or HEX_ENC, picked by data_type) maps each input byte to its packed nibble, pairs are packed two at a time into a stack buffer, and validity is checked once via an OR accumulator (seen) instead of per pair — that is what lets the pair loop unroll.
Nibble packing (numeric strings)
Strings containing only digits, dash, and dot are packed into 4 bits per character:wacore/binary/src/encoder.rs:32-43
Hex packing
Uppercase hex strings (0-9, A-F) are packed into 4 bits per character, againstHEX_ENC instead of NIBBLE_ENC:
wacore/binary/src/encoder.rs:14-28
Packing used to run through per-character
match ladders (pack_nibble/pack_hex) reached via a fn pointer, and for a while carried a portable_simd fast path for long strings on top of that. Both are gone: measurement showed the vector path only ever beat the match ladders, not a lookup table, and lost to the table at every string length tested — HEX_PAIRS[byte] (the decoder’s mirror-image table) is one 2-byte load, and a shuffle/interleave/store sequence doesn’t beat that. The two lookup tables above replaced all three call paths; an exhaustive test (encode_tables_match_the_ladders_they_replaced) checks every one of the 256 byte values against the original match ladders so the tables can’t silently drift from the encoding they replaced.JID Encoding
JIDs have special compact encodings:JID_PAIR (Standard JID)
wacore/binary/src/encoder.rs:706-715
AD_JID (Device-Specific JID)
domain_type byte is derived from the Server enum variant at encoding time, not from the agent field directly. Since Server is #[repr(u8)], the mapping is a direct cast for known variants:
Decoding used to have the mirror-image asymmetry.
domain_type resolves to server, but the decoder also wrote that same byte into agent. For Pn/Lid/Hosted/HostedLid specifically, the encoder above always re-derives domain_type from server and ignores agent, and Display never renders agent for these servers either — so nothing consumed the redundant copy. (Other variants, like Bot/Interop, do read agent through the fallback row in the table above.)
The only effect was that agent differed by provenance: 0 if you parsed the JID from text, the domain byte if you had just decoded it off the wire. With PartialEq/Hash derived at the time, the identical JID compared unequal and could hash to a different value — the same shape of bug as the encoder one above, just on the other side of the wire.
The decoder now leaves agent at 0 for Pn/Lid/Hosted/HostedLid, so encode → decode is idempotent, and a wire-decoded JID equals the same JID parsed from text. Jid’s hand-written PartialEq/Hash (see the note above) mean this no longer depends solely on the decoder holding that line, either — any other code path that leaves a stray byte in agent on these servers still compares and hashes as identity-equal.
Location: wacore/binary/src/encoder.rs:699-705, 362-369; decoder fix: wacore/binary/src/decoder.rs
INTEROP_JID (cross-platform interop JID)
server == Server::Interop && integrator != 0. A zero-integrator interop JID still encodes as JID_PAIR, as it always has. Device-specific Pn/Lid/Hosted/HostedLid JIDs are unaffected — they continue to use AD_JID, as described above.
This token is asymmetric between the two directions, and deliberately so. WA Web’s own writer emits only the three fields above. That writer is the evidence for what the server actually accepts. WA Web’s decoder additionally reads a trailing server byte. That extra byte describes what the server sends to the client, not what it expects to receive. Our decoder mirrors that same trailing read. As a result, encoding an interop JID this way does not round-trip through our own decoder for that token. That is a property of the protocol having two different shapes for the two directions, not a bug to paper over by making both ends agree locally.
Location: wacore/binary/src/encoder.rs (write_interop_jid, needs_interop_jid)
List Encoding
Lists (including node structures) have length-prefixed encoding:wacore/binary/src/encoder.rs:865-876
Node encoding format
A complete node is encoded as:list_len = 1 (tag) + (num_attrs * 2) + (content ? 1 : 0)
wacore/binary/src/encoder.rs:879-889
Decoding Process
Decoder Structure
wacore/binary/src/decoder.rs
Zero-copy decoding
The decoder usesNodeRef<'a> to avoid allocations. String and byte payloads borrow directly from the input buffer. Decoded strings use NodeStr<'a> — a borrowed-or-inline string type that stores short owned values (up to 24 bytes) inline via CompactString, avoiding heap allocation:
NodeStr implements Deref<Target = str>, AsRef<str>, PartialEq<str>, and PartialEq<&str>, so you can use it anywhere a &str is expected. It also provides to_compact_string() for efficient conversion to an owned CompactString.
NodeStr replaces the previous Cow<'a, str> used in NodeRef, AttrsRef, ValueRef, and NodeContentRef. The key difference is that the Owned variant uses CompactString (inline up to 24 bytes) instead of String (always heap-allocated), reducing allocation pressure for the many short protocol strings that can’t be statically interned.content used to be Option<Box<NodeContentRef<'a>>>. The box existed only to keep the content field itself pointer-sized (NodeContentRef is larger than a pointer) and was never a deliberate design decision — profiling a group-message fanout (one child NodeRef per device) found it responsible for the only allocation most content-bearing nodes made, about 11% of decode time in that shape. The field is now Option<NodeContentRef<'a>> directly: size_of::<NodeRef>() grows from 48 to 72 bytes, paid back by removing that allocation on every node that carries content (string, bytes, or children). Nodes with no content, like a bare <ack/>, are unaffected either way.Code matching on node.content doesn’t need a match-arm change, but code borrowing through it — node.content.as_deref() — needs to become node.content.as_ref(), since there is no longer a Box to deref through.wacore/binary/src/node.rs:10-106, 465-469, 437-441
Node nesting depth cap
read_node_ref decodes LIST-typed content recursively — a child LIST_8/LIST_16 node causes read_node_ref to call itself for each nested child. Since a single LIST node needs only ~4 wire bytes to nest one level (list_size == 2, zero attrs, one child), a hostile or malformed frame could otherwise force tens of thousands of recursion levels from a tiny payload and overflow the native call stack.
The decoder rejects this before it happens: recursion depth is tracked through read_node_ref → read_content → read_content_from_tag, and any node nested past MAX_NODE_DEPTH (128 levels) returns BinaryError::MaxDepthExceeded instead of recursing further. Real WhatsApp stanza trees are well under 20 levels deep, so this only ever rejects pathological input.
wacore/binary/src/decoder.rs
OwnedNodeRef (yoke zero-copy)
OwnedNodeRef is a self-referential type that owns the decompressed network buffer while the inner NodeRef borrows string and byte payloads directly from it. This avoids copying payloads out of the buffer during decoding — only container allocations (attribute Vec, child Vec) occur.
Arc<OwnedNodeRef>, giving handlers cheap shared access to the zero-copy decoded node. The to_owned_node() method is available as an escape hatch when you need a fully owned Node, but it allocates all strings and bytes — defeating the zero-copy benefit.
backing_bytes() goes the other direction: it hands back the entire decoded buffer as a Bytes — a refcount bump into the same allocation slice_bytes() already views into, not a copy. It returns exactly what was passed to new() — the buffer after decompression, past the leading format byte unpack already strips. That’s not necessarily the raw bytes as they arrived on the wire. Use it when you need to forward that buffer onward — to another process, a recording, a replay harness — instead of reading it. Re-encoding via marshal_ref is the alternative, and a worse one here: it costs a second pass over the tree, and it’s only byte-faithful while the token dictionaries match the ones that decoded the node. backing_bytes() has no such dependency; it stays true regardless of what the dictionaries do.
These are node bytes, though, not a sendable frame: a send path like Client::send_raw_bytes expects a packed payload — the format byte in front of the node bytes — not node bytes alone. Put the byte back with pack before forwarding backing_bytes() anywhere that expects marshal output; see The format byte above. Passing node bytes directly is exactly the shape check_plain_payload now rejects with BinaryError::UnexpectedFormatByte, where it used to reach the socket and get the connection closed by the peer instead.
Location: wacore/binary/src/node.rs:594-693
Node vs NodeRef usage pattern
Node(owned) — used for building and sending outgoing stanzas. Constructed viaNodeBuilder.NodeRef<'a>(borrowed) — used for reading received stanzas. Borrows from the network buffer.OwnedNodeRef— wraps aNodeRefwith its backing buffer viayoke, enabling safe zero-copy sharing across handler tasks asArc<OwnedNodeRef>.
Zero-copy serialization
The entireNodeRef type family implements serde::Serialize (gated behind the serde feature), producing output identical to their owned counterparts. This means you can serialize a NodeRef, OwnedNodeRef, ValueRef, JidRef, or NodeContentRef directly — without converting to an owned Node first — avoiding all intermediate allocations.
Serialize:
The
Serialize implementations use an AttrsRefWrapper to match the newtype-struct framing that serde’s derive produces for Attrs(Vec<...>). This ensures compatibility with binary formats like bincode and postcard, which distinguish between a bare sequence and a newtype struct wrapper.to_owned_node().
Location: wacore/binary/src/node.rs:67-71, 397-407, 473-488, 612-632, 875-880; wacore/binary/src/jid.rs:603-615
The borrowed counterpart to NodeValue is ValueRef<'a>, used in the decoder path and in NodeRef attributes:
ValueRef provides three methods: as_str() (returns Cow<'_, str> — zero-copy for String, allocates for Jid), as_jid() (returns Option<&JidRef>, only for Jid variant), and to_jid() (converts either variant to owned Jid, parsing from string if necessary).
Location: wacore/binary/src/node.rs:282-313
Attribute parsing
AttrParser and AttrParserRef provide structured attribute extraction from owned Node and borrowed NodeRef values respectively. They accumulate parse errors instead of panicking:
optional_jid method handles both NodeValue variants:
- If the attribute is a
NodeValue::Jid, it returns the JID directly via clone (zero parse cost). - If the attribute is a
NodeValue::String, it parses viaJid::from_str. Parse failures are captured in the error list and surfaced when you callfinish(), rather than being silently discarded.
BinaryError::Jid (for AttrParser) or BinaryError::AttrParse (for AttrParserRef) instead of silently returning None.
Location: wacore/binary/src/attrs.rs
Unpacking
Reverse of the packing process:read_packed; the value mapping above still describes what each nibble decodes to for NIBBLE_8. HEX_8 unpacking is the mirror of HEX_ENC shown earlier — 0–9 to digits, 10–15 to A–F — not the nibble table above.
Location: wacore/binary/src/decoder.rs:400-450
Performance Optimizations
Token interning with Cow
When converting decodedNodeRef values to owned Node values, the intern_cow function maps known protocol strings to their static references using the unified hashify lookup:
hashify::tiny_map! with length-bucketed dispatch. The resulting single lookup is O(1) and hash-free. Since the vast majority of node tags and attribute keys are part of the WhatsApp token dictionary, this eliminates most heap allocations during protocol decoding.
This optimization applies to:
Node.tag— protocol tags like"message","iq","receipt"Attrskeys — attribute names like"id","type","to","from"
Jid.server is now a Server enum (a Copy type, #[repr(u8)]), so it requires no allocation at all — neither heap nor interning. This is an improvement over the previous Cow<'static, str> approach.wacore/binary/src/node.rs:108-123
Two-Pass Encoding
For large or variable-size payloads, exact size calculation prevents buffer growth:wacore/binary/src/marshal.rs:67-76
String hint cache
Repeated strings (like JIDs) are analyzed once and cached. Strings longer thanPACKED_MAX (127 bytes) are immediately classified as RawBytes without running the full classification logic, since they can never be protocol tokens (max 48 bytes), packed nibble/hex, or JIDs:
write_string_uncached), where strings exceeding PACKED_MAX are emitted directly as raw bytes without classification. This avoids unnecessary work for long strings like message bodies, media URLs, and base64-encoded payloads.
Location: wacore/binary/src/encoder.rs:240-287
Capacity estimation
Auto-sizing strategy samples node structure to estimate capacity. TheCow<'static, str> tag on owned Node works transparently since Cow implements Deref<Target = str>:
wacore/binary/src/marshal.rs:167-200
Inline attribute storage
Attrs uses AttrsVec = SmallVec<[(Cow<'static, str>, NodeValue); 2]> instead of a plain Vec. The SmallVec stores up to 2 entries inline inside the struct itself, eliminating the per-node heap allocation for the attribute buffer on the encode hot path.
Inline capacity 2 is the measured optimum: the per-recipient fanout nodes (to with 1 attr, enc with 2) stay inline, while stanza roots with 3+ attrs spill once per stanza. A larger inline array (4) grows Node from ~184 bytes to ~296 bytes — moving nodes through children Vecs then costs more than the spared spills save.
Impact (iai-callgrind vs. Vec backing):
marshal_allocating(typical stanza): −6.6% instructionsmarshal_many_children_allocating(2048 children): −9.9% instructions, −25% RAM hits- Allocation count per DM stanza: −27% (15→11 allocs); per group stanza with 800 participants: −40% (4012→2412 allocs)
spilled() method on SmallVec reports whether a given node’s attrs overflowed to the heap — useful in allocation tests (see wacore/binary/tests/attrs_inline_alloc.rs).
Location: wacore/binary/src/node.rs, wacore/binary/tests/attrs_inline_alloc.rs
Unboxed content, byte-pair unpacking
Two morewacore/binary decode-path changes worth knowing about if you’re timing this yourself:
NodeRef::contentdropped itsBox(see the note under Zero-copy decoding). The saved allocation only exists on nodes that carry content, so the gain tracks how much of a decode is content-bearing: instructions on a group-fanout decode (one childNodeRefper device) drop 9.5%, at the cost of growingNodeReffrom 48 to 72 bytes. A content-less node like a bare<ack/>makes the same allocations either way.read_packedunpacks through a 256-entry byte-pair table instead of a per-byte scalar loop (see Unpacking). This was the larger win: a small-stanza decode that mixes a JID pair, nibble-packed, and hex-packed values dropped 15.5% in instructions.
read_packed. Unboxing NodeRef::content does change a public field’s type — see the caller migration (as_deref() → as_ref()) noted above — but no method signature changes.
Common protocol patterns
IQ (info/query) stanzas
Messages
Receipts
Nack reasons
When a stanza fails terminally (unparseable proto, missing message secret, exceeded retry budget, …) the client emits a<nack reason="…"> so the server stops retransmitting. v0.6 introduced the wacore::protocol::nack::NackReason enum which mirrors WA Web’s full set:
ParsingError for malformed binary, InvalidProtobuf for wa::Message decode failures, MaxRetryReached after the PDO recovery state machine gives up, and so on. Consumers building custom transports can reuse the enum to produce wire-compatible nacks.
Manual stanza acknowledgement
The automatic receive pipeline already acks and nacks stanzas as it processes them. Callers that intercept raw nodes themselves — custom transports, replay tooling, mock servers — can respond explicitly instead:acknowledge_stanza sends a plain <ack/> built from the stanza’s id/from (and, for message stanzas, the local device’s PN — this fails with StanzaResponseError::MissingLocalIdentity if pairing hasn’t completed). It always preserves the stanza’s participant attribute, unlike the automatic pipeline, which omits a participant that merely duplicates the from JID on <receipt> stanzas.
reject_stanza sends <ack error="…"> built from a StanzaRejection:
invalid_protobuf is the only constructor that can carry a failure_reason — the typed detail from a wa::Message decode failure; every other rejection reason encodes None.
acknowledge_stanza accepts any stanza class; reject_stanza accepts only message, receipt, and notification stanzas, unless rejection.reason() is NackReason::UnrecognizedStanza, in which case any class is accepted — matching the protocol’s own catch-all nack path. Malformed input (missing id/from, or reject_stanza called on an unsupported class) is returned to the caller as a typed StanzaResponseError instead of being silently dropped, unlike the tolerant automatic receive path:
Wire format examples
Simple Message
Message with Body
Debugging Tools
Inspecting encoded data
Useevcxr REPL for interactive exploration:
Error Handling
Jid variant is emitted by AttrParser::optional_jid when a string attribute fails to parse as a JID. This ensures malformed JIDs in protocol messages are surfaced as typed errors rather than silently ignored.
The EmptyData variant is emitted by check_plain_payload (and unpack/unpack_bytes) for a buffer with nothing in it, or — for check_plain_payload specifically — a format byte with no node bytes behind it.
The UnexpectedFormatByte(u8) variant is emitted by check_plain_payload — and, through it, by unmarshal_packed_ref and Client::send_raw_bytes — when a buffer that should be a packed payload doesn’t start with FORMAT_PLAIN. The typical cause is passing node bytes (e.g. OwnedNodeRef::backing_bytes()) where a packed payload is expected, instead of packing them first; see The format byte.
The MaxDepthExceeded variant is emitted by read_node_ref when a decoded frame nests LIST nodes past MAX_NODE_DEPTH (128 levels) — see Node nesting depth cap. Since real stanza trees never approach this depth, callers can treat it the same as any other malformed-frame error (drop the connection / reject the frame).
Location: wacore/binary/src/error.rs
Related Components
- Signal Protocol - How messages are encrypted before marshaling
- WebSocket Handling - How binary data is framed and transmitted
- State Management - Protocol state stored in Device
References
- Source:
wacore/binary/src/ - Token dictionary:
wacore/binary/src/token.rs - Node builder:
wacore/binary/src/builder.rs