Skip to content

Networking & the Wire Protocol

Calimero is built on libp2p. Nodes connect over TCP (with Noise/TLS) or QUIC, multiplex streams with Yamux, and run a set of behaviours for discovery, gossip, and direct exchange. The application logic sits on top and never touches sockets directly — it speaks in terms of topics and streams.

Calimero node logic — contexts · governance · syncgossipsubeager broadcast (topics)deltas · governance · heartbeatsstreams · request-responsepull, point-to-pointsync · snapshots · blobs · key recoverydiscovery — rendezvous · mDNS · Kademlia DHT · relay / hole-punch for NATtransport — TCP + Noise/TLS · QUIC · Yamux multiplexingauthenticated by transport identity (Chapter 2)

Every libp2p protocol Calimero speaks is version-pinned in its name. The identify protocol carries the crate version directly — /calimero/<pkg>/<version>, built as concat!("/", CARGO_PKG_NAME, "/", CARGO_PKG_VERSION) — and each behaviour names a fixed version:

Protocol name Carries
/calimero/stream/0.0.2 the sync stream (heads, tree-diff, snapshots, key recovery)
/calimero/blob/0.0.2 the dedicated blob transfer stream
/calimero/kad/1.0.0 the Kademlia DHT (blob discovery)

libp2p only opens a stream when both peers advertise the exact same protocol name — the version is part of that string, so a peer running /calimero/blob/0.0.2 and one running a future /calimero/blob/0.0.3 simply never negotiate that protocol. The connection still forms (transport and identify succeed), but the mismatched sub-protocol is silently unavailable rather than erroring: there is no version-negotiation handshake that downgrades or reports the skew.

For operators this means a mixed-version fleet fails quietly. Two nodes can be connected and gossiping yet unable to, say, transfer blobs or run a sync, because one side bumped a protocol version the other doesn’t offer. Roll versions out fleet-wide rather than piecemeal, and treat “connected but never syncs” as a possible version-skew symptom. (crates/network/src/behaviour.rs, crates/network/primitives/src/stream.rs.)

Everything on the wire travels one of two ways, and the choice is about eager vs. on-demand:

  • Gossip (eager broadcast) — A node publishes to a topic; every subscriber receives it through the gossipsub mesh. This is how fresh operations propagate the instant they’re created — fire it once, the mesh fans it out. Best-effort: a node that was offline simply misses it.
  • Streams (pull, point-to-point) — A node opens a direct, length-delimited stream to one peer and asks for something specific — missing operations, a snapshot, a blob, a key. This is how a node catches up on anything gossip didn’t deliver. Reliable and targeted.

Gossip keeps everyone roughly current; streams make them exactly current. Sync & convergence is essentially “the stream channel, driven by the scope-root comparison.”

Subscriptions map directly onto scopes, so a node only hears about what it belongs to:

  • context/<id> — a context’s data operations and heartbeats.
  • group/<id> — a group’s governance operations.
  • ns/<id> — a namespace’s root governance operations and heartbeats.

Gossip’s mesh is membership-biased: peers known to be members are preferred when forming the mesh, while unknown peers are never penalized — so a healthy cluster stays well-connected without excluding newcomers during cold start.

The gossipsub behaviour is configured for Calimero’s reality — small, governance-gated clusters — not the large permissionless swarms libp2p’s defaults assume. Three deliberate departures from those defaults are worth understanding.

The mesh water marks are tuned for clusters of roughly 2–20 peers. libp2p’s defaults assume larger swarms, so in (say) a 3-node cluster the default mesh_n_low=5 is permanently unreachable: every heartbeat logs Mesh low and re-runs peer selection for no candidates. Matching the water marks to the expected cluster size keeps the mesh at steady state.

Parameter Calimero libp2p default
mesh_n_low 2 5
mesh_n 4 6
mesh_n_high 8 12
mesh_outbound_min 1 2

mesh_outbound_min=1 deliberately drops libp2p’s default of 2. In a large public swarm that default is the standard defence against an inbound-only Sybil cluster monopolising a node’s mesh. Calimero topics are admission-gated by signed governance membership — a non-member can subscribe at the transport but their messages are rejected at the governance/cryptographic layer — so the Sybil-via-subscription vector that motivates the default isn’t load-bearing here. (libp2p also enforces the invariant mesh_outbound_min ≤ mesh_n_low / 2, so with mesh_n_low=2 the only legal value is 1.)

mesh_n_low is also a coupling point: the governance-broadcast readiness gate reads the same GOSSIPSUB_MESH_N_LOW constant as the upper bound on how many subscribers a publish requires (required = min(GOSSIPSUB_MESH_N_LOW, known_subscribers)). The two are intentionally driven from one source of truth — a mismatch would either reject healthy publishes or admit them on an unhealthy mesh.

flood_publish: fan out to every subscriber

Section titled “flood_publish: fan out to every subscriber”

With flood_publish=true, calling publish() fans the message out to every subscribed peer, not just the grafted mesh peers. For Calimero’s small topics this is cheap, and it removes the cold-start window where the mesh hasn’t formed yet and a freshly-created delta would simply drop. It is safe only because every topic is governance-gated: bypassing the mesh also bypasses gossipsub’s per-peer scoring and prune-backoff on the publish path, but a non-member’s forwarded or published messages are rejected at the governance layer before they can influence application state, so scoring-based abuse mitigation isn’t load-bearing on this path.

