Wire protocol
What Zycord nodes say to each other, and the rules that keep a peer protocol from becoming an amplifier. A guide to the normative specification, not a replacement for it.
The specification is spec/: the parameter files and the vector corpus. Where this page and the tree disagree, the tree is right — an implementation is conformant when it reproduces the genesis id and passes the vectors, and nothing here changes that. What follows is a map of where the requirements are.
Transport#
TCP, then TLS 1.3. Peer identity is an Ed25519 key carried in a self-signed X.509 certificate, presented by both sides. Certificate chains are not validated — there is no certificate authority and nothing for one to attest — so verification requires exactly one certificate, requires its public key to be Ed25519, and extracts that key as the peer's identity.
The property obtained is channel binding to a key, not authorisation: TLS guarantees that whoever completed the handshake holds the private key for the identity presented, and that the stream cannot be read or modified in transit. It says nothing about whether that peer is honest. Nothing in the protocol grants authority on the basis of identity.
Certificate validity dates are fixed constants rather than a window around the current time, on purpose: a relative window makes certificate acceptance depend on clock agreement, and a network that partitions along clock skew is a network that partitions for a reason unrelated to consensus.
Implementations must not reuse a key from any other layer as the peer identity. The node generates a fresh one on every start and never writes it to disk.
Framing#
offset size field
0 4 length uint32, little-endian - payload length, excluding this header
4 1 kind uint8 - see below
5 n payload
lengthmust be checked againstMaxMessageBytes= 8 MiB before any allocation. A receiver that allocates on a claimed length has a remote memory-exhaustion bug regardless of what follows.kindmust be in[1, 9]. Zero and anything above the highest known kind are a protocol violation, not an unknown extension: there is no forward-compatibility escape hatch at protocol 1, because a version field checked at the handshake makes one unnecessary.
Message kinds#
| Value | Name | Direction | Payload |
|---|---|---|---|
| 1 | hello | both, first | Handshake |
| 2 | certificate | gossip | ssz(Certificate) |
| 3 | block-announce | gossip | Header plus certificate ids |
| 4 | get-block | request | 32-byte block id ‖ u32 chunk index |
| 5 | block | response | One chunk of ssz(Block) |
| 6 | get-headers | request | Locator |
| 7 | headers | response | Header run |
| 8 | get-peers | request | empty |
| 9 | peers | response | Address list |
There is no network-specific encoding of a consensus object, which is what makes "the id of what I received" a checkable statement.
Every inbound message costs its sender#
This is the part of the specification most worth reading in full, because it is where a peer protocol usually leaks. Two rules govern it.
The cost-ordering rule#
Work is done in increasing order of cost, and a message is charged before the expensive step that follows it. A receiver that verifies first and scores afterwards has built an amplifier: the cheap thing to send is the expensive thing to check.
Every outcome is priced#
There is no unnamed Free. Every message kind, crossed with every
outcome it can produce, appears in a table with a score attached. An outcome that falls through the
table without being charged is the defect this rule exists to prevent — and it has been
reached in practice by narrower routes than an unpriced row: an implementation that diverted a
malformed frame away from its only scorer made that frame free for as long as a request was
outstanding, without adding a row to any table.
Serving is metered too. Block bytes are the one response orders of magnitude larger than the request that asks for it, so they carry their own byte budget on top of the request count.
Connection management, and the eclipse defence#
This section carries more measured failures than any other, and each requirement below exists because an implementation without it was eclipsed in a measurement.
- Outbound targets must be selected with address diversity, so one hosting range cannot fill a node's outbound slots.
- Outbound targets must also be bounded per gossip source. Address diversity alone is not enough, for a reason that is arithmetic rather than judgement: an address group is a property of an address an attacker owns, but an address a peer claims is bytes it invented, and any four bytes are a valid IPv4 host. An attacker with no addresses at all mints a fresh diversity group per string for the price of one frame. What it cannot invent is the connection the claim arrived on.
- The socket an inbound connection arrived on must not become an outbound target. It is the peer's source address — an ephemeral port its OS chose, not something it listens on — so a slot spent dialling it is a slot spent on an address that cannot answer.
- Both bounds must be counted against the connections a node holds, not against one selection call. Otherwise a dial loop that excludes already-connected peers rebuilds both budgets full every round, and the bound delays an attacker by one round per allowance instead of bounding it. Measured: one teller took 2, then 4, then 6, then 8 of 8 outbound slots over four rounds.
- The peer store must be persisted, and must be bounded. A node that starts blank after every restart hands an attacker a fresh opportunity on every restart; one that starts with the attacker's slate hands it the same opportunity permanently.
- A bounded store must not refuse a well-formed address because it is full; it evicts for it. A never-contacted honest address is never better than a never-contacted invented one, so an attacker who fills the store first locks it shut against everything offered afterwards — the operator's own bootstrap list included.
- What eviction chooses matters more than that it happens. "Take the victim from the largest population" reads as a flood displaces itself, and is that only while the flood is the largest thing in the store. On a node bootstrapped from one helpful peer, the largest population is that peer's address book. Measured on an implementation ordered that way: 200 invented addresses from one source evicted 200 honest ones at no cost to the attacker.
- Among indistinguishable entries the final tie-break must not be anything the gossiping peer chooses — the address included — and this applies to selection exactly as it applies to eviction. Measured on an implementation whose selector fell through to the address string: 8 honest addresses against 8 invented ones returned 8 of 8 invented, and zero honest outbound connections over ten rounds.
What is deliberately absent#
| Absent | Why |
|---|---|
| NAT traversal | The cost is stated and the reopen condition is measured rather than assumed: the share of dialable nodes on the public testnet is the first condition on reopening the decision. |
| Compression | A compressor on an attacker-controlled stream is an attack surface, for a bandwidth saving nobody has measured a need for. |
| Message-level signatures | The transport authenticates the channel; consensus objects carry their own signatures. A third layer would authenticate the relay, which is not something any decision depends on. |
| Request ids | Request and response are matched by connection and order, so sync runs on its own connection — which removes a state machine. Where a peer is not dialable, sync may run over an existing gossip connection and must then match responses by content. |
| A forward-compatible extension mechanism | Protocol 1 checks its version at the handshake and disconnects on mismatch. Extensions arrive as protocol 2. |
What a certificate id does not cover#
Worth restating here because it decides a relay rule. A certificate's id commits to what it authorizes and never to what it merely demonstrates, so signatures sit outside the id's preimage. That makes evidence something anyone in transit can replace: take a certificate, substitute garbage for its signature, and propagate — same id, now-invalid exemplar.
Two rules close it. A block commits to the evidence it carries, through a root over exemplar hashes rather than over ids. And relay treats exemplars, not ids: a node that receives an exemplar whose evidence fails verification discards that exemplar without prejudice to the id — the id is not marked, not cached as invalid, and a later exemplar that verifies relays normally. A mutilated copy costs its mutilator the bandwidth of sending it and costs the certificate nothing.
Implementing this from scratch#
An independent implementation that passes
the golden vectors is a peer, not a fork — that
is the whole point of specifying it this way. Start with
spec/README.md for the consensus objects, then check
your work with zcd vectors.