Application-specific peer scoring biases mesh maintenance toward verified members, but it is configured so it can only ever prefer a peer, never exclude one:

  • Only app_specific_weight contributes to a peer’s score. Every traffic- and behaviour-dependent weight — per-topic delivery, IP-colocation, behaviour penalty, slow-peer — is zeroed, so the score reflects verified-membership knowledge and nothing else.
  • The node only ever pushes non-negative app scores: it boosts authenticated namespace/group members and leaves unknown peers at exactly 0.
  • Every gating threshold is ≤ 0 — gossip -10, publish -50, graylist -80.

An unknown peer therefore sits at score 0, above all three cutoffs, and is never graylisted for being unverified. Scoring tiers verified members higher for mesh slots without ever penalizing a newcomer during cold start.

All messages are Borsh-encoded (compact, deterministic binary). They fall into the two channels:

Channel Message Carries
Gossip StateDelta a context’s data operation(s): author, parents, hlc, encrypted artifact, new root, signature.
Gossip GovernanceDelta a governance operation (root op in clear, group op encrypted).
Gossip Heartbeat current DAG heads + root, published periodically for divergence detection.
Stream DeltaRequest / Response fetch a specific operation by id.
Stream DagHeadsRequest / Response exchange heads + roots during a sync handshake.
Stream TreeNode / LevelWise compare state trees to locate exactly what differs (sync).
Stream Snapshot* transfer a full materialized scope when incremental sync isn’t enough.
Stream KeyRequest / Response recover a scope key wrapped to the requester.

Confidentiality is defense-in-depth, and the three layers map exactly onto the three keys:

  • Transport — Noise/TLS encrypts every connection, authenticated by the peers’ transport identities.
  • Payload — an operation’s sensitive content is encrypted under its scope key, so even a subscribed non-member on the topic sees only ciphertext.
  • Key wrapping — scope keys themselves move ECDH-wrapped to a recipient’s member key, never in the clear.

Root operations that must be readable by everyone in a namespace (a join announcement, a key delivery envelope) travel in clear at the payload layer but are still inside the encrypted transport.

Every message is Borsh. Recall the encoding: an enum is a single-byte variant index then its fields in order; a Vec<u8> is a u32 little-endian length then the bytes; an Option is a 1-byte present/absent flag.

Point-to-point messages are wrapped in a StreamMessage with a rolling nonce:

Variant Fields
Init context_id: ContextId, party_id: PublicKey, payload: InitPayload, next_nonce: Nonce
Message sequence_id: u64, payload: MessagePayload, next_nonce: Nonce
OpaqueError — (failure without leaking state)
NotMaterialized — (member, but hasn’t joined this context yet)

Stream requests (InitPayload) and responses (MessagePayload)

Section titled “Stream requests (InitPayload) and responses (MessagePayload)”
Request Response Purpose
DagHeadsRequest DagHeadsResponse { dag_heads: Vec<[u8;32]>, root_hash: Hash, scope_root: Option<Hash> } sync handshake
DeltaRequest { delta_id } DeltaResponse { delta, author_id, governance_position_blob?, delta_signature?: [u8;64] } fetch one op
TreeNodeRequest { node_id, max_depth?: u8 } TreeNodeResponse { nodes: Vec<TreeNode>, not_found } tree-diff sync
LevelWiseRequest { level: u32, parent_ids? } LevelWiseResponse { level, nodes, has_more_levels, deleted_children } level-wise sync
SnapshotStreamRequest { boundary_root_hash, page_limit: u16, byte_limit: u32, resume_cursor? } SnapshotPage { payload (lz4), uncompressed_len, cursor?, total_records } full transfer
GroupKeyRequest { group_id, requester_public_key } GroupKeyResponse { key_envelope_bytes, responder_identity } key recovery

The data-plane broadcast carries the operation and everything a peer needs to verify, decrypt, and authorize it:

StateDelta {
context_id: ContextId, author_id: PublicKey,
delta_id: [u8;32], parent_ids: Vec<[u8;32]>, hlc: HybridTimestamp,
root_hash: Hash,
artifact: bytes, // ENCRYPTED under the scope key
nonce: Nonce, events: Option<bytes>, // events also encrypted
governance_position: Option<GovernanceParentEdge>, // the authorization cut
key_id: [u8;32], // = SHA-256(scope_key)
delta_signature: Option<[u8;64]>, producing_app_key: Option<[u8;32]>,
}
Other broadcast Fields
HashHeartbeat context_id, root_hash: Hash, dag_heads: Vec<[u8;32]>
NamespaceGovernanceDelta namespace_id, delta_id, parent_ids, payload: Vec<u8> (signed gov op)
NamespaceStateHeartbeat namespace_id, dag_heads: Vec<[u8;32]>
Constant Value Constant Value
tree request depth 16 DAG heads / msg 100
signed gov op payload 64 KiB snapshot page (default) 256 KiB
entities / page 1000 decompressed page cap 8 MiB

Signatures are fixed [u8;64] (Ed25519); hashes and ids are [u8;32]; a Nonce is the crypto-layer nonce, distinct from the raw [u8;32] nonces used by node-discovery messages.

We can now move operations between nodes — but gossip is best-effort, so some never arrive. Next, Sync & Convergence is the reliable channel that finds whatever the eager broadcast missed and repairs it by comparing scope roots.