Logos LIP Index
An IETF-style index of Logos-managed LIPs across Storage, Messaging, Blockchain and IFT-TS components. Use the filters below to jump straight to a specification.
About
The Logos LIP Index collects specifications maintained by IFT-TS across Messaging, Blockchain, and Storage. Each RFC documents a protocol, process, or system in a consistent, reviewable format.
This site is generated with mdBook from the repository: vacp2p/rfc-index.
Contributing
- Open a pull request against the repo.
- Add or update the RFC in the appropriate component folder.
- Include clear status and category metadata in the RFC header table.
If you are unsure where a document belongs, open an issue first and we will help route it.
We keep RFCs in Markdown within this repository; updates happen through pull requests.
Links
- IFT-TS: https://vac.dev
- IETF RFC Series: https://www.rfc-editor.org/
- Repository: https://github.com/vacp2p/rfc-index
Messaging LIPs
Logos Messaging builds a family of privacy-preserving, censorship-resistant communication protocols for web3 applications.
Contributors can visit Messaging LIPs for new Messaging specifications under discussion.
Waku Standards - Core
Core Waku protocol specifications, including messaging, peer discovery, and network primitives.
10/WAKU2
| Field | Value |
|---|---|
| Name | Waku v2 |
| Slug | 10 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Sanaz Taheri [email protected], Hanno Cornelius [email protected], Reeshav Khan [email protected], Daniel Kaiser [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-15 —
34aa3f3— Fix links 10/WAKU2 (#153) - 2025-04-09 —
cafa04f— 10/WAKU2: Update (#125) - 2024-11-20 —
ff87c84— Update Waku Links (#104) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
8e14d58— Update waku2.md - 2024-02-01 —
6cf68fd— Update waku2.md - 2024-02-01 —
6734b16— Update waku2.md - 2024-01-31 —
356649a— Update and rename WAKU2.md to waku2.md - 2024-01-27 —
550238c— Rename README.md to WAKU2.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-26 —
d6651b7— Update README.md - 2024-01-25 —
6e98666— Rename README.md to README.md - 2024-01-25 —
9b740d8— Rename waku/10/README.md to waku/specs/standards/core/10-WAKU2/README.md - 2024-01-24 —
330c35b— Create README.md
Abstract
Waku is a family of modular peer-to-peer protocols for secure communication. The protocols are designed to be secure, privacy-preserving, censorship-resistant and being able to run in resource-restricted environments. At a high level, it implements Pub/Sub over libp2p and adds a set of capabilities to it. These capabilities are things such as: (i) retrieving historical messages for mostly-offline devices (ii) adaptive nodes, allowing for heterogeneous nodes to contribute to the network (iii) preserving bandwidth usage for resource-restriced devices
This makes Waku ideal for running a p2p protocol on mobile devices and other similar restricted environments.
Historically, it has its roots in 6/WAKU1, which stems from Whisper, originally part of the Ethereum stack. However, Waku acts more as a thin wrapper for Pub/Sub and has a different API. It is implemented in an iterative manner where initial focus is on porting essential functionality to libp2p. See rough road map (2020) for more historical context.
Motivation and Goals
Waku, as a family of protocols, is designed to have a set of properties that are useful for many applications:
1.Useful for generalized messaging.
Many applications require some form of messaging protocol to communicate between different subsystems or different nodes. This messaging can be human-to-human, machine-to-machine or a mix. Waku is designed to work for all these scenarios.
2.Peer-to-peer.
Applications sometimes have requirements that make them suitable for peer-to-peer solutions:
- Censorship-resistant with no single point of failure
- Adaptive and scalable network
- Shared infrastructure
3.Runs anywhere.
Applications often run in restricted environments, where resources or the environment is restricted in some fashion. For example:
- Limited bandwidth, CPU, memory, disk, battery, etc.
- Not being publicly connectable
- Only being intermittently connected; mostly-offline
4.Privacy-preserving.
Applications often have a desire for some privacy guarantees, such as:
- Pseudonymity and not being tied to any personally identifiable information (PII)
- Metadata protection in transit
- Various forms of unlinkability, etc.
5.Modular design.
Applications often have different trade-offs when it comes to what properties they and their users value. Waku is designed in a modular fashion where an application protocol or node can choose what protocols they run. We call this concept adaptive nodes.
For example:
- Resource usage vs metadata protection
- Providing useful services to the network vs mostly using it
- Stronger guarantees for spam protection vs economic registration cost
For more on the concept of adaptive nodes and what this means in practice, please see the 30/ADAPTIVE-NODES spec.
Specification
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Network Interaction Domains
While Waku is best thought of as a single cohesive thing, there are three network interaction domains:
(a) gossip domain (b) discovery domain (c) request/response domain
Protocols and Identifiers
Since Waku is built on top of libp2p, many protocols have a libp2p protocol identifier. The current main protocol identifiers are:
/vac/waku/relay/2.0.0/vac/waku/store-query/3.0.0/vac/waku/filter/2.0.0-beta1/vac/waku/lightpush/2.0.0-beta1
This is in addition to protocols that specify messages, payloads, and recommended usages. Since these aren't negotiated libp2p protocols, they are referred to by their RFC ID. For example:
- 14/WAKU2-MESSAGE and 26/WAKU-PAYLOAD for message payloads
- 23/WAKU2-TOPICS and 27/WAKU2-PEERS for recommendations around usage
There are also more experimental libp2p protocols such as:
/vac/waku/waku-rln-relay/2.0.0-alpha1/vac/waku/peer-exchange/2.0.0-alpha1
The semantics of these protocols are referred to by RFC ID 17/WAKU2-RLN-RELAY and 34/WAKU2-PEER-EXCHANGE.
Use of libp2p and Protobuf
Unless otherwise specified, all protocols are implemented over libp2p and use Protobuf by default. Since messages are exchanged over a bi-directional binary stream, as a convention, libp2p protocols prefix binary message payloads with the length of the message in bytes. This length integer is encoded as a protobuf varint.
Gossip Domain
Waku is using gossiping to disseminate messages throughout the network.
Protocol identifier: /vac/waku/relay/2.0.0
See 11/WAKU2-RELAY specification for more details.
For an experimental privacy-preserving economic spam protection mechanism, see 17/WAKU2-RLN-RELAY.
See 23/WAKU2-TOPICS for more information about the recommended topic usage.
Direct use of libp2p protocols
In addition to /vac/waku/* protocols,
Waku MAY directly use the following libp2p protocols:
- libp2p ping protocol with protocol id
/ipfs/ping/1.0.0
for liveness checks between peers, or to keep peer-to-peer connections alive.
- libp2p identity and identity/push with protocol IDs
/ipfs/id/1.0.0
and
/ipfs/id/push/1.0.0
respectively, as basic means for capability discovery. These protocols are anyway used by the libp2p connection establishment layer Waku is built on. We plan to introduce a new IFT-TS capability discovery protocol with better anonymity properties and more functionality.
Transports
Waku is built in top of libp2p, and like libp2p it strives to be transport agnostic. We define a set of recommended transports in order to achieve a baseline of interoperability between clients. This section describes these recommended transports.
Waku client implementations SHOULD support the TCP transport. Where TCP is supported it MUST be enabled for both dialing and listening, even if other transports are available.
Waku nodes running in environments that do not allow the use of TCP directly, MAY use other transports.
A Waku node SHOULD support secure websockets for bidirectional communication streams, for example in a web browser context.
A node MAY support unsecure websockets if required by the application or running environment.
Discovery Domain
Discovery Methods
Waku can retrieve a list of nodes to connect to using DNS-based discovery as per EIP-1459. While this is a useful way of bootstrapping connection to a set of peers, it MAY be used in conjunction with an ambient peer discovery procedure to find other nodes to connect to, such as Node Discovery v5. It is possible to bypass the discovery domain by specifying static nodes.
Use of ENR
WAKU2-ENR
describes the usage of EIP-778 ENR (Ethereum Node Records)
for Waku discovery purposes.
It introduces two new ENR fields, multiaddrs and
waku2, that a Waku node MAY use for discovery purposes.
These fields MUST be used under certain conditions, as set out in the specification.
Both EIP-1459 DNS-based discovery and Node Discovery v5 operate on ENR,
and it's reasonable to expect even wider utility for ENR in Waku networks in the future.
Request/Response Domain
In addition to the Gossip domain, Waku provides a set of request/response protocols. They are primarily used in order to get Waku to run in resource restricted environments, such as low bandwidth or being mostly offline.
Historical Message Support
Protocol identifier*: /vac/waku/store-query/3.0.0
This is used to fetch historical messages for mostly offline devices. See 13/WAKU2-STORE spec specification for more details.
There is also an experimental fault-tolerant addition to the store protocol that relaxes the high availability requirement. See 21/WAKU2-FAULT-TOLERANT-STORE
Content Filtering
Protocol identifier*: /vac/waku/filter/2.0.0-beta1
This is used to preserve more bandwidth when fetching a subset of messages. See 12/WAKU2-FILTER specification for more details.
LightPush
Protocol identifier*: /vac/waku/lightpush/2.0.0-beta1
This is used for nodes with short connection windows and limited bandwidth to publish messages into the Waku network. See 19/WAKU2-LIGHTPUSH specification for more details.
Other Protocols
The above is a non-exhaustive list, and due to the modular design of Waku, there may be other protocols here that provide a useful service to the Waku network.
Overview of Protocol Interaction
See the sequence diagram below for an overview of how different protocols interact.

-
We have six nodes, A-F. The protocols initially mounted are indicated as such. The PubSub topics
pubtopic1andpubtopic2is used for routing and indicates that it is subscribed to messages on that topic for relay, see 11/WAKU2-RELAY for details. Ditto for 13/WAKU2-STORE where it indicates that these messages are persisted on that node. -
Node A creates a WakuMessage
msg1with a ContentTopiccontentTopic1. See 14/WAKU2-MESSAGE for more details. If WakuMessage version is set to 1, we use the 6/WAKU1 compatibledatafield with encryption. See 7/WAKU-DATA for more details. -
Node F requests to get messages filtered by PubSub topic
pubtopic1and ContentTopiccontentTopic1. Node D subscribes F to this filter and will in the future forward messages that match that filter. See 12/WAKU2-FILTER for more details. -
Node A publishes
msg1onpubtopic1and subscribes to that relay topic. It then gets relayed further from B to D, but not C since it doesn't subscribe to that topic. See 11/WAKU2-RELAY. -
Node D saves
msg1for possible later retrieval by other nodes. See 13/WAKU2-STORE. -
Node D also pushes
msg1to F, as it has previously subscribed F to this filter. See 12/WAKU2-FILTER. -
At a later time, Node E comes online. It then requests messages matching
pubtopic1andcontentTopic1from Node D. Node D responds with messages meeting this (and possibly other) criteria. See 13/WAKU2-STORE.
Appendix A: Upgradability and Compatibility
Compatibility with Waku Legacy
6/WAKU1 and Waku are different protocols all together. They use a different transport protocol underneath; 6/WAKU1 is devp2p RLPx based while Waku uses libp2p. The protocols themselves also differ as does their data format. Compatibility can be achieved only by using a bridge that not only talks both devp2p RLPx and libp2p, but that also transfers (partially) the content of a packet from one version to the other.
See 15/WAKU-BRIDGE for details on a bidirectional bridge mode.
Appendix B: Security
Each protocol layer of Waku provides a distinct service and is associated with a separate set of security features and concerns. Therefore, the overall security of Waku depends on how the different layers are utilized. In this section, we overview the security properties of Waku protocols against a static adversarial model which is described below. Note that a more detailed security analysis of each Waku protocol is supplied in its respective specification as well.
Primary Adversarial Model
In the primary adversarial model, we consider adversary as a passive entity that attempts to collect information from others to conduct an attack, but it does so without violating protocol definitions and instructions.
The following are not considered as part of the adversarial model:
- An adversary with a global view of all the peers and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of peers (unless the adversary is one end of the communication). Specifically, the communication channels are assumed to be secure.
Security Features
Pseudonymity
Waku by default guarantees pseudonymity for all of the protocol layers
since parties do not have to disclose their true identity
and instead they utilize libp2p PeerID as their identifiers.
While pseudonymity is an appealing security feature,
it does not guarantee full anonymity since the actions taken under the same pseudonym
i.e., PeerID can be linked together and
potentially result in the re-identification of the true actor.
Anonymity / Unlinkability
At a high level, anonymity is the inability of an adversary in linking an actor to its data/performed action (the actor and action are context-dependent). To be precise about linkability, we use the term Personally Identifiable Information (PII) to refer to any piece of data that could potentially be used to uniquely identify a party. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII. Notice that users' actions can be traced through their PIIs (e.g., signatures) and hence result in their re-identification risk. As such, we seek anonymity by avoiding linkability between actions and the actors / actors' PII. Concerning anonymity, Waku provides the following features:
Publisher-Message Unlinkability:
This feature signifies the unlinkability of a publisher
to its published messages in the 11/WAKU2-RELAY protocol.
The Publisher-Message Unlinkability
is enforced through the StrictNoSign policy due to which the data fields
of pubsub messages that count as PII for the publisher must be left unspecified.
Subscriber-Topic Unlinkability: This feature stands for the unlinkability of the subscriber to its subscribed topics in the 11/WAKU2-RELAY protocol. The Subscriber-Topic Unlinkability is achieved through the utilization of a single PubSub topic. As such, subscribers are not re-identifiable from their subscribed topic IDs as the entire network is linked to the same topic ID. This level of unlinkability / anonymity is known as k-anonymity where k is proportional to the system size (number of subscribers). Note that there is no hard limit on the number of the pubsub topics, however, the use of one topic is recommended for the sake of anonymity.
Spam protection
This property indicates that no adversary can flood the system
(i.e., publishing a large number of messages in a short amount of time),
either accidentally or deliberately, with any kind of message
i.e. even if the message content is valid or useful.
Spam protection is partly provided in 11/WAKU2-RELAY
through the scoring mechanism
provided for by GossipSub v1.1.
At a high level,
peers utilize a scoring function to locally score the behavior
of their connections and remove peers with a low score.
Data confidentiality, Integrity, and Authenticity
Confidentiality can be addressed through data encryption whereas integrity and authenticity are achievable through digital signatures. These features are provided for in 14/WAKU2-MESSAGE (version 1)` through payload encryption as well as encrypted signatures.
Security Considerations
Lack of anonymity/unlinkability in the protocols involving direct connections
including 13/WAKU2-STORE and 12/WAKU2-FILTER protocols:
The anonymity/unlinkability is not guaranteed in the protocols like 13/WAKU2-STORE
and 12/WAKU2-FILTER where peers need to have direct connections
to benefit from the designated service.
This is because during the direct connections peers utilize PeerID
to identify each other,
therefore the service obtained in the protocol is linkable
to the beneficiary's PeerID (which counts as PII).
For 13/WAKU2-STORE,
the queried node would be able to link the querying node's PeerID
to its queried topics.
Likewise, in the 12/WAKU2-FILTER,
a full node can link the light node's PeerIDs to its content filter.
Appendix C: Implementation Notes
Implementation Matrix
There are multiple implementations of Waku and its protocols:
Below you can find an overview of the specifications that they implement as they relate to Waku. This includes Waku legacy specifications, as they are used for bridging between the two networks.
| Spec | nim-waku (Nim) | go-waku (Go) | js-waku (Node JS) | js-waku (Browser JS) |
|---|---|---|---|---|
| 6/WAKU1 | ✔ | |||
| 7/WAKU-DATA | ✔ | ✔ | ||
| 8/WAKU-MAIL | ✔ | |||
| 9/WAKU-RPC | ✔ | |||
| 10/WAKU2 | ✔ | 🚧 | 🚧 | ✔ |
| 11/WAKU2-RELAY | ✔ | ✔ | ✔ | ✔ |
| 12/WAKU2-FILTER | ✔ | ✔ | ||
| 13/WAKU2-STORE | ✔ | ✔ | ✔* | ✔* |
| 14/WAKU2-MESSAGE) | ✔ | ✔ | ✔ | ✔ |
| 15/WAKU2-BRIDGE | ✔ | |||
| 16/WAKU2-RPC | ✔ | |||
| 17/WAKU2-RLN-RELAY | 🚧 | |||
| 18/WAKU2-SWAP | 🚧 | |||
| 19/WAKU2-LIGHTPUSH | ✔ | ✔ | ✔** | ✔** |
| 21/WAKU2-FAULT-TOLERANT-STORE | ✔ | ✔ |
*js-waku implements 13/WAKU2-STORE as a querying node only. **js-waku only implements 19/WAKU2-LIGHTPUSH requests.
Recommendations for Clients
To implement a minimal Waku client, we recommend implementing the following subset in the following order:
- 10/WAKU2 - this specification
- 11/WAKU2-RELAY - for basic operation
- 14/WAKU2-MESSAGE - version 0 (unencrypted)
- 13/WAKU2-STORE - for historical messaging (query mode only)
To get compatibility with Waku Legacy:
- 7/WAKU-DATA
- 14/WAKU2-MESSAGE - version 1 (encrypted with
7/WAKU-DATA)
For an interoperable keep-alive mechanism:
- libp2p ping protocol, with periodic pings to connected peers
Appendix D: Future work
The following features are currently experimental, under research and initial implementations:
Economic Spam Resistance:
We aim to enable an incentivized spam protection technique
to enhance 11/WAKU2-RELAY by using rate limiting nullifiers.
More details on this can be found in 17/WAKU2-RLN-RELAY.
In this advanced method,
peers are limited to a certain rate of messaging per epoch and
an immediate financial penalty is enforced for spammers who break this rate.
Prevention of Denial of Service (DoS) and Node Incentivization:
Denial of service signifies the case where an adversarial node
exhausts another node's service capacity (e.g., by making a large number of requests)
and makes it unavailable to the rest of the system.
DoS attack is to be mitigated through the accounting model as described in 18/WAKU2-SWAP.
In a nutshell, peers have to pay for the service they obtain from each other.
In addition to incentivizing the service provider,
accounting also makes DoS attacks costly for malicious peers.
The accounting model can be used in 13/WAKU2-STORE and
12/WAKU2-FILTER to protect against DoS attacks.
Additionally, this gives node operators who provide a useful service to the network an incentive to perform that service. See 18/WAKU2-SWAP for more details on this piece of work.
Copyright
Copyright and related rights waived via CC0.
References
11/WAKU2-RELAY
| Field | Value |
|---|---|
| Name | Waku v2 Relay |
| Slug | 11 |
| Status | stable |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Oskar Thorén [email protected], Sanaz Taheri [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-01 —
b346ad2— Update relay.md - 2024-02-01 —
0904a8b— Update and rename RELAY.md to relay.md - 2024-01-27 —
8ff46fa— Rename WAKU2-RELAY.md to RELAY.md - 2024-01-27 —
4c4591c— Rename README.md to WAKU2-RELAY.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
6874961— Create README.md
11/WAKU2-RELAY specifies a Publish/Subscribe approach
to peer-to-peer messaging with a strong focus on privacy,
censorship-resistance, security and scalability.
Its current implementation is a minor extension of the
libp2p GossipSub protocol
and prescribes gossip-based dissemination.
As such the scope is limited to defining a separate
protocol id
for 11/WAKU2-RELAY, establishing privacy and security requirements,
and defining how the underlying GossipSub is to be interpreted and
implemented within the Waku and cryptoeconomic domain.
11/WAKU2-RELAY should not be confused with libp2p circuit relay.
Protocol identifier: /vac/waku/relay/2.0.0
Security Requirements
The 11/WAKU2-RELAY protocol is designed to provide the following security properties
under a static Adversarial Model.
Note that data confidentiality, integrity, and
authenticity are currently considered out of scope for 11/WAKU2-RELAY and
must be handled by higher layer protocols such as 14/WAKU2-MESSAGE.
-
Publisher-Message Unlinkability: This property indicates that no adversarial entity can link a published
Messageto its publisher. This feature also implies the unlinkability of the publisher to its published topic ID as theMessageembodies the topic IDs. -
Subscriber-Topic Unlinkability: This feature stands for the inability of any adversarial entity from linking a subscriber to its subscribed topic IDs.
Terminology
Personally identifiable information (PII) refers to any piece of data that can be used to uniquely identify a user. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII.
Adversarial Model
- Any entity running the
11/WAKU2-RELAYprotocol is considered an adversary. This includes publishers, subscribers, and all the peers' direct connections. Furthermore, we consider the adversary as a passive entity that attempts to collect information from others to conduct an attack but it does so without violating protocol definitions and instructions. For example, under the passive adversarial model, no malicious subscriber hides the messages it receives from other subscribers as it is against the description of11/WAKU2-RELAY. However, a malicious subscriber may learn which topics are subscribed to by which peers. - The following are not considered as part of the adversarial model:
- An adversary with a global view of all the peers and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of peers (unless the adversary is one end of the communication). In other words, the communication channels are assumed to be secure.
Wire Specification
The PubSub interface specification
defines the protobuf RPC messages
exchanged between peers participating in a GossipSub network.
We republish these messages here for ease of reference and
define how 11/WAKU2-RELAY uses and interprets each field.
Protobuf definitions
The PubSub RPC messages are specified using protocol buffers v2
syntax = "proto2";
message RPC {
repeated SubOpts subscriptions = 1;
repeated Message publish = 2;
message SubOpts {
optional bool subscribe = 1;
optional string topicid = 2;
}
message Message {
optional string from = 1;
optional bytes data = 2;
optional bytes seqno = 3;
repeated string topicIDs = 4;
optional bytes signature = 5;
optional bytes key = 6;
}
}
NOTE: The various control messages defined for GossipSub are used as specified there. NOTE: The
TopicDescriptoris not currently used by11/WAKU2-RELAY.
Message fields
The Message protobuf defines the format in which content is relayed between peers.
11/WAKU2-RELAY specifies the following usage requirements for each field:
-
The
fromfield MUST NOT be used, following theStrictNoSignsignature policy. -
The
datafield MUST be filled out with aWakuMessage. See14/WAKU2-MESSAGEfor more details. -
The
seqnofield MUST NOT be used, following theStrictNoSignsignature policy. -
The
topicIDsfield MUST contain the content-topics that a message is being published on. -
The
signaturefield MUST NOT be used, following theStrictNoSignsignature policy. -
The
keyfield MUST NOT be used, following theStrictNoSignsignature policy.
SubOpts fields
The SubOpts protobuf defines the format
in which subscription options are relayed between peers.
A 11/WAKU2-RELAY node MAY decide to subscribe or
unsubscribe from topics by sending updates using SubOpts.
The following usage requirements apply:
-
The
subscribefield MUST contain a boolean, wheretrueindicates subscribe andfalseindicates unsubscribe to a topic. -
The
topicidfield MUST contain the pubsub topic.
Note: The
topicidrefering to pubsub topic andtopicIdrefering to content-topic are detailed in 23/WAKU2-TOPICS.
Signature Policy
The StrictNoSign option
MUST be used, to ensure that messages are built without the signature,
key, from and seqno fields.
Note that this does not merely imply that these fields be empty, but
that they MUST be absent from the marshalled message.
Security Analysis
- Publisher-Message Unlinkability:
To address publisher-message unlinkability,
one should remove any PII from the published message.
As such,
11/WAKU2-RELAYfollows theStrictNoSignpolicy as described in libp2p PubSub specs. As the result of theStrictNoSignpolicy,Messages should be built without thefrom,signatureandkeyfields since each of these three fields individually counts as PII for the author of the message (one can link the creation of the message with libp2p peerId and thus indirectly with the IP address of the publisher). Note that removing identifiable information from messages cannot lead to perfect unlinkability. The direct connections of a publisher might be able to figure out whichMessages belong to that publisher by analyzing its traffic. The possibility of such inference may get higher when thedatafield is also not encrypted by the upper-level protocols.
- Subscriber-Topic Unlinkability:
To preserve subscriber-topic unlinkability,
it is recommended by
10/WAKU2to use a single PubSub topic in the11/WAKU2-RELAYprotocol. This allows an immediate subscriber-topic unlinkability where subscribers are not re-identifiable from their subscribed topic IDs as the entire network is linked to the same topic ID. This level of unlinkability / anonymity is known as k-anonymity where k is proportional to the system size (number of participants of Waku relay protocol). However, note that11/WAKU2-RELAYsupports the use of more than one topic. In case that more than one topic id is utilized, preserving unlinkability is the responsibility of the upper-level protocols which MAY adopt partitioned topics technique to achieve K-anonymity for the subscribed peers.
Future work
- Economic spam resistance:
In the spam-protected
11/WAKU2-RELAYprotocol, no adversary can flood the system with spam messages (i.e., publishing a large number of messages in a short amount of time). Spam protection is partly provided by GossipSub v1.1 through scoring mechanism. At a high level, peers utilize a scoring function to locally score the behavior of their connections and remove peers with a low score.11/WAKU2-RELAYaims at enabling an advanced spam protection mechanism with economic disincentives by utilizing Rate Limiting Nullifiers. In a nutshell, peers must conform to a certain message publishing rate per a system-defined epoch, otherwise, they get financially penalized for exceeding the rate. More details on this new technique can be found in17/WAKU2-RLN-RELAY.
- Providing Unlinkability, Integrity and Authenticity simultaneously:
Integrity and authenticity are typically addressed through digital signatures and
Message Authentication Code (MAC) schemes, however,
the usage of digital signatures (where each signature is bound to a particular peer)
contradicts with the unlinkability requirement
(messages signed under a certain signature key are verifiable by a verification key
that is bound to a particular publisher).
As such, integrity and authenticity are missing features in
11/WAKU2-RELAYin the interest of unlinkability. In future work, advanced signature schemes like group signatures can be utilized to enable authenticity, integrity, and unlinkability simultaneously. In a group signature scheme, a member of a group can anonymously sign a message on behalf of the group as such the true signer is indistinguishable from other group members.
Copyright
Copyright and related rights waived via CC0.
References
12/WAKU2-FILTER
| Field | Value |
|---|---|
| Name | Waku v2 Filter |
| Slug | 12 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected], Sanaz Taheri [email protected], Ebube Ud [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-03-25 —
e8a3f8a— 12/WAKU2-FILTER: Update (#119) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-01 —
e4d8f27— Update and rename FILTER.md to filter.md - 2024-01-27 —
046a3b7— Rename WAKU2-FILTER.md to FILTER.md - 2024-01-27 —
57124a7— Rename README.md to WAKU2-FILTER.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
940d795— Rename waku/12/README.md to waku/rfcs/standards/core/12/README.md - 2024-01-22 —
420adf1— Vac RFC index initial structure
Protocol identifiers:
- filter-subscribe:
/vac/waku/filter-subscribe/2.0.0-beta1 - filter-push:
/vac/waku/filter-push/2.0.0-beta1
Abstract
This specification describes the 12/WAKU2-FILTER protocol,
which enables a client to subscribe to a subset of real-time messages from a Waku peer.
This is a more lightweight version of 11/WAKU2-RELAY,
useful for bandwidth restricted devices.
This is often used by nodes with lower resource limits to subscribe to full Relay nodes and
only receive the subset of messages they desire,
based on content topic interest.
Motivation
Unlike the 13/WAKU2-STORE protocol for historical messages, this protocol allows for native lower latency scenarios, such as instant messaging. It is thus complementary to it.
Strictly speaking, it is not just doing basic request-response, but performs sender push based on receiver intent. While this can be seen as a form of light publish/subscribe, it is only used between two nodes in a direct fashion. Unlike the Gossip domain, this is suitable for light nodes which put a premium on bandwidth. No gossiping takes place.
It is worth noting that a light node could get by with only using the 13/WAKU2-STORE protocol to query for a recent time window, provided it is acceptable to do frequent polling.
Semantics
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Content filtering
Content filtering is a way to do
message-based filtering.
Currently the only content filter being applied is on contentTopic.
Terminology
The term Personally identifiable information (PII) refers to any piece of data that can be used to uniquely identify a user. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII.
Protobuf
syntax = "proto3";
// Protocol identifier: /vac/waku/filter-subscribe/2.0.0-beta1
message FilterSubscribeRequest {
enum FilterSubscribeType {
SUBSCRIBER_PING = 0;
SUBSCRIBE = 1;
UNSUBSCRIBE = 2;
UNSUBSCRIBE_ALL = 3;
}
string request_id = 1;
FilterSubscribeType filter_subscribe_type = 2;
// Filter criteria
optional string pubsub_topic = 10;
repeated string content_topics = 11;
}
message FilterSubscribeResponse {
string request_id = 1;
uint32 status_code = 10;
optional string status_desc = 11;
}
// Protocol identifier: /vac/waku/filter-push/2.0.0-beta1
message MessagePush {
WakuMessage waku_message = 1;
optional string pubsub_topic = 2;
}
Filter-Subscribe
A filter service node MUST support the filter-subscribe protocol to allow filter clients to subscribe, modify, refresh and unsubscribe a desired set of filter criteria. The combination of different filter criteria for a specific filter client node is termed a "subscription". A filter client is interested in receiving messages matching the filter criteria in its registered subscriptions.
Since a filter service node is consuming resources to provide this service, it MAY account for usage and adapt its service provision to certain clients.
Filter Subscribe Request
A client node MUST send all filter requests in a FilterSubscribeRequest message.
This request MUST contain a request_id.
The request_id MUST be a uniquely generated string.
Each request MUST include a filter_subscribe_type, indicating the type of request.
Filter Subscribe Response
When responding to a FilterSubscribeRequest,
a filter service node SHOULD send a FilterSubscribeResponse
with a requestId matching that of the request.
This response MUST contain a status_code indicating if the request was successful
or not.
Successful status codes are in the 2xx range.
Client nodes SHOULD consider all other status codes as error codes and
assume that the requested operation had failed.
In addition,
the filter service node MAY choose to provide a more detailed status description
in the status_desc field.
Filter matching
In the description of each request type below,
the term "filter criteria" refers to the combination of pubsub_topic and
a set of content_topics.
The request MAY include filter criteria,
conditional to the selected filter_subscribe_type.
If the request contains filter criteria,
it MUST contain a pubsub_topic
and the content_topics set MUST NOT be empty.
A 14/WAKU2-MESSAGE matches filter criteria
when its content_topic is in the content_topics set
and it was published on a matching pubsub_topic.
Filter Subscribe Types
The filter-subscribe types are defined as follows:
SUBSCRIBER_PING
A filter client that sends a FilterSubscribeRequest with
filter_subscribe_type set to SUBSCRIBER_PING,
requests that the filter service node SHOULD indicate if it has any active subscriptions
for this client.
The filter client SHOULD exclude any filter criteria from the request.
The filter service node SHOULD respond with a success status_code
if it has any active subscriptions for this client
or an error status_code if not.
The filter service node SHOULD ignore any filter criteria in the request.
SUBSCRIBE
A filter client that sends a FilterSubscribeRequest with
filter_subscribe_type set to SUBSCRIBE
requests that the filter service node SHOULD push messages
matching this filter to the client.
The filter client MUST include the desired filter criteria in the request.
A client MAY use this request type to modify an existing subscription
by providing additional filter criteria in a new request.
A client MAY use this request type to refresh an existing subscription
by providing the same filter criteria in a new request.
The filter service node SHOULD respond with a success status_code
if it successfully honored this request
or an error status_code if not.
The filter service node SHOULD respond with an error status_code and
discard the request if the FilterSubscribeRequest
does not contain valid filter criteria,
i.e. both a pubsub_topic and a non-empty content_topics set.
UNSUBSCRIBE
A filter client that sends a FilterSubscribeRequest with
filter_subscribe_type set to UNSUBSCRIBE
requests that the service node SHOULD stop pushing messages
matching this filter to the client.
The filter client MUST include the filter criteria
it desires to unsubscribe from in the request.
A client MAY use this request type to modify an existing subscription
by providing a subset of the original filter criteria
to unsubscribe from in a new request.
The filter service node SHOULD respond with a success status_code
if it successfully honored this request
or an error status_code if not.
The filter service node SHOULD respond with an error status_code and
discard the request if the unsubscribe request does not contain valid filter criteria,
i.e. both a pubsub_topic and a non-empty content_topics set.
UNSUBSCRIBE_ALL
A filter client that sends a FilterSubscribeRequest with
filter_subscribe_type set to UNSUBSCRIBE_ALL
requests that the service node SHOULD stop pushing messages
matching any filter to the client.
The filter client SHOULD exclude any filter criteria from the request.
The filter service node SHOULD remove any existing subscriptions for this client.
It SHOULD respond with a success status_code if it successfully honored this request
or an error status_code if not.
Filter-Push
A filter client node MUST support the filter-push protocol to allow filter service nodes to push messages matching registered subscriptions to this client.
A filter service node SHOULD push all messages
matching the filter criteria in a registered subscription
to the subscribed filter client.
These WakuMessages
are likely to come from 11/WAKU2-RELAY,
but there MAY be other sources or protocols where this comes from.
This is up to the consumer of the protocol.
If a message push fails,
the filter service node MAY consider the client node to be unreachable.
If a specific filter client node is not reachable from the service node
for a period of time,
the filter service node MAY choose to stop pushing messages to the client and
remove its subscription.
This period is up to the service node implementation.
It is RECOMMENDED to set 1 minute as a reasonable default.
Message Push
Each message MUST be pushed in a MessagePush message.
Each MessagePush MUST contain one (and only one) waku_message.
If this message was received on a specific pubsub_topic,
it SHOULD be included in the MessagePush.
A filter client SHOULD NOT respond to a MessagePush.
Since the filter protocol does not include caching or fault-tolerance,
this is a best effort push service with no bundling
or guaranteed retransmission of messages.
A filter client SHOULD verify that each MessagePush it receives
originated from a service node where the client has an active subscription
and that it matches filter criteria belonging to that subscription.
Adversarial Model
Any node running the WakuFilter protocol
i.e., both the subscriber node and
the queried node are considered as an adversary.
Furthermore, we consider the adversary as a passive entity
that attempts to collect information from other nodes to conduct an attack but
it does so without violating protocol definitions and instructions.
For example, under the passive adversarial model,
no malicious node intentionally hides the messages
matching to one's subscribed content filter
as it is against the description of the WakuFilter protocol.
The following are not considered as part of the adversarial model:
- An adversary with a global view of all the nodes and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of nodes (unless the adversary is one end of the communication). In specific, the communication channels are assumed to be secure.
Security Considerations
Note that while using WakuFilter allows light nodes to save bandwidth,
it comes with a privacy cost in the sense that they need to
disclose their liking topics to the full nodes to retrieve the relevant messages.
Currently, anonymous subscription is not supported by the WakuFilter, however,
potential solutions in this regard are discussed below.
Future Work
Anonymous filter subscription:
This feature guarantees that nodes can anonymously subscribe for a message filter
(i.e., without revealing their exact content filter).
As such, no adversary in the WakuFilter protocol
would be able to link nodes to their subscribed content filers.
The current version of the WakuFilter protocol does not provide anonymity
as the subscribing node has a direct connection to the full node and
explicitly submits its content filter to be notified about the matching messages.
However, one can consider preserving anonymity through one of the following ways:
- By hiding the source of the subscription i.e., anonymous communication. That is the subscribing node shall hide all its PII in its filter request e.g., its IP address. This can happen by the utilization of a proxy server or by using Tor
Note that the current structure of filter requests
i.e., FilterRPC does not embody any piece of PII, otherwise,
such data fields must be treated carefully to achieve anonymity.
- By deploying secure 2-party computations in which the subscribing node obtains the messages matching a content filter whereas the full node learns nothing about the content filter as well as the messages pushed to the subscribing node. Examples of such 2PC protocols are Oblivious Transfers and one-way Private Set Intersections (PSI).
Copyright
Copyright and related rights waived via CC0.
Previous versions
References
Informative
12/WAKU2-FILTER
| Field | Value |
|---|---|
| Name | Waku v2 Filter |
| Slug | 12 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected], Sanaz Taheri [email protected], Ebube Ud [email protected] |
Timeline
- 2026-01-21 —
a00f16e— chore: mdbook fixes (#265) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-03-25 —
e8a3f8a— 12/WAKU2-FILTER: Update (#119) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-05 —
d41f106— Update filter.md - 2024-02-05 —
8436a31— Update and rename README.md to filter.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
420a51b— Rename waku/rfcs/core/12/previous-versions00/README.md to waku/rfcs/standards/core/12/previous-versions00/README.md - 2024-01-25 —
755fea9— Rename waku/12/previous-versions/00/README.md to waku/rfcs/core/12/previous-versions00/README.md - 2024-01-22 —
420adf1— Vac RFC index initial structure
WakuFilter is a protocol that enables subscribing to messages that a peer receives.
This is a more lightweight version of WakuRelay
specifically designed for bandwidth restricted devices.
This is due to the fact that light nodes subscribe to full-nodes and
only receive the messages they desire.
Content filtering
Protocol identifier*: /vac/waku/filter/2.0.0-beta1
Content filtering is a way to do message-based
filtering.
Currently the only content filter being applied is on contentTopic. This
corresponds to topics in Waku v1.
Rationale
Unlike the store protocol for historical messages, this protocol allows for
native lower latency scenarios such as instant messaging. It is thus
complementary to it.
Strictly speaking, it is not just doing basic request response, but performs sender push based on receiver intent. While this can be seen as a form of light pub/sub, it is only used between two nodes in a direct fashion. Unlike the Gossip domain, this is meant for light nodes which put a premium on bandwidth. No gossiping takes place.
It is worth noting that a light node could get by with only using the store
protocol to query for a recent time window, provided it is acceptable to do
frequent polling.
Design Requirements
The effectiveness and reliability of the content filtering service
enabled by WakuFilter protocol rely on the high availability of the full nodes
as the service providers.
To this end, full nodes must feature high uptime
(to persistently listen and capture the network messages)
as well as high Bandwidth (to provide timely message delivery to the light nodes).
Security Consideration
Note that while using WakuFilter allows light nodes to save bandwidth,
it comes with a privacy cost in the sense that they need to disclose their liking
topics to the full nodes to retrieve the relevant messages.
Currently, anonymous subscription is not supported by the WakuFilter, however,
potential solutions in this regard are sketched below in Future Work
section.
Terminology
The term Personally identifiable information (PII) refers to any piece of data that can be used to uniquely identify a user. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII.
Adversarial Model
Any node running the WakuFilter protocol i.e.,
both the subscriber node and the queried node are considered as an adversary.
Furthermore, we consider the adversary as a passive entity
that attempts to collect information from other nodes to conduct an attack but
it does so without violating protocol definitions and instructions.
For example, under the passive adversarial model,
no malicious node intentionally hides the messages matching
to one's subscribed content filter as it is against the description
of the WakuFilter protocol.
The following are not considered as part of the adversarial model:
- An adversary with a global view of all the nodes and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of nodes (unless the adversary is one end of the communication). In specific, the communication channels are assumed to be secure.
Protobuf
message FilterRequest {
bool subscribe = 1;
string topic = 2;
repeated ContentFilter contentFilters = 3;
message ContentFilter {
string contentTopic = 1;
}
}
message MessagePush {
repeated WakuMessage messages = 1;
}
message FilterRPC {
string requestId = 1;
FilterRequest request = 2;
MessagePush push = 3;
}
FilterRPC
A node MUST send all Filter messages (FilterRequest, MessagePush)
wrapped inside a FilterRPC this allows the node handler
to determine how to handle a message as the Waku Filter protocol
is not a request response based protocol but instead a push based system.
The requestId MUST be a uniquely generated string. When a MessagePush is sent
the requestId MUST match the requestId of the subscribing FilterRequest
whose filters matched the message causing it to be pushed.
FilterRequest
A FilterRequest contains an optional topic, zero or more content filters and
a boolean signifying whether to subscribe or unsubscribe to the given filters.
True signifies 'subscribe' and false signifies 'unsubscribe'.
A node that sends the RPC with a filter request and subscribe set to 'true'
requests that the filter node SHOULD notify the light requesting node of messages
matching this filter.
A node that sends the RPC with a filter request and subscribe set to 'false'
requests that the filter node SHOULD stop notifying the light requesting node
of messages matching this filter if it is currently doing so.
The filter matches when content filter and, optionally, a topic is matched.
Content filter is matched when a WakuMessage contentTopic field is the same.
A filter node SHOULD honor this request, though it MAY choose not to do so. If it chooses not to do so it MAY tell the light why. The mechanism for doing this is currently not specified. For notifying the light node a filter node sends a MessagePush message.
Since such a filter node is doing extra work for a light node, it MAY also account for usage and be selective in how much service it provides. This mechanism is currently planned but underspecified.
MessagePush
A filter node that has received a filter request SHOULD push all messages that
match this filter to a light node. These WakuMessage's
are likely to come from the
relay protocol and be kept at the Node, but there MAY be other sources or
protocols where this comes from. This is up to the consumer of the protocol.
A filter node MUST NOT send a push message for messages that have not been requested via a FilterRequest.
If a specific light node isn't connected to a filter node for some specific period of time (e.g. a TTL), then the filter node MAY choose to not push these messages to the node. This period is up to the consumer of the protocol and node implementation, though a reasonable default is one minute.
Future Work
Anonymous filter subscription:
This feature guarantees that nodes can anonymously subscribe for a message filter
(i.e., without revealing their exact content filter).
As such, no adversary in the WakuFilter protocol would be able to link nodes
to their subscribed content filers.
The current version of the WakuFilter protocol does not provide anonymity
as the subscribing node has a direct connection to the full node and
explicitly submits its content filter to be notified about the matching messages.
However, one can consider preserving anonymity through one of the following ways:
- By hiding the source of the subscription i.e., anonymous communication. That is the subscribing node shall hide all its PII in its filter request e.g., its IP address. This can happen by the utilization of a proxy server or by using Tor
Note that the current structure of filter requests i.e.,
FilterRPC does not embody any piece of PII, otherwise,
such data fields must be treated carefully to achieve anonymity.
- By deploying secure 2-party computations in which the subscribing node obtains the messages matching a content filter whereas the full node learns nothing about the content filter as well as the messages pushed to the subscribing node. Examples of such 2PC protocols are Oblivious Transfers and one-way Private Set Intersections (PSI).
Copyright
Copyright and related rights waived via CC0.
References
13/WAKU2-STORE
| Field | Value |
|---|---|
| Name | Waku Store Query |
| Slug | 13 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-15 —
1b8b2ac— Add missing status to 13/WAKU-STORE (#149) - 2025-02-03 —
a60a2c4— 13/WAKU-STORE: Update (#124) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
755be94— Update and rename STORE.md to store.md - 2024-01-27 —
3baed07— Rename README.md to STORE.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
51e2879— Create README.md
Abstract
This specification explains the WAKU2-STORE protocol,
which enables querying of 14/WAKU2-MESSAGEs.
Protocol identifier*: /vac/waku/store-query/3.0.0
Terminology
The term PII, Personally Identifiable Information, refers to any piece of data that can be used to uniquely identify a user. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII.
Wire Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC2119.
Design Requirements
The concept of ephemeral messages introduced in 14/WAKU2-MESSAGE affects WAKU2-STORE as well.
Nodes running WAKU2-STORE SHOULD support ephemeral messages as specified in 14/WAKU2-MESSAGE.
Nodes running WAKU2-STORE SHOULD NOT store messages with the ephemeral flag set to true.
Payloads
syntax = "proto3";
// Protocol identifier: /vac/waku/store-query/3.0.0
package waku.store.v3;
import "waku/message/v1/message.proto";
message WakuMessageKeyValue {
optional bytes message_hash = 1; // Globally unique key for a Waku Message
// Full message content and associated pubsub_topic as value
optional waku.message.v1.WakuMessage message = 2;
optional string pubsub_topic = 3;
}
message StoreQueryRequest {
string request_id = 1;
bool include_data = 2; // Response should include full message content
// Filter criteria for content-filtered queries
optional string pubsub_topic = 10;
repeated string content_topics = 11;
optional sint64 time_start = 12;
optional sint64 time_end = 13;
// List of key criteria for lookup queries
repeated bytes message_hashes = 20; // Message hashes (keys) to lookup
// Pagination info. 50 Reserved
optional bytes pagination_cursor = 51; // Message hash (key) from where to start query (exclusive)
bool pagination_forward = 52;
optional uint64 pagination_limit = 53;
}
message StoreQueryResponse {
string request_id = 1;
optional uint32 status_code = 10;
optional string status_desc = 11;
repeated WakuMessageKeyValue messages = 20;
optional bytes pagination_cursor = 51;
}
General Store Query Concepts
Waku Message Key-Value Pairs
The store query protocol operates as a query protocol for a key-value store of historical messages,
with each entry having a 14/WAKU2-MESSAGE
and associated pubsub_topic as the value,
and deterministic message hash as the key.
The store can be queried to return either a set of keys or a set of key-value pairs.
Within the store query protocol,
the 14/WAKU2-MESSAGE keys and
values MUST be represented in a WakuMessageKeyValue message.
This message MUST contain the deterministic message_hash as the key.
It MAY contain the full 14/WAKU2-MESSAGE and
associated pubsub topic as the value in the message and
pubsub_topic fields, depending on the use case as set out below.
If the message contains a value entry in addition to the key,
both the message and pubsub_topic fields MUST be populated.
The message MUST NOT have either message or pubsub_topic populated with the other unset.
Both fields MUST either be set or unset.
Waku Message Store Eligibility
In order for a message to be eligible for storage:
- it MUST be a valid 14/WAKU2-MESSAGE.
- the
timestampfield MUST be populated with the Unix epoch time, at which the message was generated in nanoseconds. If at the time of storage thetimestampdeviates by more than 20 seconds either into the past or the future when compared to the store node’s internal clock, the store node MAY reject the message. - the
ephemeralfield MUST be set tofalse.
Waku message sorting
The key-value entries in the store MUST be time-sorted by the 14/WAKU2-MESSAGE timestamp attribute.
Where two or more key-value entries have identical timestamp values,
the entries MUST be further sorted by the natural order of their message hash keys.
Within the context of traversing over key-value entries in the store,
"forward" indicates traversing the entries in ascending order,
whereas "backward" indicates traversing the entries in descending order.
Pagination
If a large number of entries in the store service node match the query criteria provided in a StoreQueryRequest,
the client MAY make use of pagination
in a chain of store query request and response transactions
to retrieve the full response in smaller batches termed "pages".
Pagination can be performed either in a forward or backward direction.
A store query client MAY indicate the maximum number of matching entries it wants in the StoreQueryResponse,
by setting the page size limit in the pagination_limit field.
Note that a store service node MAY enforce its own limit
if the pagination_limit is unset
or larger than the service node's internal page size limit.
A StoreQueryResponse with a populated pagination_cursor indicates that more stored entries match the query than included in the response.
A StoreQueryResponse without a populated pagination_cursor indicates that
there are no more matching entries in the store.
The client MAY request the next page of entries from the store service node
by populating a subsequent StoreQueryRequest with the pagination_cursor
received in the StoreQueryResponse.
All other fields and query criteria MUST be the same as in the preceding StoreQueryRequest.
A StoreQueryRequest without a populated pagination_cursor indicates that
the client wants to retrieve the "first page" of the stored entries matching the query.
Store Query Request
A client node MUST send all historical message queries within a StoreQueryRequest message.
This request MUST contain a request_id.
The request_id MUST be a uniquely generated string.
If the store query client requires the store service node to include 14/WAKU2-MESSAGE values in the query response,
it MUST set include_data to true.
If the store query client requires the store service node to return only message hash keys in the query response,
it SHOULD set include_data to false.
By default, therefore, the store service node assumes include_data to be false.
A store query client MAY include query filter criteria in the StoreQueryRequest.
There are two types of filter use cases:
- Content filtered queries and
- Message hash lookup queries
Content filtered queries
A store query client MAY request the store service node to filter historical entries by a content filter. Such a client MAY create a filter on content topic, on time range or on both.
To filter on content topic,
the client MUST populate both the pubsub_topic and content_topics field.
The client MUST NOT populate either pubsub_topic or
content_topics and leave the other unset.
Both fields MUST either be set or unset.
A mixed content topic filter with just one of either pubsub_topic or
content_topics set, SHOULD be regarded as an invalid request.
To filter on time range, the client MUST set time_start, time_end or both.
Each time_ field should contain a Unix epoch timestamp in nanoseconds.
An unset time_start SHOULD be interpreted as "from the oldest stored entry".
An unset time_end SHOULD be interpreted as "up to the youngest stored entry".
If any of the content filter fields are set,
namely pubsub_topic, content_topic, time_start, or time_end,
the client MUST NOT set the message_hashes field.
Message hash lookup queries
A store query client MAY request the store service node to filter historical entries by one or more matching message hash keys. This type of query acts as a "lookup" against a message hash key or set of keys already known to the client.
In order to perform a lookup query,
the store query client MUST populate the message_hashes field with the list of message hash keys it wants to lookup in the store service node.
If the message_hashes field is set,
the client MUST NOT set any of the content filter fields,
namely pubsub_topic, content_topic, time_start, or time_end.
Presence queries
A presence query is a special type of lookup query that allows a client to check for the presence of one or more messages in the store service node, without retrieving the full contents (values) of the messages. This can, for example, be used as part of a reliability mechanism, whereby store query clients verify that previously published messages have been successfully stored.
In order to perform a presence query,
the store query client MUST populate the message_hashes field in the StoreQueryRequest with the list of message hashes
for which it wants to verify presence in the store service node.
The include_data property MUST be set to false.
The client SHOULD interpret every message_hash returned in the messages field of the StoreQueryResponse as present in the store.
The client SHOULD assume that all other message hashes included in the original StoreQueryRequest but
not in the StoreQueryResponse is not present in the store.
Pagination info
The store query client MAY include a message hash as pagination_cursor,
to indicate at which key-value entry a store service node SHOULD start the query.
The pagination_cursor is treated as exclusive
and the corresponding entry will not be included in subsequent store query responses.
For forward queries,
only messages following (see sorting) the one indexed at pagination_cursor
will be returned.
For backward queries,
only messages preceding (see sorting) the one indexed at pagination_cursor
will be returned.
If the store query client requires the store service node to perform a forward query,
it MUST set pagination_forward to true.
If the store query client requires the store service node to perform a backward query,
it SHOULD set pagination_forward to false.
By default, therefore, the store service node assumes pagination to be backward.
A store query client MAY indicate the maximum number of matching entries it wants in the StoreQueryResponse,
by setting the page size limit in the pagination_limit field.
Note that a store service node MAY enforce its own limit
if the pagination_limit is unset
or larger than the service node's internal page size limit.
See pagination for more on how the pagination info is used in store transactions.
Store Query Response
In response to any StoreQueryRequest,
a store service node SHOULD respond with a StoreQueryResponse with a requestId matching that of the request.
This response MUST contain a status_code indicating if the request was successful or not.
Successful status codes are in the 2xx range.
A client node SHOULD consider all other status codes as error codes and
assume that the requested operation had failed.
In addition,
the store service node MAY choose to provide a more detailed status description in the status_desc field.
Filter matching
For content filtered queries,
an entry in the store service node matches the filter criteria in a StoreQueryRequest if each of the following conditions are met:
- its
content_topicis in the requestcontent_topicsset and it was published on a matchingpubsub_topicOR the requestcontent_topicsandpubsub_topicfields are unset - its
timestampis larger or equal than the requeststart_timeOR the requeststart_timeis unset - its
timestampis smaller than the requestend_timeOR the requestend_timeis unset
Note that for content filtered queries, start_time is treated as inclusive and
end_time is treated as exclusive.
For message hash lookup queries,
an entry in the store service node matches the filter criteria if its message_hash is in the request message_hashes set.
The store service node SHOULD respond with an error code and discard the request if the store query request contains both content filter criteria and message hashes.
Populating response messages
The store service node SHOULD populate the messages field in the response
only with entries matching the filter criteria provided in the corresponding request.
Regardless of whether the response is to a forward or backward query,
the messages field in the response MUST be ordered in a forward direction
according to the message sorting rules.
If the corresponding StoreQueryRequest has include_data set to true,
the service node SHOULD populate both the message_hash and
message for each entry in the response.
In all other cases,
the store service node SHOULD populate only the message_hash field for each entry in the response.
Paginating the response
The response SHOULD NOT contain more messages than the pagination_limit provided in the corresponding StoreQueryRequest.
It is RECOMMENDED that the store node defines its own maximum page size internally.
If the pagination_limit in the request is unset,
or exceeds this internal maximum page size,
the store service node SHOULD ignore the pagination_limit field and
apply its own internal maximum page size.
In response to a forward StoreQueryRequest:
- if the
pagination_cursoris set, the store service node SHOULD populate themessagesfield with matching entries following thepagination_cursor(exclusive). - if the
pagination_cursoris unset, the store service node SHOULD populate themessagesfield with matching entries from the first entry in the store. - if there are still more matching entries in the store
after the maximum page size is reached while populating the response,
the store service node SHOULD populate the
pagination_cursorin theStoreQueryResponsewith the message hash key of the last entry included in the response.
In response to a backward StoreQueryRequest:
- if the
pagination_cursoris set, the store service node SHOULD populate themessagesfield with matching entries preceding thepagination_cursor(exclusive). - if the
pagination_cursoris unset, the store service node SHOULD populate themessagesfield with matching entries from the last entry in the store. - if there are still more matching entries in the store
after the maximum page size is reached while populating the response,
the store service node SHOULD populate the
pagination_cursorin theStoreQueryResponsewith the message hash key of the first entry included in the response.
Security Consideration
The main security consideration while using this protocol is that a querying node has to reveal its content filters of interest to the queried node, hence potentially compromising their privacy.
Adversarial Model
Any peer running the WAKU2-STORE protocol, i.e.
both the querying node and the queried node, are considered as an adversary.
Furthermore,
we currently consider the adversary as a passive entity that attempts to collect information from other peers to conduct an attack but
it does so without violating protocol definitions and instructions.
As we evolve the protocol,
further adversarial models will be considered.
For example, under the passive adversarial model,
no malicious node hides or
lies about the history of messages as it is against the description of the WAKU2-STORE protocol.
The following are not considered as part of the adversarial model:
- An adversary with a global view of all the peers and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of peers (unless the adversary is one end of the communication). Specifically, the communication channels are assumed to be secure.
Future Work
-
Anonymous query: This feature guarantees that nodes can anonymously query historical messages from other nodes i.e., without disclosing the exact topics of 14/WAKU2-MESSAGE they are interested in.
As such, no adversary in theWAKU2-STOREprotocol would be able to learn which peer is interested in which content filters i.e., content topics of 14/WAKU2-MESSAGE. The current version of theWAKU2-STOREprotocol does not provide anonymity for historical queries, as the querying node needs to directly connect to another node in theWAKU2-STOREprotocol and explicitly disclose the content filters of its interest to retrieve the corresponding messages. However, one can consider preserving anonymity through one of the following ways: -
By hiding the source of the request i.e., anonymous communication. That is the querying node shall hide all its PII in its history request e.g., its IP address. This can happen by the utilization of a proxy server or by using Tor. Note that the current structure of historical requests does not embody any piece of PII, otherwise, such data fields must be treated carefully to achieve query anonymity.
- By deploying secure 2-party computations in which the querying node obtains the historical messages of a certain topic, the queried node learns nothing about the query. Examples of such 2PC protocols are secure one-way Private Set Intersections (PSI).
- Robust and verifiable timestamps: Messages timestamp is a way to show that
the message existed prior to some point in time.
However, the lack of timestamp verifiability can create room for a range of attacks,
including injecting messages with invalid timestamps pointing to the far future.
To better understand the attack,
consider a store node whose current clock shows
2021-01-01 00:00:30(and assume all the other nodes have a synchronized clocks +-20seconds). The store node already has a list of messages,(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20), that are sorted based on their timestamp.
An attacker sends a message with an arbitrary large timestamp e.g., 10 hours ahead of the correct clock(m',2021-01-01 10:00:30). The store node placesm'at the end of the list,(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20), (m',2021-01-01 10:00:30). Now another message arrives with a valid timestamp e.g.,(m11, 2021-01-01 00:00:45). However, since its timestamp precedes the malicious messagem', it gets placed beforem'in the list i.e.,(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20), (m11, 2021-01-01 00:00:45), (m',2021-01-01 10:00:30). In fact, for the next 10 hours,m'will always be considered as the most recent message and served as the last message to the querying nodes irrespective of how many other messages arrive afterward.
A robust and verifiable timestamp allows the receiver of a message to verify that a message has been generated prior to the claimed timestamp. One solution is the use of open timestamps e.g., block height in Blockchain-based timestamps. That is, messages contain the most recent block height perceived by their senders at the time of message generation. This proves accuracy within a range of minutes (e.g., in Bitcoin blockchain) or seconds (e.g., in Ethereum 2.0) from the time of origination.
Copyright
Copyright and related rights waived via CC0.
Previous versions
References
13/WAKU2-STORE
| Field | Value |
|---|---|
| Name | Waku v2 Store |
| Slug | 13 |
| Status | draft |
| Editor | Simon-Pierre Vivier [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected], Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-02-03 —
a60a2c4— 13/WAKU-STORE: Update (#124) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
755be94— Update and rename STORE.md to store.md - 2024-01-27 —
3baed07— Rename README.md to STORE.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
51e2879— Create README.md
Abstract
This specification explains the 13/WAKU2-STORE protocol
which enables querying of messages received through the relay protocol and
stored by other nodes.
It also supports pagination for more efficient querying of historical messages.
Protocol identifier*: /vac/waku/store/2.0.0-beta4
Terminology
The term PII, Personally Identifiable Information, refers to any piece of data that can be used to uniquely identify a user. For example, the signature verification key, and the hash of one's static IP address are unique for each user and hence count as PII.
Design Requirements
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC2119.
Nodes willing to provide the storage service using 13/WAKU2-STORE protocol,
SHOULD provide a complete and full view of message history.
As such, they are required to be highly available and
specifically have a high uptime to consistently receive and store network messages.
The high uptime requirement makes sure that no message is missed out
hence a complete and intact view of the message history
is delivered to the querying nodes.
Nevertheless, in case storage provider nodes cannot afford high availability,
the querying nodes may retrieve the historical messages from multiple sources
to achieve a full and intact view of the past.
The concept of ephemeral messages introduced in
14/WAKU2-MESSAGE affects 13/WAKU2-STORE as well.
Nodes running 13/WAKU2-STORE SHOULD support ephemeral messages as specified in
14/WAKU2-MESSAGE.
Nodes running 13/WAKU2-STORE SHOULD NOT store messages
with the ephemeral flag set to true.
Adversarial Model
Any peer running the 13/WAKU2-STORE protocol, i.e.
both the querying node and the queried node, are considered as an adversary.
Furthermore,
we currently consider the adversary as a passive entity
that attempts to collect information from other peers to conduct an attack but
it does so without violating protocol definitions and instructions.
As we evolve the protocol,
further adversarial models will be considered.
For example, under the passive adversarial model,
no malicious node hides or
lies about the history of messages
as it is against the description of the 13/WAKU2-STORE protocol.
The following are not considered as part of the adversarial model:
- An adversary with a global view of all the peers and their connections.
- An adversary that can eavesdrop on communication links between arbitrary pairs of peers (unless the adversary is one end of the communication). In specific, the communication channels are assumed to be secure.
Wire Specification
Peers communicate with each other using a request / response API. The messages sent are Protobuf RPC messages which are implemented using protocol buffers v3. The following are the specifications of the Protobuf messages.
Payloads
syntax = "proto3";
message Index {
bytes digest = 1;
sint64 receiverTime = 2;
sint64 senderTime = 3;
string pubsubTopic = 4;
}
message PagingInfo {
uint64 pageSize = 1;
Index cursor = 2;
enum Direction {
BACKWARD = 0;
FORWARD = 1;
}
Direction direction = 3;
}
message ContentFilter {
string contentTopic = 1;
}
message HistoryQuery {
// the first field is reserved for future use
string pubsubtopic = 2;
repeated ContentFilter contentFilters = 3;
PagingInfo pagingInfo = 4;
}
message HistoryResponse {
// the first field is reserved for future use
repeated WakuMessage messages = 2;
PagingInfo pagingInfo = 3;
enum Error {
NONE = 0;
INVALID_CURSOR = 1;
}
Error error = 4;
}
message HistoryRPC {
string request_id = 1;
HistoryQuery query = 2;
HistoryResponse response = 3;
}
Index
To perform pagination,
each WakuMessage stored at a node running the 13/WAKU2-STORE protocol
is associated with a unique Index that encapsulates the following parts.
digest: a sequence of bytes representing the SHA256 hash of aWakuMessage. The hash is computed over the concatenation ofcontentTopicandpayloadfields of aWakuMessage(see 14/WAKU2-MESSAGE).receiverTime: the UNIX time in nanoseconds at which theWakuMessageis received by the receiving node.senderTime: the UNIX time in nanoseconds at which theWakuMessageis generated by its sender.pubsubTopic: the pubsub topic on which theWakuMessageis received.
PagingInfo
PagingInfo holds the information required for pagination.
It consists of the following components.
pageSize: A positive integer indicating the number of queriedWakuMessages in aHistoryQuery(or retrievedWakuMessages in aHistoryResponse).cursor: holds theIndexof aWakuMessage.direction: indicates the direction of paging which can be eitherFORWARDorBACKWARD.
ContentFilter
ContentFilter carries the information required for filtering historical messages.
contentTopicrepresents the content topic of the queried historicalWakuMessage. This field maps to thecontentTopicfield of the 14/WAKU2-MESSAGE.
HistoryQuery
RPC call to query historical messages.
- The
pubsubTopicfield MUST indicate the pubsub topic of the historical messages to be retrieved. This field denotes the pubsub topic on whichWakuMessages are published. This field maps totopicIDsfield ofMessagein11/WAKU2-RELAY. Leaving this field empty means no filter on the pubsub topic of message history is requested. This field SHOULD be left empty in order to retrieve the historicalWakuMessageregardless of the pubsub topics on which they are published. - The
contentFiltersfield MUST indicate the list of content filters based on which the historical messages are to be retrieved. Leaving this field empty means no filter on the content topic of message history is required. This field SHOULD be left empty in order to retrieve historicalWakuMessageregardless of their content topics. PagingInfoholds the information required for pagination.
ItspageSizefield indicates the number ofWakuMessages to be included in the correspondingHistoryResponse. It is RECOMMENDED that the queried node defines a maximum page size internally. If the querying node leaves thepageSizeunspecified, or if thepageSizeexceeds the maximum page size, the queried node SHOULD auto-paginate theHistoryResponseto no more than the configured maximum page size. This allows mitigation of long response time forHistoryQuery. In the forward pagination request, themessagesfield of theHistoryResponseSHALL contain, at maximum, thepageSizeamount ofWakuMessagewhoseIndexvalues are larger than the givencursor(and vise versa for the backward pagination). Note that thecursorof aHistoryQueryMAY be empty (e.g., for the initial query), as such, and depending on whether thedirectionisBACKWARDorFORWARDthe last or the firstpageSizeWakuMessageSHALL be returned, respectively.
Sorting Messages
The queried node MUST sort the WakuMessage based on their Index,
where the senderTime constitutes the most significant part and
the digest comes next, and
then perform pagination on the sorted result.
As such, the retrieved page contains an ordered list of WakuMessage
from the oldest messages to the most recent one.
Alternatively, the receiverTime (instead of senderTime)
MAY be used to sort messages during the paging process.
However, it is RECOMMENDED the use of the senderTime
for sorting as it is invariant and
consistent across all the nodes.
This has the benefit of cursor reusability i.e.,
a cursor obtained from one node can be consistently used
to query from another node.
However, this cursor reusability does not hold when the receiverTime is utilized
as the receiver time is affected by the network delay and
nodes' clock asynchrony.
HistoryResponse
RPC call to respond to a HistoryQuery call.
- The
messagesfield MUST contain the messages found, these are 14/WAKU2-MESSAGE types. PagingInfoholds the paging information based on which the querying node can resume its further history queries. ThepageSizeindicates the number of returned Waku messages (i.e., the number of messages included in themessagesfield ofHistoryResponse). Thedirectionis the same direction as in the correspondingHistoryQuery. In the forward pagination, thecursorholds theIndexof the last message in theHistoryResponsemessages(and the first message in the backward paging). Regardless of the paging direction, the retrievedmessagesare always sorted in ascending order based on their timestamp as explained in the sorting messagessection, that is, from the oldest to the most recent. The requester SHALL embed the returnedcursorinside its nextHistoryQueryto retrieve the next page of the 14/WAKU2-MESSAGE.
Thecursorobtained from one node SHOULD NOT be used in a request to another node because the result may be different.- The
errorfield contains information about any error that has occurred while processing the correspondingHistoryQuery.NONEstands for no error. This is also the default value.INVALID_CURSORmeans that thecursorfield ofHistoryQuerydoes not match with theIndexof any of theWakuMessagepersisted by the queried node.
Security Consideration
The main security consideration to take into account while using this protocol is that a querying node have to reveal their content filters of interest to the queried node, hence potentially compromising their privacy.
Future Work
- Anonymous query: This feature guarantees that nodes
can anonymously query historical messages from other nodes i.e.,
without disclosing the exact topics of 14/WAKU2-MESSAGE
they are interested in.
As such, no adversary in the13/WAKU2-STOREprotocol would be able to learn which peer is interested in which content filters i.e., content topics of 14/WAKU2-MESSAGE. The current version of the13/WAKU2-STOREprotocol does not provide anonymity for historical queries, as the querying node needs to directly connect to another node in the13/WAKU2-STOREprotocol and explicitly disclose the content filters of its interest to retrieve the corresponding messages. However, one can consider preserving anonymity through one of the following ways:- By hiding the source of the request i.e., anonymous communication. That is the querying node shall hide all its PII in its history request e.g., its IP address. This can happen by the utilization of a proxy server or by using Tor. Note that the current structure of historical requests does not embody any piece of PII, otherwise, such data fields must be treated carefully to achieve query anonymity.
- By deploying secure 2-party computations in which the querying node obtains the historical messages of a certain topic, the queried node learns nothing about the query. Examples of such 2PC protocols are secure one-way Private Set Intersections (PSI).
- Robust and verifiable timestamps:
Messages timestamp is a way to show that the message existed
prior to some point in time.
However, the lack of timestamp verifiability can create room for a range of attacks,
including injecting messages with invalid timestamps pointing to the far future.
To better understand the attack,
consider a store node whose current clock shows
2021-01-01 00:00:30(and assume all the other nodes have a synchronized clocks +-20seconds). The store node already has a list of messages,(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20), that are sorted based on their timestamp.
An attacker sends a message with an arbitrary large timestamp e.g., 10 hours ahead of the correct clock(m',2021-01-01 10:00:30). The store node placesm'at the end of the list,
(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20),(m',2021-01-01 10:00:30).
Now another message arrives with a valid timestamp e.g.,
(m11, 2021-01-01 00:00:45).
However, since its timestamp precedes the malicious message m',
it gets placed before m' in the list i.e.,
(m1,2021-01-01 00:00:00), (m2,2021-01-01 00:00:01), ..., (m10:2021-01-01 00:00:20), (m11, 2021-01-01 00:00:45), (m',2021-01-01 10:00:30).
In fact, for the next 10 hours,
m' will always be considered as the most recent message and
served as the last message to the querying nodes irrespective
of how many other messages arrive afterward.
A robust and verifiable timestamp allows the receiver of a message to verify that a message has been generated prior to the claimed timestamp. One solution is the use of open timestamps e.g., block height in Blockchain-based timestamps. That is, messages contain the most recent block height perceived by their senders at the time of message generation. This proves accuracy within a range of minutes (e.g., in Bitcoin blockchain) or seconds (e.g., in Ethereum 2.0) from the time of origination.
Copyright
Copyright and related rights waived via CC0.
References
14/WAKU2-MESSAGE
| Field | Value |
|---|---|
| Name | Waku v2 Message |
| Slug | 14 |
| Status | stable |
| Category | Standards Track |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Sanaz Taheri [email protected], Aaryamann Challani [email protected], Lorenzo Delgado [email protected], Abhimanyu Rawat [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-30 —
d5a9240— chore: removed archived (#283) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-09 —
8052808— 14/WAKU2-MESSAGE: Move to Stable (#120) - 2024-11-20 —
ff87c84— Update Waku Links (#104) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
8e70159— Update and rename MESSAGE.md to message.md - 2024-01-27 —
88df5d8— Rename README.md to MESSAGE.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
9cd48a8— Create README.md
Abstract
10/WAKU2 is a family of modular peer-to-peer protocols for secure communication. These protocols are designed to be secure, privacy-preserving, and censorship-resistant and can run in resource-restricted environments. At a high level, 10/WAKU2 implements a publish/subscribe messaging pattern over libp2p and adds capabilities.
The present document specifies the 10/WAKU2 message format. A way to encapsulate the messages sent with specific information security goals, and Whisper/6/WAKU1 backward compatibility.
Motivation
When sending messages over Waku, there are multiple requirements:
- One may have a separate encryption layer as part of the application.
- One may want to provide efficient routing for resource-restricted devices.
- One may want to provide compatibility with 6/WAKU1 envelopes.
- One may want encrypted payloads by default.
- One may want to provide unlinkability to get metadata protection.
This specification attempts to provide for these various requirements.
Semantics
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Waku Message
A WakuMessage is constituted by the combination of data payload and
attributes that, for example, a publisher sends to a topic and
is eventually delivered to subscribers.
The WakuMessage attributes are key-value pairs of metadata associated with a message.
The message data payload is the part of the transmitted WakuMessage
that is the actual message information.
The data payload is also treated as a WakuMessage attribute for convenience.
Message Attributes
-
The
payloadattribute MUST contain the message data payload to be sent. -
The
content_topicattribute MUST specify a string identifier that can be used for content-based filtering, as described in 23/WAKU2-TOPICS. -
The
metaattribute, if present, contains an arbitrary application-specific variable-length byte array with a maximum length limit of 64 bytes. This attribute can be utilized to convey supplementary details to various 10/WAKU2 protocols, thereby enabling customized processing based on its contents. -
The
versionattribute, if present, contains a version number to discriminate different types of payload encryption. If omitted, the value SHOULD be interpreted as version 0. -
The
timestampattribute, if present, signifies the time at which the message was generated by its sender. This attribute MAY contain the Unix epoch time in nanoseconds. If the attribute is omitted, it SHOULD be interpreted as timestamp 0. -
The
rate_limit_proofattribute, if present, contains a rate limit proof encoded as per 17/WAKU2-RLN-RELAY. -
The
ephemeralattribute, if present, signifies the transient nature of the message. For example, an ephemeral message SHOULD not be persisted by other nodes on the same network. If this attribute is set totrue, the message SHOULD be interpreted as ephemeral. If, instead, the attribute is omitted or set tofalse, the message SHOULD be interpreted as non-ephemeral.
Wire Format
The WakuMessage wire format is specified using protocol buffers v3.
syntax = "proto3";
message WakuMessage {
bytes payload = 1;
string content_topic = 2;
optional uint32 version = 3;
optional sint64 timestamp = 10;
optional bytes meta = 11;
optional bytes rate_limit_proof = 21;
optional bool ephemeral = 31;
}
An example proto file following this specification can be found here (vacp2p/waku).
Payload encryption
The WakuMessage payload MAY be encrypted.
The message version attribute indicates
the schema used to encrypt the payload data.
-
Version 0: The payload SHOULD be interpreted as unencrypted; additionally, it CAN indicate that the message payload has been encrypted at the application layer.
-
Version 1: The payload SHOULD be encrypted using 6/WAKU1 payload encryption specified in 26/WAKU-PAYLOAD. This provides asymmetric and symmetric encryption. The key agreement is performed out of band. And provides an encrypted signature and padding for some form of unlinkability.
-
Version 2: The payload SHOULD be encoded according to WAKU2-NOISE. The Waku Noise protocol provides symmetric encryption and asymmetric key exchange.
Any version value not included in this list is reserved for future specification.
And, in this case, the payload SHOULD be interpreted as unencrypted by the Waku layer.
Whisper/6/WAKU1 envelope compatibility
Whisper/6/WAKU1 envelopes are compatible with Waku messages format.
- Whisper/6/WAKU1
topicfield SHOULD be mapped to Waku message'scontent_topicattribute. - Whisper/6/WAKU1
datafield SHOULD be mapped to Waku message'spayloadattribute.
10/WAKU2 implements a publish/subscribe messaging pattern over libp2p.
This makes some Whisper/6/WAKU1 envelope fields redundant
(e.g., expiry, ttl, topic, etc.), so they can be ignored.
Deterministic message hashing
In Protocol Buffers v3, the deterministic serialization is not canonical across the different implementations and languages. It is also unstable across different builds with schema changes due to unknown fields.
To overcome this interoperability limitation, a 10/WAKU2 message's hash MUST be computed following this schema:
message_hash = sha256(concat(pubsub_topic, message.payload, message.content_topic, message.meta, message.timestamp))
If an optional attribute, such as meta, is absent,
the concatenation of attributes SHOULD exclude it.
This recommendation is made to ensure that the concatenation process proceeds smoothly
when certain attributes are missing and to maintain backward compatibility.
This hashing schema is deemed appropriate for use cases where a cross-implementation deterministic hash is needed, such as message deduplication and integrity validation. The collision probability offered by this hashing schema can be considered negligible. This is due to the deterministic concatenation order of the message attributes, coupled with using a SHA-2 (256-bit) hashing algorithm.
Test vectors
The WakuMessage hash computation (meta size of 12 bytes):
pubsub_topic = "/waku/2/default-waku/proto" (0x2f77616b752f322f64656661756c742d77616b752f70726f746f)
message.payload = 0x010203045445535405060708
message.content_topic = "/waku/2/default-content/proto" (0x2f77616b752f322f64656661756c742d636f6e74656e742f70726f746f)
message.meta = 0x73757065722d736563726574
message.timestamp = 0x175789bfa23f8400
message_hash = 0x64cce733fed134e83da02b02c6f689814872b1a0ac97ea56b76095c3c72bfe05
The WakuMessage hash computation (meta size of 64 bytes):
pubsub_topic = "/waku/2/default-waku/proto" (0x2f77616b752f322f64656661756c742d77616b752f70726f746f)
message.payload = 0x010203045445535405060708
message.content_topic = "/waku/2/default-content/proto" (0x2f77616b752f322f64656661756c742d636f6e74656e742f70726f746f)
message.meta = 0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f
message.timestamp = 0x175789bfa23f8400
message_hash = 0x7158b6498753313368b9af8f6e0a0a05104f68f972981da42a43bc53fb0c1b27
The WakuMessage hash computation (meta attribute not present):
pubsub_topic = "/waku/2/default-waku/proto" (0x2f77616b752f322f64656661756c742d77616b752f70726f746f)
message.payload = 0x010203045445535405060708
message.content_topic = "/waku/2/default-content/proto" (0x2f77616b752f322f64656661756c742d636f6e74656e742f70726f746f)
message.meta = <not-present>
message.timestamp = 0x175789bfa23f8400
message_hash = 0xa2554498b31f5bcdfcbf7fa58ad1c2d45f0254f3f8110a85588ec3cf10720fd8
The WakuMessage hash computation (payload length 0):
pubsub_topic = "/waku/2/default-waku/proto" (0x2f77616b752f322f64656661756c742d77616b752f70726f746f)
message.payload = []
message.content_topic = "/waku/2/default-content/proto" (0x2f77616b752f322f64656661756c742d636f6e74656e742f70726f746f)
message.meta = 0x73757065722d736563726574
message.timestamp = 0x175789bfa23f8400
message_hash = 0x483ea950cb63f9b9d6926b262bb36194d3f40a0463ce8446228350bd44e96de4
Security Considerations
Confidentiality, integrity, and authenticity
The level of confidentiality, integrity, and
authenticity of the WakuMessage payload is discretionary.
Accordingly, the application layer shall utilize the encryption and
signature schemes supported by 10/WAKU2,
to meet the application-specific privacy needs.
Reliability of the timestamp attribute
The message timestamp attribute is set by the sender.
Therefore, because message timestamps aren’t independently verified,
this attribute is prone to exploitation and misuse.
It should not solely be relied upon for operations such as message ordering.
For example, a malicious actor can arbitrarily set the timestamp of a WakuMessage
to a high value so that it always shows up as the most recent message
in a chat application.
Applications using 10/WAKU2 messages’ timestamp attribute
are RECOMMENDED to use additional methods for more robust message ordering.
An example of how to deal with message ordering against adversarial message timestamps
can be found in the Status protocol,
see 62/STATUS-PAYLOADS.
Reliability of the ephemeral attribute
The message ephemeral attribute is set by the sender.
Since there is currently no incentive mechanism
for network participants to behave correctly,
this attribute is inherently insecure.
A malicious actor can tamper with the value of a Waku message’s ephemeral attribute,
and the receiver would not be able to verify the integrity of the message.
Copyright
Copyright and related rights waived via CC0.
References
- 10/WAKU2
- 6/WAKU1
- 23/WAKU2-TOPICS
- 17/WAKU2-RLN-RELAY
- 64/WAKU2-NETWORK
- protocol buffers v3
- 26/WAKU-PAYLOAD
- WAKU2-NOISE
- 62/STATUS-PAYLOADS
15/WAKU-BRIDGE
| Field | Value |
|---|---|
| Name | Waku Bridge |
| Slug | 15 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-01-28 —
c3d5fe6— 15/WAKU2-BRIDGE: Update (#113) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-01 —
d637b10— Update and rename BRIDGE.md to bridge.md - 2024-01-27 —
4bf2f6e— Rename README.md to BRIDGE.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
f883f26— Create README.md
Abstract
This specification describes how 6/WAKU1 traffic can be used with 10/WAKU2 networks.
Wire Format
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
A bridge requires supporting both Waku versions:
Publishing Packets
Packets received on 6/WAKU1 networks SHOULD be published just once on 10/WAKU2 networks. More specifically, the bridge SHOULD publish this through 11/WAKU2-RELAY (PubSub domain).
When publishing such packet,
the creation of a new Message with a new WakuMessage as data field is REQUIRED.
The data and
topic field, from the 6/WAKU1 Envelope,
MUST be copied to the payload and content_topic fields of the WakuMessage.
See 14/WAKU2-MESSAGE
for message format details.
Other fields such as nonce, expiry and
ttl will be dropped as they become obsolete in 10/WAKU2.
Before this is done, the usual envelope verification still applies:
- Expiry & future time verification
- PoW verification
- Size verification
Bridging SHOULD occur through the 11/WAKU2-RELAY, but it MAY also be done on other 10/WAKU2 protocols (e.g. 12/WAKU2-FILTER). The latter is however not advised as it will increase the complexity of the bridge and because of the Security Considerations explained further below.
Packets received on 10/WAKU2 networks,
SHOULD be posted just once on 6/WAKU1 networks.
The 14/WAKU2-MESSAGE contains only the payload and
contentTopic fields.
The bridge MUST create a new 6/WAKU1 Envelope and
copy over the payload and contentFilter
fields to the data and topic fields.
Next, before posting on the network,
the bridge MUST set a new expiry, ttl and do the PoW nonce calculation.
Security Considerations
As mentioned above,
a bridge will be posting new 6/WAKU1 envelopes,
which requires doing the PoW nonce calculation.
This could be a DoS attack vector, as the PoW calculation will make it more expensive to post the message compared to the original publishing on 10/WAKU2 networks. Low PoW setting will lower this problem, but it is likely that it is still more expensive.
For this reason, it is RECOMMENDED to run bridges independently of other nodes, so that a bridge that gets overwhelmed does not disrupt regular Waku v2 to v2 traffic.
Bridging functionality SHOULD also be carefully implemented so that messages do not bounce back and forth between the 10/WAKU2 and 6/WAKU1 networks. The bridge SHOULD properly track messages with a seen filter, so that no amplification occurs.
Copyright
Copyright and related rights waived via CC0.
References
17/WAKU2-RLN-RELAY
| Field | Value |
|---|---|
| Name | Waku v2 RLN Relay |
| Slug | 17 |
| Status | draft |
| Editor | Alvaro Revuelta [email protected] |
| Contributors | Oskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected], Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-11-20 —
776c1b7— rfc-index: Update (#110) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-06-06 —
5064ded— Update 17/WAKU2-RLN-RELAY: Proof Size (#44) - 2024-05-28 —
7b443c1— 17/WAKU2-RLN-RELAY: Update (#32) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
244ea55— Update and rename RLN-RELAY.md to rln-relay.md - 2024-01-27 —
7bcefac— Rename README.md to RLN-RELAY.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-26 —
1ed5919— Update README.md - 2024-01-25 —
4b3b4e3— Create README.md
Abstract
This specification describes the 17/WAKU2-RLN-RELAY protocol,
which is an extension of 11/WAKU2-RELAY
to provide spam protection using Rate Limiting Nullifiers (RLN).
The security objective is to contain spam activity in the 64/WAKU-NETWORK by enforcing a global messaging rate to all the peers. Peers that violate the messaging rate are considered spammers and their message is considered spam. Spammers are also financially punished and removed from the system.
Motivation
In open and anonymous p2p messaging networks, one big problem is spam resistance. Existing solutions, such as Whisper’s proof of work, are computationally expensive hence not suitable for resource-limited nodes. Other reputation-based approaches might not be desirable, due to issues around arbitrary exclusion and privacy.
We augment the 11/WAKU2-RELAY protocol
with a novel construct of RLN
to enable an efficient economic spam prevention mechanism
that can be run in resource-constrained environments.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Flow
The messaging rate is defined by the period
which indicates how many messages can be sent in a given period.
We define an epoch as $\lceil$ unix_time / period $\rceil$.
For example, if unix_time is 1644810116 and we set period to 30,
then epoch is $\lceil$ (unix_time/period) $\rceil$ = 54827003.
NOTE: The
epochrefers to the epoch in RLN and not Unix epoch. This means a message can only be sent every period, where theperiodis up to the application.
See section Recommended System Parameters
for the RECOMMENDED method to set a sensible period value depending on the application.
Peers subscribed to a spam-protected pubsubTopic
are only allowed to send one message per epoch.
The higher-level layers adopting 17/WAKU2-RLN-RELAY
MAY choose to enforce the messaging rate for WakuMessages
with a specific contentTopic published on a pubsubTopic.
Setup and Registration
A pubsubTopic that is spam-protected requires subscribed peers to form a RLN group.
- Peers MUST be registered to the RLN group to be able to publish messages.
- Registration MAY be moderated through a smart contract deployed on the Ethereum blockchain.
Each peer has an RLN key pair denoted by sk
and pk.
- The secret key
skis secret data and MUST be persisted securely by the peer. - The state of the membership contract
SHOULD contain a list of all registered members' public identity keys i.e.,
pks.
For registration,
a peer MUST create a transaction to invoke the registration function on the contract,
which registers its pk in the RLN group.
- The transaction MUST transfer additional tokens to the contract to be staked.
This amount is denoted by
staked_fundand is a system parameter. The peer who has the secret keyskassociated with a registeredpkwould be able to withdraw a portionreward_portionof the staked fund by providing valid proof.
reward_portion is also a system parameter.
NOTE: Initially
skis only known to its owning peer however, it may get exposed to other peers in case the owner attempts spamming the system i.e., sending more than one message perepoch.
An overview of registration is illustrated in Figure 1.

Publishing
To publish at a given epoch, the publishing peer proceeds
based on the regular 11/WAKU2-RELAY protocol.
However, to protect against spamming, each WakuMessage
(which is wrapped inside the data field of a PubSub message)
MUST carry a RateLimitProof with the following fields.
Section Payload covers the details about the type and
encoding of these fields.
- The
merkle_rootcontains the root of the Merkle tree. - The
epochrepresents the current epoch. - The
nullifieris an internal nullifier acting as a fingerprint that allows specifying whether two messages are published by the same peer during the sameepoch. - The
nullifieris a deterministic value derived fromskandepochtherefore any two messages issued by the same peer (i.e., using the samesk) for the sameepochare guaranteed to have identicalnullifiers. - The
share_xandshare_ycan be seen as partial disclosure of peer'sskfor the intendedepoch. They are derived deterministically from peer'sskand currentepochusing Shamir secret sharing scheme.
If a peer discloses more than one such pair (share_x, share_y) for the same epoch,
it would allow full disclosure of its sk and
hence get access to its staked fund in the membership contract.
- The
prooffield is a zero-knowledge proof signifying that:
- The message owner is the current member of the group i.e.,
the peer's identity commitment key,
pk, is part of the membership group Merkle tree with the rootmerkle_root. share_xandshare_yare correctly computed.- The
nullifieris constructed correctly. For more details about the proof generation check RLN The proof generation relies on the knowledge of two pieces of private information i.e.,skandauthPath. TheauthPathis a subset of Merkle tree nodes by which a peer can prove the inclusion of itspkin the group.
The proof generation also requires a set of public inputs which are:
the Merkle tree root merkle_root, the current epoch, and
the message for which the proof is going to be generated.
In 17/WAKU2-RLN-RELAY,
the message is the concatenation of WakuMessage's payload filed and
its contentTopic i.e., payload||contentTopic.
Group Synchronization
Proof generation relies on the knowledge of Merkle tree root merkle_root and
authPath which both require access to the membership Merkle tree.
Getting access to the Merkle tree can be done in various ways:
-
Peers construct the tree locally. This can be done by listening to the registration and deletion events emitted by the membership contract. Peers MUST update the local Merkle tree on a per-block basis. This is discussed further in the Merkle Root Validation section.
-
For synchronizing the state of slashed
pks, disseminate such information through apubsubTopicto which all peers are subscribed. A deletion transaction SHOULD occur on the membership contract. The benefit of an off-chain slashing is that it allows real-time removal of spammers as opposed to on-chain slashing in which peers get informed with a delay, where the delay is due to mining the slashing transaction.
For the group synchronization,
one important security consideration is that peers MUST make sure they always use
the most recent Merkle tree root in their proof generation.
The reason is that using an old root can allow inference
about the index of the user's pk in the membership tree
hence compromising user privacy and breaking message unlinkability.
Routing
Upon the receipt of a PubSub message via 11/WAKU2-RELAY protocol,
the routing peer parses the data field as a WakuMessage and
gets access to the RateLimitProof field.
The peer then validates the RateLimitProof as explained next.
Epoch Validation
If the epoch attached to the WakuMessage is more than max_epoch_gap,
apart from the routing peer's current epoch,
then the WakuMessage MUST be discarded and considered invalid.
This is to prevent a newly registered peer from spamming the system
by messaging for all the past epochs.
max_epoch_gap is a system parameter
for which we provide some recommendations in section Recommended System Parameters.
Merkle Root Validation
The routing peers MUST check whether the provided Merkle root
in the RateLimitProof is valid.
It can do so by maintaining a local set of valid Merkle roots,
which consist of acceptable_root_window_size past roots.
These roots refer to the final state of the Merkle tree
after a whole block consisting of group changes is processed.
The Merkle roots are updated on a per-block basis instead of a per-event basis.
This is done because if Merkle roots are updated on a per-event basis,
some peers could send messages with a root that refers to a Merkle tree state
that might get invalidated while the message is still propagating in the network,
due to many registrations happening during this time frame.
By updating roots on a per-block basis instead,
we will have only one root update per-block processed,
regardless on how many registrations happened in a block, and
peers will be able to successfully propagate messages in a time frame
corresponding to roughly the size of the roots window times the block mining time.
Atomic processing of the blocks are necessary so that even if the peer is unable to process one event, the previous roots remain valid, and can be used to generate valid RateLimitProof's.
This also allows peers which are not well connected to the network
to be able to send messages, accounting for network delay.
This network delay is related to the nature of asynchronous network conditions,
which means that peers see membership changes asynchronously, and
therefore may have differing local Merkle trees.
See Recommended System Parameters
on choosing an appropriate acceptable_root_window_size.
Proof Verification
The routing peers MUST check whether the zero-knowledge proof proof is valid.
It does so by running the zk verification algorithm as explained in RLN.
If proof is invalid then the message MUST be discarded.
Spam detection
To enable local spam detection and slashing,
routing peers MUST record the nullifier, share_x, and share_y
of incoming messages which are not discarded i.e.,
not found spam or with invalid proof or epoch.
To spot spam messages, the peer checks whether a message
with an identical nullifier has already been relayed.
- If such a message exists and its
share_xandshare_ycomponents are different from the incoming message, then slashing takes place. That is, the peer uses theshare_xandshare_yof the new message and theshare'_xandshare'_yof the old record to reconstruct theskof the message owner. Theskthen MAY be used to delete the spammer from the group and withdraw a portionreward_portionof its staked funds. - If the
share_xandshare_yfields of the previously relayed message are identical to the incoming message, then the message is a duplicate and MUST be discarded. - If none is found, then the message gets relayed.
An overview of the routing procedure and slashing is provided in Figure 2.

Payloads
Payloads are protobuf messages implemented using protocol buffers v3.
Nodes MAY extend the 14/WAKU2-MESSAGE
with a rate_limit_proof field to indicate that their message is not spam.
syntax = "proto3";
message RateLimitProof {
bytes proof = 1;
bytes merkle_root = 2;
bytes epoch = 3;
bytes share_x = 4;
bytes share_y = 5;
bytes nullifier = 6;
}
message WakuMessage {
bytes payload = 1;
string content_topic = 2;
optional uint32 version = 3;
optional sint64 timestamp = 10;
optional bool ephemeral = 31;
RateLimitProof rate_limit_proof = 21;
}
WakuMessage
rate_limit_proof holds the information required to prove that the message owner
has not exceeded the message rate limit.
RateLimitProof
Below is the description of the fields of RateLimitProof and their types.
| Parameter | Type | Description |
|---|---|---|
proof | array of 256 bytes uncompressed or 128 bytes compressed | the zkSNARK proof as explained in the Publishing process |
merkle_root | array of 32 bytes in little-endian order | the root of membership group Merkle tree at the time of publishing the message |
share_x and share_y | array of 32 bytes each | Shamir secret shares of the user's secret identity key sk . share_x is the Poseidon hash of the WakuMessage's payload concatenated with its contentTopic . share_y is calculated using Shamir secret sharing scheme |
nullifier | array of 32 bytes | internal nullifier derived from epoch and peer's sk as explained in RLN construct |
Recommended System Parameters
The system parameters are summarized in the following table, and the RECOMMENDED values for a subset of them are presented next.
| Parameter | Description |
|---|---|
period | the length of epoch in seconds |
staked_fund | the amount of funds to be staked by peers at the registration |
reward_portion | the percentage of staked_fund to be rewarded to the slashers |
max_epoch_gap | the maximum allowed gap between the epoch of a routing peer and the incoming message |
acceptable_root_window_size | The maximum number of past Merkle roots to store |
Epoch Length
A sensible value for the period depends on the application
for which the spam protection is going to be used.
For example, while the period of 1 second i.e.,
messaging rate of 1 per second, might be acceptable for a chat application,
might be too low for communication among Ethereum network validators.
One should look at the desired throughput of the application
to decide on a proper period value.
Maximum Epoch Gap
We discussed in the Routing section that the gap between the epoch
observed by the routing peer and
the one attached to the incoming message
should not exceed a threshold denoted by max_epoch_gap.
The value of max_epoch_gap can be measured based on the following factors.
- Network transmission delay
Network_Delay: the maximum time that it takes for a message to be fully disseminated in the GossipSub network. - Clock asynchrony
Clock_Asynchrony: The maximum difference between the Unix epoch clocks perceived by network peers which can be due to clock drifts.
With a reasonable approximation of the preceding values,
one can set max_epoch_gap as
max_epoch_gap
$= \lceil \frac{\text{Network Delay} + \text{Clock Asynchrony}}{\text{Epoch Length}}\rceil$
where period is the length of the epoch in seconds.
Network_Delay and Clock_Asynchrony MUST have the same resolution as period.
By this formulation, max_epoch_gap indeed measures the maximum number of epochs
that can elapse since a message gets routed from its origin
to all the other peers in the network.
acceptable_root_window_size depends upon the underlying chain's average blocktime,
block_time
The lower bound for the acceptable_root_window_size SHOULD be set as $acceptable_root_window_size=(Network_Delay)/block_time$
Network_Delay MUST have the same resolution as block_time.
By this formulation,
acceptable_root_window_size will provide a lower bound
of how many roots can be acceptable by a routing peer.
The acceptable_root_window_size should indicate how many blocks may have been mined
during the time it takes for a peer to receive a message.
This formula represents a lower bound of the number of acceptable roots.
Copyright
Copyright and related rights waived via CC0.
References
11/WAKU2-RELAY- 64/WAKU-NETWORK
- RLN
- 14/WAKU2-MESSAGE
- RLN documentation
- Public inputs to the RLN circuit
- Shamir secret sharing scheme used in RLN
- RLN internal nullifier
19/WAKU2-LIGHTPUSH
| Field | Value |
|---|---|
| Name | Waku v2 Light Push |
| Slug | 19 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Daniel Kaiser [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-11-20 —
ff87c84— Update Waku Links (#104) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
c88680a— Update and rename LIGHTPUSH.md to lightpush.md - 2024-01-27 —
f9efd29— Rename README.md to LIGHTPUSH.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
c90013b— Create README.md
Protocol identifier: /vac/waku/lightpush/2.0.0-beta1
Motivation and Goals
Light nodes with short connection windows and limited bandwidth wish to publish messages into the Waku network. Additionally, there is sometimes a need for confirmation that a message has been received "by the network" (here, at least one node).
19/WAKU2-LIGHTPUSH is a request/response protocol for this.
Payloads
syntax = "proto3";
message PushRequest {
string pubsub_topic = 1;
WakuMessage message = 2;
}
message PushResponse {
bool is_success = 1;
// Error messages, etc
string info = 2;
}
message PushRPC {
string request_id = 1;
PushRequest request = 2;
PushResponse response = 3;
}
Message Relaying
Nodes that respond to PushRequests MUST either
relay the encapsulated message via 11/WAKU2-RELAY protocol
on the specified pubsub_topic,
or forward the PushRequest via 19/LIGHTPUSH on a WAKU2-DANDELION
stem.
If they are unable to do so for some reason,
they SHOULD return an error code in PushResponse.
Security Considerations
Since this can introduce an amplification factor, it is RECOMMENDED for the node relaying to the rest of the network to take extra precautions. This can be done by rate limiting via 17/WAKU2-RLN-RELAY.
Note that the above is currently not fully implemented.
Copyright
Copyright and related rights waived via CC0.
References
31/WAKU2-ENR
| Field | Value |
|---|---|
| Name | Waku v2 usage of ENR |
| Slug | 31 |
| Status | draft |
| Editor | Franck Royer [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-10-16 —
e4f5f28— Update WAKU-ENR: Move to Draft (#180)
Abstract
This specification describes the usage of the ENR (Ethereum Node Records) format for 10/WAKU2 purposes. The ENR format is defined in EIP-778 [3].
This specification is an extension of EIP-778, ENR used in Waku MUST adhere to both EIP-778 and 31/WAKU2-ENR.
Motivation
EIP-1459 with the usage of ENR has been implemented [1] [2] as a discovery protocol for Waku.
EIP-778 specifies a number of pre-defined keys. However, the usage of these keys alone does not allow for certain transport capabilities to be encoded, such as Websocket. Currently, Waku nodes running in a browser only support websocket transport protocol. Hence, new ENR keys need to be defined to allow for the encoding of transport protocol other than raw TCP.
Usage of Multiaddr Format Rationale
One solution would be to define new keys such as ws to encode the websocket port of a node.
However, we expect new transport protocols to be added overtime such as quic.
Hence, this would only provide a short term solution until another specification would need to be added.
Moreover, secure websocket involves SSL certificates. SSL certificates are only valid for a given domain and ip, so an ENR containing the following information:
- secure websocket port
- ipv4 fqdn
- ipv4 address
- ipv6 address
Would carry some ambiguity: Is the certificate securing the websocket port valid for the ipv4 fqdn? the ipv4 address? the ipv6 address?
The 10/WAKU2 protocol family is built on the libp2p protocol stack. Hence, it uses multiaddr to format network addresses.
Directly storing one or several multiaddresses in the ENR would fix the issues listed above:
- multiaddr is self-describing and support addresses for any network protocol: No new specification would be needed to support encoding other transport protocols in an ENR.
- multiaddr contains both the host and port information, allowing the ambiguity previously described to be resolved.
Wire Format
multiaddrs ENR key
We define a multiaddrs key.
- The value MUST be a list of binary encoded multiaddr prefixed by their size.
- The size of the multiaddr MUST be encoded in a Big Endian unsigned 16-bit integer.
- The size of the multiaddr MUST be encoded in 2 bytes.
- The
secp256k1value MUST be present on the record;secp256k1is defined in EIP-778 and contains the compressed secp256k1 public key. - The node's peer id SHOULD be deduced from the
secp256k1value. - The multiaddresses SHOULD NOT contain a peer id except for circuit relay addresses
- For raw TCP & UDP connections details,
EIP-778 pre-defined keys SHOULD be used;
The keys
tcp,udp,ip(andtcp6,udp6,ip6for IPv6) are enough to convey all necessary information; - To save space,
multiaddrskey SHOULD only be used for connection details that cannot be represented using the EIP-778 pre-defined keys. - The 300 bytes size limit as defined by EIP-778 still applies; In practice, it is possible to encode 3 multiaddresses in ENR, more or less could be encoded depending on the size of each multiaddress.
Usage
Many connection types
Alice is a Waku node operator, she runs a node that supports inbound connection for the following protocols:
- TCP 10101 on
1.2.3.4 - UDP 20202 on
1.2.3.4 - TCP 30303 on
1234:5600:101:1::142 - UDP 40404 on
1234:5600:101:1::142 - Secure Websocket on
wss://example.com:443/ - QUIC on
quic://quic.example.com:443/ - A circuit relay address
/ip4/1.2.3.4/tcp/55555/p2p/QmRelay/p2p-circuit/p2p/QmAlice
Alice SHOULD structure the ENR for her node as follows:
| key | value |
|---|---|
tcp | 10101 |
udp | 20202 |
tcp6 | 30303 |
udp6 | 40404 |
ip | 1.2.3.4 |
ip6 | 1234:5600:101:1::142 |
secp256k1 | Alice's compressed secp256k1 public key, 33 bytes |
multiaddrs | len1 | /dns4/example.com/tcp/443/wss | len2 | /dns4/quic.examle.com/tcp/443/quic | len3 | /ip4/1.2.3.4/tcp/55555/p2p/QmRelay |
Where multiaddrs:
|is the concatenation operator,len1is the length of/dns4/example.com/tcp/443/wssbyte representation,len2is the length of/dns4/quic.examle.com/tcp/443/quicbyte representation.len3is the length of/ip4/1.2.3.4/tcp/55555/p2p/QmRelaybyte representation. Notice that the/p2p-circuitcomponent is not stored, but, since circuit relay addresses are the only one containing ap2pcomponent, it's safe to assume that any address containing this component is a circuit relay address. Decoding this type of multiaddresses would require appending the/p2p-circuitcomponent.
Raw TCP only
Bob is a node operator that runs a node that supports inbound connection for the following protocols:
- TCP 10101 on
1.2.3.4
Bob SHOULD structure the ENR for his node as follows:
| key | value |
|---|---|
tcp | 10101 |
ip | 1.2.3.4 |
secp256k1 | Bob's compressed secp256k1 public key, 33 bytes |
As Bob's node's connection details can be represented with EIP-778's pre-defined keys only,
it is not needed to use the multiaddrs key.
Limitations
Supported key type is secp256k1 only.
Support for other elliptic curve cryptography such as ed25519 MAY be used.
waku2 ENR key
We define a waku2 field key:
- The value MUST be an 8-bit flag field,
where bits set to
1indicatetrueand bits set to0indicatefalsefor the relevant flags. - The flag values already defined are set out below,
with
bit 7the most significant bit andbit 0the least significant bit.
| bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 |
|---|---|---|---|---|---|---|---|
undef | undef | undef | sync | lightpush | filter | store | relay |
- In the scheme above, the flags
sync,lightpush,filter,storeandrelaycorrelates with support for protocols with the same name. If a protocol is not supported, the corresponding field MUST be set tofalse. Indicating positive support for any specific protocol is OPTIONAL, though it MAY be required by the relevant application or discovery process. - Flags marked as
undefis not yet defined. These SHOULD be set tofalseby default.
Key Usage
- A Waku node MAY choose to populate the
waku2field for enhanced discovery capabilities, such as indicating supported protocols. Such a node MAY indicate support for any specific protocol by setting the corresponding flag totrue. - Waku nodes that want to participate in Node Discovery Protocol v5 [4], however,
MUST implement the
waku2key with at least one flag set totrue. - Waku nodes that discovered other participants using Discovery v5,
MUST filter out participant records that do not implement this field or
do not have at least one flag set to
true. - In addition, such nodes MAY choose to filter participants on specific flags
(such as supported protocols),
or further interpret the
waku2field as required by the application.
Copyright
Copyright and related rights waived via CC0.
References
33/WAKU2-DISCV5
| Field | Value |
|---|---|
| Name | Waku v2 Discv5 Ambient Peer Discovery |
| Slug | 33 |
| Status | draft |
| Editor | Daniel Kaiser [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-29 —
5971166— Update discv5.md (#139) - 2024-11-20 —
87d4ff7— Workflow Fix: markdown-lint (#111) - 2024-11-20 —
dcc579c— Update WAKU2-PEER-EXCHANGE: Move to draft (#7) - 2024-11-20 —
ff87c84— Update Waku Links (#104) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
38d68ce— Update discv5.md - 2024-02-01 —
b8f8d20— Update and rename DISCV5.md to discv5.md - 2024-01-27 —
c6ef447— Rename README.md to DISCV5.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
037474d— Create README.md
Abstract
33/WAKU2-DISCV5 specifies a modified version of
Ethereum's Node Discovery Protocol v5
as a means for ambient node discovery.
10/WAKU2 uses the 33/WAKU2-DISCV5 ambient node discovery network
for establishing a decentralized network of interconnected Waku2 nodes.
In its current version,
the 33/WAKU2-DISCV5 discovery network
is isolated from the Ethereum Discovery v5 network.
Isolation improves discovery efficiency,
which is especially significant with a low number of Waku nodes
compared to the total number of Ethereum nodes.
Disclaimer
This version of 33/WAKU2-DISCV5 has a focus on timely deployment
of an efficient discovery method for 10/WAKU2.
Establishing a separate discovery network is in line with this focus.
However, we are aware of potential resilience problems
(see section on security considerations) and
are discussing
and researching hybrid approaches.
Background and Rationale
11/WAKU2-RELAY assumes the existence of a network of Waku2 nodes. For establishing and growing this network, new nodes trying to join the Waku2 network need a means of discovering nodes within the network. 10/WAKU2 supports the following discovery methods in order of increasing decentralization
- hard coded bootstrap nodes
DNS discovery(based on EIP-1459)34/WAKU2-PEER-EXCHANGE33/WAKU2-DISCV5(specified in this document)
The purpose of ambient node discovery within 10/WAKU2
is discovering Waku2 nodes in a decentralized way.
The unique selling point of 33/WAKU2-DISCV5 is its holistic view of the network,
which allows avoiding hotspots and allows merging the network after a split.
While the other methods provide either a fixed or local set of nodes,
33/WAKU2-DISCV5 can provide a random sample of Waku2 nodes.
Future iterations of this document will add the possibility
of efficiently discovering Waku2 nodes that have certain capabilities,
e.g. holding messages of a certain time frame
during which the querying node was offline.
Separate Discovery Network
w.r.t. Waku2 Relay Network
33/WAKU2-DISCV5 spans an overlay network separate from the
GossipSub
network 11/WAKU2-RELAY builds on.
Because it is a P2P network on its own, it also depends on bootstrap nodes.
Having a separate discovery network reduces load on the bootstrap nodes,
because the actual work is done by randomly discovered nodes.
This also increases decentralization.
w.r.t. Ethereum Discovery v5
33/WAKU2-DISCV5 spans a discovery network
isolated from the Ethereum Discovery v5 network.
Another simple solution would be taking part in the Ethereum Discovery network, and filtering Waku nodes based on whether they support WAKU2-ENR. This solution is more resilient towards eclipse attacks. However, this discovery method is very inefficient for small percentages of Waku nodes (see estimation). It boils down to random walk discovery and does not offer a O(log(n)) hop bound. The rarer the requested property (in this case Waku), the longer a random walk will take until finding an appropriate node, which leads to a needle-in-the-haystack problem. Using a dedicated Waku2 discovery network, nodes can query this discovery network for a random set of nodes and all (well-behaving) returned nodes can serve as bootstrap nodes for other Waku2 protocols.
A more sophisticated solution would be using Discv5 topic discovery. However, in its current state it also has efficiency problems for small percentages of Waku nodes and is still in the design phase (see here).
Currently, the Ethereum discv5 network is very efficient in finding other discv5 nodes, but it is not so efficient for finding discv5 nodes that have a specific property or offer specific services, e.g. Waku.
As part of our discv5 roadmap,
we consider two ideas for future versions of 33/WAKU2-DISCV5
- Discv5 topic discovery with adjustments (ideally upstream)
- a hybrid solution that uses both a separate discv5 network and a Waku-ENR-filtered Ethereum discv5 network
Semantics
33/WAKU2-DISCV5 fully inherits the discv5 semantics.
Before announcing their address via Waku2 discv5, nodes SHOULD check if this address is publicly reachable. Nodes MAY use the libp2p AutoNAT protocol to perform that check. Nodes SHOULD only announce publicly reachable addresses via Waku2 discv5, to avoid cluttering peer lists with nodes that are not reachable.
Wire Format Specification
33/WAKU2-DISCV5 inherits the discv5 wire protocol
except for the following differences
WAKU2-Specific protocol-id
Ethereum discv5:
header = static-header || authdata
static-header = protocol-id || version || flag || nonce || authdata-size
protocol-id = <b>"discv5"</b>
version = 0x0001
authdata-size = uint16 -- byte length of authdata
flag = uint8 -- packet type identifier
nonce = uint96 -- nonce of message
33/WAKU2-DISCV5:
kcode>
header = static-header || authdata
static-header = protocol-id || version || flag || nonce || authdata-size
protocol-id = <b>"d5waku"</b>
version = 0x0001
authdata-size = uint16 -- byte length of authdata
flag = uint8 -- packet type identifier
nonce = uint96 -- nonce of message
Suggestions for Implementations
Existing discv5 implementations
- can be augmented to make the
protocol-idselectable using a compile-time flag as in this feature branch of nim-eth/discv5. - can be forked followed by changing the
protocol-idstring as in go-waku.
Security Considerations
Sybil attack
Implementations should limit the number of bucket entries that have the same network parameters (IP address / port) to mitigate Sybil attacks.
Eclipse attack
Eclipse attacks aim to eclipse certain regions in a DHT. Malicious nodes provide false routing information for certain target regions. The larger the desired eclipsed region, the more resources (i.e. controlled nodes) the attacker needs. This introduces an efficiency versus resilience tradeoff. Discovery is more efficient if information about target objects (e.g. network parameters of nodes supporting Waku) are closer to a specific DHT address. If nodes providing specific information are closer to each other, they cover a smaller range in the DHT and are easier to eclipse.
Sybil attacks greatly increase the power of eclipse attacks, because they significantly reduce resources necessary to mount a successful eclipse attack.
Security Implications of a Separate Discovery Network
A dedicated Waku discovery network is more likely to be subject to successful eclipse attacks (and to DoS attacks in general). This is because eclipsing in a smaller network requires less resources for the attacker. DoS attacks render the whole network unusable if the percentage of attacker nodes is sufficient.
Using random walk discovery would mitigate eclipse attacks targeted at specific capabilities, e.g. Waku. However, this is because eclipse attacks aim at the DHT overlay structure, which is not used by random walks. So, this mitigation would come at the cost of giving up overlay routing efficiency. The efficiency loss is especially severe with a relatively small number of Waku nodes.
Properly protecting against eclipse attacks is challenging and raises research questions that we will address in future stages of our discv5 roadmap.
References
- 10/WAKU2
34/WAKU2-PEER-EXCHANGE- 11/WAKU2-RELAY
- WAKU2-ENR
- Node Discovery Protocol v5 (
discv5) discv5semantics.discv5wire protocoldiscv5topic discovery- libp2p AutoNAT protocol
EIP-1459GossipSub- Waku discv5 roadmap discussion
- discovery efficiency estimation
- implementation: Nim
- implementation: Go
Copyright
Copyright and related rights waived via CC0.
34/WAKU2-PEER-EXCHANGE
| Field | Value |
|---|---|
| Name | Waku2 Peer Exchange |
| Slug | 34 |
| Status | draft |
| Category | Standards Track |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Daniel Kaiser [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-12-07 —
2b297d5— Update peer-exchange.md to fix a build error (#114) - 2024-11-20 —
87d4ff7— Workflow Fix: markdown-lint (#111) - 2024-11-20 —
dcc579c— Update WAKU2-PEER-EXCHANGE: Move to draft (#7)
Abstract
This document specifies a simple request-response peer exchange protocol. Responders send information about a requested number of peers. The main purpose of this protocol is providing resource restricted devices with peers.
Protocol Identifier
/vac/waku/peer-exchange/2.0.0-alpha1
Background and Motivation
It may not be feasible, on resource restricted devices, to take part in distributed random sampling ambient peer discovery protocols, such as 33/WAKU2-DISCV5. The Waku peer discovery protocol, specified in this document, allows resource restricted devices to request a list of peers from a service node. Network parameters necessary to connect to this service node COULD be learned from a static bootstrapping method or using EIP-1459: Node Discovery via DNS. The advantage of using Waku peer exchange to discover new peers, compared to relying on a static peer list or DNS discovery, is a more even load distribution. If a lot of resource restricted nodes would use the same service nodes as relay or store nodes, the load on these would be very high. Heavily used static nodes also add a centralized element. Downtime of such a node might significantly impact the network.
However, the resource efficiency of this protocol comes at an anonymity cost, which is explained in the Security/Privacy Considerations section. This protocol SHOULD only be used if 33/WAKU2-DISCV5 is infeasible.
Theory and Protocol Semantics
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
The peer exchange protocol, specified in this document, is a simple request-response protocol. As Figure 1 illustrates, the requesting node sends a request to a peer, which acts as the responder. The responder replies with a list of ENRs as specified in WAKU2-ENR. The multiaddresses used to connect to the respective peers can be extracted from the ENRs.
In order to protect its anonymity, the responder MUST NOT provide peers from its actively used peer list as this opens pathways to Neighbourhood Surveillance attacks, as described in the Security/Privacy Considerations Section. The responder SHOULD provide a set of peers that has been retrieved using ambient peer discovery methods supporting random sampling, e.g. 33/WAKU2-DISCV5. This both protects the responder's anonymity as well as helps distributing load.
To allow for fast responses, responders SHOULD retrieve peers unsolicited (before receiving a query) and maintain a queue of peers for the purpose of providing them in peer exchange responses. To get the best anonymity properties with respect to response peer sets, responders SHOULD use each of these peers only once.
To save bandwidth, and as a trade off to anonymity, responders MAY maintain a larger cache of exchange peers and randomly sample response sets from this local cache. The size of the cache SHOULD be large enough to allow randomly sampling peer sets that (on average) do not overlap too much. The responder SHOULD periodically replace the oldest peers in the cache. The RECOMMENDED options for the cache size are described in the Implementation Suggestions Section.
Requesters, in the context of the specified peer exchange protocol, SHOULD be resource restricted devices. While any node could technically act as a requester, using the peer exchange protocol comes with two drawbacks:
- reducing anonymity
- causing load on responder nodes
Wire Format Specification
syntax = "proto3";
message PeerInfo {
bytes enr = 1;
}
message PeerExchangeQuery {
uint64 num_peers = 1;
}
message PeerExchangeResponse {
repeated PeerInfo peer_infos = 1;
}
message PeerExchangeRPC {
PeerExchangeQuery query = 1;
PeerExchangeResponse response = 2;
}
The enr field contains a Waku ENR as specified in WAKU2-ENR.
Requesters send a PeerExchangeQuery to a peer.
Responders SHOULD include a maximum of num_peers PeerInfo instances into a response.
Responders send a PeerExchangeResponse to requesters
containing a list of PeerInfo instances, which in turn hold an ENR.
Implementation Suggestions
Discovery Interface
Implementations can implement the libp2p discovery interface:
Exchange Peer Cache Size
The size of the (optional) exchange peer cache discussed in
Theory and Protocol Semantics
depends on the average number of requested peers,
which is expected to be the outbound degree of the underlying
libp2p gossipsub
mesh network.
The RECOMMENDED value for this outbound degree is 6 (see parameter D in 29/WAKU2-CONFIG).
It is RECOMMENDED for the cache to hold at least 10 times as many peers (60).
The RECCOMENDED cache size also depends on the number of requesters a responder is expected to serve within a refresh cycle. A refresh cycle is the time interval in which all peers in the cache are expected to be replaced. If the number of requests expected per refresh cycle exceeds 600 (10 times the above recommended 60), it is RECOMMENDED to increase the cache size to at least a tenth of that number.
Security Considerations
Privacy and Anonymity
The peer exchange protocol specified in this document comes with anonymity and security implications. We differentiate these implications into the requester and responder side, respectively.
Requester
With a simple peer exchange protocol, the requester is inherently susceptible to both neighbourhood surveillance and controlled neighbourhood attacks.
To mount a neighbourhood surveillance attack, an attacker has to connect to the peers of the victim node. The peer exchange protocol allows a malicious responder to easily get into this position. The responder connects to a set of peers and simply returns this set of peers to the requester.
The peer exchange protocol also makes it much easier to get into the position required for the controlled neighbourhood attack: A malicious responder provides controlled peers in the response peer list.
More on these attacks may be found in our research log article.
As a weak mitigation the requester MAY ask several peers and select a subset of the returned peers.
Responder
Responders that answer with active mesh peers are more vulnerable to a neighbourhood surveillance attack. Responding with the set of active mesh peers allows a malicious requester to get into the required position more easily. It takes away the first hurdle of the neighbourhood surveillance attack: The attacker knows which peers to try to connect to. This increased vulnerability can be avoided by only responding with randomly sampled sets of peers, e.g. by requesting a random peer set via 33/WAKU2-DISCV5. (As stated in the Theory and Protocol Semantics Section, these peer sets SHOULD be retrieved unsolicitedly before receiving requests to achieve faster response times.)
Responders are also susceptible to amplification DoS attacks.
Requesters send a simple message request which causes responders to
engage in ambient peer discovery to retrieve a new random peer set.
As a mitigation, responders MAY feature a seen cache for requests and
only answer once per time interval.
The exchange-peer cache discussed in Theory and Protocol Semantics Section
also provides mitigation.
Still, frequent queries can tigger the refresh cycle more often.
The seen cache MAY be used in conjunction to provide additional mitigation.
Further Considerations
The response field contains ENRs as specified in WAKU2-ENR. While ENRs contain signatures, they do not violate the Waku relay no-sign policy, because they are part of the discovery domain and are not propagated in the relay domain. However, there might still be some form of leakage: ENRs could be used to track peers and facilitate linking attacks. We will investigate this further in our Waku anonymity analysis.
Copyright
Copyright and related rights waived via CC0.
References
- 33/WAKU2-DISCV5
- EIP-1459: Node Discovery via DNS
- WAKU2-ENR
- multiaddress
- libp2p discovery interface in nim
- libp2p discovery interface in javascript
- libp2p gossipsub
- 29/WAKU2-CONFIG
- Waku Relay Anonymity
- Waku relay no-sign policy
36/WAKU2-BINDINGS-API
| Field | Value |
|---|---|
| Name | Waku v2 C Bindings API |
| Slug | 36 |
| Status | draft |
| Editor | Richard Ramos [email protected] |
| Contributors | Franck Royer [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-28 —
cb56103— Update bindings-api.md - 2024-02-01 —
e9469d0— Update and rename BINDINGS-API.md to bindings-api.md - 2024-01-27 —
76e514a— Rename README.md to BINDINGS-API.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
6bd686d— Create README.md
Introduction
Native applications that wish to integrate Waku may not be able to use nwaku and its JSON RPC API due to constraints on packaging, performance or executables.
An alternative is to link existing Waku implementation as a static or dynamic library in their application.
This specification describes the C API that SHOULD be implemented by native Waku library and that SHOULD be used to consume them.
Design requirements
The API should be generic enough, so:
- it can be implemented by both nwaku and go-waku C-Bindings,
- it can be consumed from a variety of languages such as C#, Kotlin, Swift, Rust, C++, etc.
The selected format to pass data to and from the API is JSON.
It has been selected due to its widespread usage and easiness of use. Other alternatives MAY replace it in the future (C structure, protobuf) if it brings limitations that need to be lifted.
The API
General
WakuCallBack type
All the API functions require passing callbacks which will be executed depending on the result of the execution result. These callbacks are defined as
typedef void (*WakuCallBack) (const char* msg, size_t len_0);
With msg containing a \0 terminated string, and len_0 the length of this string.
The format of the data sent to these callbacks
will depend on the function being executed.
The data can be characters, numeric or json.
Status Codes
The API functions return an integer with status codes depending on the execution result. The following status codes are defined:
0- Success1- Error2- Missing callback
JsonMessage type
A Waku Message in JSON Format:
{
payload: string;
contentTopic: string;
version: number;
timestamp: number;
}
Fields:
payload: base64 encoded payload,waku_utils_base64_encodecan be used for this.contentTopic: The content topic to be set on the message.version: The Waku Message version number.timestamp: Unix timestamp in nanoseconds.
DecodedPayload type
A payload once decoded, used when a received Waku Message is encrypted:
interface DecodedPayload {
pubkey?: string;
signature?: string;
data: string;
padding: string;
}
Fields:
pubkey: Public key that signed the message (optional), hex encoded with0xprefix,signature: Message signature (optional), hex encoded with0xprefix,data: Decrypted message payload base64 encoded,padding: Padding base64 encoded.
FilterSubscription type
The criteria to create subscription to a light node in JSON Format:
{
contentFilters: ContentFilter[];
pubsubTopic: string?;
}
Fields:
contentFilters: Array ofContentFilterbeing subscribed to / unsubscribed from.topic: Optional pubsub topic.
ContentFilter type
{
contentTopic: string;
}
Fields:
contentTopic: The content topic of a Waku message.
StoreQuery type
Criteria used to retrieve historical messages
interface StoreQuery {
pubsubTopic?: string;
contentFilters?: ContentFilter[];
startTime?: number;
endTime?: number;
pagingOptions?: PagingOptions
}
Fields:
pubsubTopic: The pubsub topic on which messages are published.contentFilters: Array ofContentFilterto query for historical messages,startTime: The inclusive lower bound on the timestamp of queried messages. This field holds the Unix epoch time in nanoseconds.endTime: The inclusive upper bound on the timestamp of queried messages. This field holds the Unix epoch time in nanoseconds.pagingOptions: Paging information inPagingOptionsformat.
StoreResponse type
The response received after doing a query to a store node:
interface StoreResponse {
messages: JsonMessage[];
pagingOptions?: PagingOptions;
}
Fields:
messages: Array of retrieved historical messages inJsonMessageformat.pagingOption: Paging information inPagingOptionsformat from which to resume further historical queries
PagingOptions type
interface PagingOptions {
pageSize: number;
cursor?: Index;
forward: bool;
}
Fields:
pageSize: Number of messages to retrieve per page.cursor: Message Index from which to perform pagination. If not included and forward is set to true, paging will be performed from the beginning of the list. If not included and forward is set to false, paging will be performed from the end of the list.forward:trueif paging forward,falseif paging backward
Index type
interface Index {
digest: string;
receiverTime: number;
senderTime: number;
pubsubTopic: string;
}
Fields:
digest: Hash of the message at thisIndex.receiverTime: UNIX timestamp in nanoseconds at which the message at thisIndexwas received.senderTime: UNIX timestamp in nanoseconds at which the message is generated by its sender.pubsubTopic: The pubsub topic of the message at thisIndex.
Events
Asynchronous events require a callback to be registered.
An example of an asynchronous event that might be emitted is receiving a message.
When an event is emitted,
this callback will be triggered receiving a JSON string of type JsonSignal.
JsonSignal type
{
type: string;
event: any;
}
Fields:
type: Type of signal being emitted. Currently, onlymessageis available.event: Format depends on the type of signal.
For example:
{
"type": "message",
"event": {
"pubsubTopic": "/waku/2/default-waku/proto",
"messageId": "0x6496491e40dbe0b6c3a2198c2426b16301688a2daebc4f57ad7706115eac3ad1",
"wakuMessage": {
"payload": "TODO",
"contentTopic": "/my-app/1/notification/proto",
"version": 1,
"timestamp": 1647826358000000000
}
}
}
type | event Type |
|---|---|
message | JsonMessageEvent |
JsonMessageEvent type
Type of event field for a message event:
{
pubsubTopic: string;
messageId: string;
wakuMessage: JsonMessage;
}
pubsubTopic: The pubsub topic on which the message was received.messageId: The message id.wakuMessage: The message inJsonMessageformat.
waku_set_event_callback
extern void waku_set_event_callback(WakuCallBack cb){}
Register callback to act as event handler and receive application signals, which are used to react to asynchronous events in Waku.
Parameters
WakuCallBack cb: callback that will be executed when an async event is emitted.
Node management
JsonConfig type
Type holding a node configuration:
interface JsonConfig {
host?: string;
port?: number;
advertiseAddr?: string;
nodeKey?: string;
keepAliveInterval?: number;
relay?: boolean;
relayTopics?: Array<string>;
gossipsubParameters?: GossipSubParameters;
minPeersToPublish?: number
legacyFilter?: boolean;
discV5?: boolean;
discV5BootstrapNodes?: Array<string>;
discV5UDPPort?: number;
store?: boolean;
databaseURL?: string;
storeRetentionMaxMessages?: number;
storeRetentionTimeSeconds?: number;
websocket?: Websocket;
dns4DomainName?: string;
}
Fields:
All fields are optional.
If a key is undefined, or null, a default value will be set.
host: Listening IP address. Default0.0.0.0.port: Libp2p TCP listening port. Default60000. Use0for random.advertiseAddr: External address to advertise to other nodes. Can be ip4, ip6 or dns4, dns6. Ifnull, the multiaddress(es) generated from the ip and port specified in the config (or default ones) will be used. Default:null.nodeKey: Secp256k1 private key in Hex format (0x123...abc). Default random.keepAliveInterval: Interval in seconds for pinging peers to keep the connection alive. Default20.relay: Enable relay protocol. Defaulttrue.relayTopics: Array of pubsub topics that WakuRelay will automatically subscribe to when the node starts Default[]gossipSubParameters: custom gossipsub parameters. SeeGossipSubParameterssection for defaultsminPeersToPublish: The minimum number of peers required on a topic to allow broadcasting a message. Default0.legacyFilter: Enable Legacy Filter protocol. Defaultfalse.discV5: Enable DiscoveryV5. DefaultfalsediscV5BootstrapNodes: Array of bootstrap nodes ENRdiscV5UDPPort: UDP port for DiscoveryV5 Default9000store: Enable store protocol to persist message history DefaultfalsedatabaseURL: url connection string. Accepts SQLite and PostgreSQL connection strings Default:sqlite3://store.dbstoreRetentionMaxMessages: max number of messages to store in the database. Default10000storeRetentionTimeSeconds: max number of seconds that a message will be persisted in the database. Default2592000(30d)websocket: custom websocket support parameters. SeeWebsocketsection for defaultsdns4DomainName: the domain name resolving to the node's public IPv4 address.
For example:
{
"host": "0.0.0.0",
"port": 60000,
"advertiseAddr": "1.2.3.4",
"nodeKey": "0x123...567",
"keepAliveInterval": 20,
"relay": true,
"minPeersToPublish": 0
}
GossipsubParameters type
Type holding custom gossipsub configuration:
interface GossipSubParameters {
D?: number;
D_low?: number;
D_high?: number;
D_score?: number;
D_out?: number;
HistoryLength?: number;
HistoryGossip?: number;
D_lazy?: number;
GossipFactor?: number;
GossipRetransmission?: number;
HeartbeatInitialDelayMs?: number;
HeartbeatIntervalSeconds?: number;
SlowHeartbeatWarning?: number;
FanoutTTLSeconds?: number;
PrunePeers?: number;
PruneBackoffSeconds?: number;
UnsubscribeBackoffSeconds?: number;
Connectors?: number;
MaxPendingConnections?: number;
ConnectionTimeoutSeconds?: number;
DirectConnectTicks?: number;
DirectConnectInitialDelaySeconds?: number;
OpportunisticGraftTicks?: number;
OpportunisticGraftPeers?: number;
GraftFloodThresholdSeconds?: number;
MaxIHaveLength?: number;
MaxIHaveMessages?: number;
IWantFollowupTimeSeconds?: number;
}
Fields:
All fields are optional.
If a key is undefined, or null, a default value will be set.
d: optimal degree for a GossipSub topic mesh. Default6dLow: lower bound on the number of peers we keep in a GossipSub topic mesh Default5dHigh: upper bound on the number of peers we keep in a GossipSub topic mesh. Default12dScore: affects how peers are selected when pruning a mesh due to over subscription. Default4dOut: sets the quota for the number of outbound connections to maintain in a topic mesh. Default2historyLength: controls the size of the message cache used for gossip. Default5historyGossip: controls how many cached message ids we will advertise in IHAVE gossip messages. Default3dLazy: affects how many peers we will emit gossip to at each heartbeat. Default6gossipFactor: affects how many peers we will emit gossip to at each heartbeat. Default0.25gossipRetransmission: controls how many times we will allow a peer to request the same message id through IWANT gossip before we start ignoring them. Default3heartbeatInitialDelayMs: short delay in milliseconds before the heartbeat timer begins after the router is initialized. Default100millisecondsheartbeatIntervalSeconds: controls the time between heartbeats. Default1secondslowHeartbeatWarning: duration threshold for heartbeat processing before emitting a warning. Default0.1fanoutTTLSeconds: controls how long we keep track of the fanout state. Default60secondsprunePeers: controls the number of peers to include in prune Peer eXchange. Default16pruneBackoffSeconds: controls the backoff time for pruned peers. Default60secondsunsubscribeBackoffSeconds: controls the backoff time to use when unsuscribing from a topic. Default10secondsconnectors: number of active connection attempts for peers obtained through PX. Default8maxPendingConnections: maximum number of pending connections for peers attempted through px. Default128connectionTimeoutSeconds: timeout in seconds for connection attempts. Default30secondsdirectConnectTicks: the number of heartbeat ticks for attempting to reconnect direct peers that are not currently connected. Default300directConnectInitialDelaySeconds: initial delay before opening connections to direct peers. Default1secondopportunisticGraftTicks: number of heartbeat ticks for attempting to improve the mesh with opportunistic grafting. Default60opportunisticGraftPeers: the number of peers to opportunistically graft. Default2graftFloodThresholdSeconds: If a GRAFT comes before GraftFloodThresholdSeconds has elapsed since the last PRUNE, then there is an extra score penalty applied to the peer through P7. Default10secondsmaxIHaveLength: max number of messages to include in an IHAVE message, also controls the max number of IHAVE ids we will accept and request with IWANT from a peer within a heartbeat. Default5000maxIHaveMessages: max number of IHAVE messages to accept from a peer within a heartbeat. Default10iWantFollowupTimeSeconds: Time to wait for a message requested through IWANT following an IHAVE advertisement. Default3secondsseenMessagesTTLSeconds: configures when a previously seen message ID can be forgotten about. Default120seconds
Websocket type
Type holding custom websocket support configuration:
interface Websocket {
enabled?: bool;
host?: string;
port?: number;
secure?: bool;
certPath?: string;
keyPath?: string;
}
Fields:
All fields are optional.
If a key is undefined, or null, a default value will be set.
If using secure websockets support, certPath and
keyPath become mandatory attributes.
Unless selfsigned certificates are used,
it will probably make sense in the JsonConfiguration
to specify the domain name used in the certificate in the dns4DomainName attribute.
enabled: indicates if websockets support will be enabled Defaultfalsehost: listening address for websocket connections Default0.0.0.0port: TCP listening port for websocket connection (0for random, binding to443requires root access) Default60001, if secure websockets support is enabled, the default is6443“secure: enable secure websockets support DefaultfalsecertPath: secure websocket certificate pathkeyPath: secure websocket key path
waku_new
extern int waku_new(char* jsonConfig, WakuCallBack onErrCb){}
Instantiates a Waku node.
Parameters
char* jsonConfig: JSON string containing the options used to initialize a waku node. TypeJsonConfig. It can beNULLto use defaults.WakuCallBack onErrCb:WakuCallBack. Callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_start
extern int waku_start(WakuCallBack onErrCb){}
Starts a Waku node mounting all the protocols that were enabled during the Waku node instantiation.
Parameters
WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_stop
extern int waku_stop(WakuCallBack onErrCb){}
Stops a Waku node.
Parameters
WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_peerid
extern int waku_peerid(WakuCallBack onOkCb, WakuCallBack onErrCb){}
Get the peer ID of the waku node.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the base58 encoded peer ID, for exampleQmWjHKUrXDHPCwoWXpUZ77E8o6UbAoTTZwf1AD1tDC4KNP - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_listen_addresses
extern int waku_listen_addresses(WakuCallBack onOkCb, WakuCallBack onErrCb){}
Get the multiaddresses the Waku node is listening to.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive a json array of multiaddresses. The multiaddresses arestrings. For example:
[
"/ip4/127.0.0.1/tcp/30303",
"/ip4/1.2.3.4/tcp/30303",
"/dns4/waku.node.example/tcp/8000/wss"
]
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCbandonErrCbcallback
Connecting to peers
waku_add_peer
extern int waku_add_peer(char* address, char* protocolId, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Add a node multiaddress and protocol to the waku node's peerstore.
Parameters
char* address: A multiaddress (with peer id) to reach the peer being added.char* protocolId: A protocol we expect the peer to support.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the base 58 peer ID of the peer that was added. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_connect
extern int waku_connect(char* address, int timeoutMs, WakuCallBack onErrCb){}
Dial peer using a multiaddress.
Parameters
char* address: A multiaddress to reach the peer being dialed.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_connect_peerid
extern int waku_connect_peerid(char* peerId, int timeoutMs, WakuCallBack onErrCb){}
Dial peer using its peer ID.
Parameters
char* peerID: Peer ID to dial. The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_disconnect
extern int waku_disconnect(char* peerId, WakuCallBack onErrCb){}
Disconnect a peer using its peerID
Parameters
char* peerID: Peer ID to disconnect.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_peer_cnt
extern int waku_peer_cnt(WakuCallBack onOkCb, WakuCallBack onErrCb){}
Get number of connected peers.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the number of connected peers. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_peers
extern int waku_peers(WakuCallBack onOkCb, WakuCallBack onErrCb){}
Retrieve the list of peers known by the Waku node.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive a json array with the list of peers. This list has this format:
[
{
"peerID": "16Uiu2HAmJb2e28qLXxT5kZxVUUoJt72EMzNGXB47RedcBafeDCBA",
"protocols": [
"/ipfs/id/1.0.0",
"/vac/waku/relay/2.0.0",
"/ipfs/ping/1.0.0"
],
"addrs": [
"/ip4/1.2.3.4/tcp/30303"
],
"connected": true
}
]
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Waku Relay
waku_content_topic
extern int waku_content_topic(char* applicationName, unsigned int applicationVersion, char* contentTopicName, char* encoding, WakuCallBack onOkCb){}
Create a content topic string according to RFC 23.
Parameters
char* applicationNameunsigned int applicationVersionchar* contentTopicNamechar* encoding: depending on the payload, useproto,rlporrfc26WakuCallBack onOkCb: callback to be executed if the function is succesful
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the content topic formatted according to RFC 23:/{application-name}/{version-of-the-application}/{content-topic-name}/{encoding} - 1 - The operation failed for any reason.
- 2 - The function is missing the
onOkCbcallback
waku_pubsub_topic
extern int waku_pubsub_topic(char* name, char* encoding, WakuCallBack onOkCb){}
Create a pubsub topic string according to RFC 23.
Parameters
char* namechar* encoding: depending on the payload, useproto,rlporrfc26WakuCallBack onOkCb: callback to be executed if the function is succesful
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill get populated with a pubsub topic formatted according to RFC 23:/waku/2/{topic-name}/{encoding} - 1 - The operation failed for any reason.
- 2 - The function is missing the
onOkCbcallback
waku_default_pubsub_topic
extern int waku_default_pubsub_topic(WakuCallBack onOkCb){}
Returns the default pubsub topic used for exchanging waku messages defined in RFC 10.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesful
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill get populated with/waku/2/default-waku/proto - 1 - The operation failed for any reason.
- 2 - The function is missing the
onOkCbcallback
waku_relay_publish
extern int waku_relay_publish(char* messageJson, char* pubsubTopic, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Publish a message using Waku Relay.
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* pubsubTopic: pubsub topic on which to publish the message. IfNULL, it uses the default pubsub topic.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
If the execution is successful, the result field contains the message ID.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill get populated with the message ID - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_relay_enough_peers
extern int waku_relay_enough_peers(char* pubsubTopic, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Determine if there are enough peers to publish a message on a given pubsub topic.
Parameters
char* pubsubTopic: Pubsub topic to verify. IfNULL, it verifies the number of peers in the default pubsub topic.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive a stringbooleanindicating whether there are enough peers, i.e.trueorfalse - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_relay_subscribe
extern int waku_relay_subscribe(char* topic, WakuCallBack onErrCb){}
Subscribe to a Waku Relay pubsub topic to receive messages.
Parameters
char* topic: Pubsub topic to subscribe to. IfNULL, it subscribes to the default pubsub topic.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
Events
When a message is received,
a "message" event is emitted containing the message, pubsub topic,
and node ID in which
the message was received.
The event type is JsonMessageEvent.
For Example:
{
"type": "message",
"event": {
"pubsubTopic": "/waku/2/default-waku/proto",
"messageId": "0x6496491e40dbe0b6c3a2198c2426b16301688a2daebc4f57ad7706115eac3ad1",
"wakuMessage": {
"payload": "TODO",
"contentTopic": "/my-app/1/notification/proto",
"version": 1,
"timestamp": 1647826358000000000
}
}
}
waku_relay_unsubscribe
extern int waku_relay_unsubscribe(char* topic, WakuCallBack onErrCb)
Closes the pubsub subscription to a pubsub topic. No more messages will be received from this pubsub topic.
Parameters
char* pusubTopic: Pubsub topic to unsubscribe from. IfNULL, unsubscribes from the default pubsub topic.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
- 2 - The function is missing the
onErrCbcallback
waku_relay_topics
extern int waku_relay_topics(WakuCallBack onOkCb, WakuCallBack onErrCb)
Get the list of subscribed pubsub topics in Waku Relay.
Parameters
WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive a json array of pubsub topics i.e["pubsubTopic1", "pubsubTopic2"] - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Waku Filter
waku_filter_subscribe
extern int waku_filter_subscribe(char* filterJSON, char* peerID, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb)
Creates a subscription to a filter full node matching a content filter..
Parameters
char* filterJSON: JSON string containing theFilterSubscriptionto subscribe to.char* peerID: Peer ID to subscribe to. The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer. UseNULLto automatically select a node.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the subscription details, for example:
{
"peerID": "....",
"pubsubTopic": "...",
"contentTopics": [...]
}
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Events
When a message is received,
a "message" event is emitted containing the message, pubsub topic,
and node ID in which the message was received.
The event type is JsonMessageEvent.
For Example:
{
"type": "message",
"event": {
"pubsubTopic": "/waku/2/default-waku/proto",
"messageId": "0x6496491e40dbe0b6c3a2198c2426b16301688a2daebc4f57ad7706115eac3ad1",
"wakuMessage": {
"payload": "TODO",
"contentTopic": "/my-app/1/notification/proto",
"version": 1,
"timestamp": 1647826358000000000
}
}
}
waku_filter_ping
extern int waku_filter_ping(char* peerID, int timeoutMs, WakuCallBack onErrCb){}
Used to know if a service node has an active subscription for this client
Parameters
char* peerID: Peer ID to check for an active subscription The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
waku_filter_unsubscribe
extern int waku_filter_unsubscribe(filterJSON *C.char, char* peerID, int timeoutMs, WakuCallBack onErrCb){}
Sends a requests to a service node to stop pushing messages matching this filter to this client. It might be used to modify an existing subscription by providing a subset of the original filter criteria
Parameters
char* filterJSON: JSON string containing theFilterSubscriptioncriteria to unsubscribe fromchar* peerID: Peer ID to unsubscribe from The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_filter_unsubscribe_all
extern int waku_filter_unsubscribe_all(char* peerID, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Sends a requests to a service node (or all service nodes) to stop pushing messages
Parameters
char* peerID: Peer ID to unsubscribe from The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer. UseNULLto unsubscribe from all peers with active subscriptionsint timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive an array with information about the state of each unsubscription attempt (one per peer)
[
{
"peerID": ....,
"error": "" // Empty if succesful
},
...
]
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Waku Legacy Filter
waku_legacy_filter_subscribe
extern int waku_legacy_filter_subscribe(char* filterJSON, char* peerID, int timeoutMs, WakuCallBack onErrCb){}
Creates a subscription in a lightnode for messages that matches a content filter
and optionally a PubSub topic.
Parameters
char* filterJSON: JSON string containing theLegacyFilterSubscriptionto subscribe to.char* peerID: Peer ID to subscribe to. The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer. UseNULLto automatically select a node.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
Events
When a message is received,
a "message" event is emitted containing the message, pubsub topic,
and node ID in which the message was received.
The event type is JsonMessageEvent.
For Example:
{
"type": "message",
"event": {
"pubsubTopic": "/waku/2/default-waku/proto",
"messageId": "0x6496491e40dbe0b6c3a2198c2426b16301688a2daebc4f57ad7706115eac3ad1",
"wakuMessage": {
"payload": "TODO",
"contentTopic": "/my-app/1/notification/proto",
"version": 1,
"timestamp": 1647826358000000000
}
}
}
waku_legacy_filter_unsubscribe
extern int waku_legacy_filter_unsubscribe(char* filterJSON, int timeoutMs, WakuCallBack onErrCb){}
Removes subscriptions in a light node matching a content filter and,
optionally, a PubSub topic.
Parameters
char* filterJSON: JSON string containing theLegacyFilterSubscription.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
Waku Lightpush
waku_lightpush_publish
extern int waku_lightpush_publish(char* messageJSON, char* topic, char* peerID, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Publish a message using Waku Lightpush.
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* pubsubTopic: pubsub topic on which to publish the message. IfNULL, it uses the default pubsub topic.char* peerID: Peer ID supporting the lightpush protocol. The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer. UseNULLto automatically select a node.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Note: messageJson.version is overwritten to 0.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the message ID - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Waku Store
waku_store_query
extern int waku_store_query(char* queryJSON, char* peerID, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Retrieves historical messages on specific content topics.
This method may be called with PagingOptions,
to retrieve historical messages on a per-page basis.
If the request included PagingOptions, the node
must return messages on a per-page basis and
include PagingOptions in the response.
These PagingOptions
must contain a cursor pointing to the Index from which a new page can be requested.
Parameters
char* queryJSON: JSON string containing theStoreQuery.char* peerID: Peer ID supporting the store protocol. The peer must be already known. It must have been added before withwaku_add_peeror previously dialed withwaku_connect_peer.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive aStoreResponse. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_store_local_query
extern int waku_store_local_query(char* queryJSON, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Retrieves locally stored historical messages on specific content topics.
This method may be called with PagingOptions,
to retrieve historical messages on a per-page basis.
If the request included PagingOptions, the node
must return messages on a per-page basis and
include PagingOptions in the response.
These PagingOptions
must contain a cursor pointing to the Index from which a new page can be requested.
Parameters
char* queryJSON: JSON string containing theStoreQuery.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive aStoreResponse. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Encrypting messages
waku_encode_symmetric
extern int waku_encode_symmetric(char* messageJson, char* symmetricKey, char* optionalSigningKey, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Encrypt a message using symmetric encryption and optionally sign the message
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* symmetricKey: hex encoded secret key to be used for encryption.char* optionalSigningKey: hex encoded private key to be used to sign the message.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Note: messageJson.version is overwritten to 1.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the encrypted waku message which can be broadcasted with relay or lightpush protocol publish functions. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_encode_asymmetric
extern int waku_encode_asymmetric(char* messageJson, char* publicKey, char* optionalSigningKey, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Encrypt a message using asymmetric encryption and optionally sign the message
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* publicKey: hex encoded public key to be used for encryption.char* optionalSigningKey: hex encoded private key to be used to sign the message.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Note: messageJson.version is overwritten to 1.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the encrypted waku message which can be broadcasted with relay or lightpush protocol publish functions. - 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
Decrypting messages
waku_decode_symmetric
extern int waku_decode_symmetric(char* messageJson, char* symmetricKey, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Decrypt a message using a symmetric key
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* symmetricKey: 32 byte symmetric key hex encoded.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Note: messageJson.version is expected to be 1.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the decoded payload as aDecodedPayload.
{
"pubkey": "0x......",
"signature": "0x....",
"data": "...",
"padding": "..."
}
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
waku_decode_asymmetric
extern int waku_decode_asymmetric(char* messageJson, char* privateKey, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Decrypt a message using a secp256k1 private key
Parameters
char* messageJson: JSON string containing the Waku Message asJsonMessage.char* privateKey: secp256k1 private key hex encoded.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Note: messageJson.version is expected to be 1.
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive the decoded payload as aDecodedPayload.
{
"pubkey": "0x......",
"signature": "0x....",
"data": "...",
"padding": "..."
}
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
DNS Discovery
waku_dns_discovery
extern int waku_dns_discovery(char* url, char* nameserver, int timeoutMs, WakuCallBack onOkCb, WakuCallBack onErrCb){}
Returns a list of multiaddress and enrs given a url to a DNS discoverable ENR tree
Parameters
char* url: URL containing a discoverable ENR treechar* nameserver: The nameserver to resolve the ENR tree url. IfNULLor empty, it will automatically use the default system dns.int timeoutMs: Timeout value in milliseconds to execute the call. If the function execution takes longer than this value, the execution will be canceled and an error returned. Use0for no timeout.WakuCallBack onOkCb: callback to be executed if the function is succesfulWakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
onOkCbwill receive an array objects describing the multiaddresses, enr and peerID each node found.
[
{
"peerID":"16Uiu2HAmPLe7Mzm8TsYUubgCAW1aJoeFScxrLj8ppHFivPo97bUZ",
"multiaddrs":[
"/ip4/134.209.139.210/tcp/30303/p2p/16Uiu2HAmPLe7Mzm8TsYUubgCAW1aJoeFScxrLj8ppHFivPo97bUZ",
"/dns4/node-01.do-ams3.wakuv2.test.statusim.net/tcp/8000/wss/p2p/16Uiu2HAmPLe7Mzm8TsYUubgCAW1aJoeFScxrLj8ppHFivPo97bUZ"
],
"enr":"enr:-M-4QCtJKX2WDloRYDT4yjeMGKUCRRcMlsNiZP3cnPO0HZn6IdJ035RPCqsQ5NvTyjqHzKnTM6pc2LoKliV4CeV0WrgBgmlkgnY0gmlwhIbRi9KKbXVsdGlhZGRyc7EALzYobm9kZS0wMS5kby1hbXMzLndha3V2Mi50ZXN0LnN0YXR1c2ltLm5ldAYfQN4DiXNlY3AyNTZrMaEDnr03Tuo77930a7sYLikftxnuG3BbC3gCFhA4632ooDaDdGNwgnZfg3VkcIIjKIV3YWt1Mg8"
},
...
]
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onOkCboronErrCbcallback
DiscoveryV5
waku_discv5_update_bootnodes
extern int waku_discv5_update_bootnodes(char* bootnodes, WakuCallBack onErrCb)`
Update the bootnode list used for discovering new peers via DiscoveryV5
Parameters
char* bootnodes: JSON array containing the bootnode ENRs i.e.["enr:...", "enr:..."]WakuCallBack onErrCb: callback to be executed if the function fails
Returns
int with a status code. Possible values:
- 0 - The operation was completed successfuly.
- 1 - The operation failed for any reason.
onErrCbwill be executed with the reason the function execution failed. - 2 - The function is missing the
onErrCbcallback
Copyright
Copyright and related rights waived via CC0.
64/WAKU2-NETWORK
| Field | Value |
|---|---|
| Name | Waku v2 Network |
| Slug | 64 |
| Status | draft |
| Category | Best Current Practice |
| Editor | Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-02-25 —
0277fd0— docs: update dead links in 64/Network (#133) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-07-09 —
77029a2— Add RLNv2 to TheWakuNetwork (#82) - 2024-05-10 —
e5b859a— Update WAKU2-NETWORK: Move to draft (#5)
Abstract
This specification describes an opinionated deployment of 10/WAKU2 protocols to form a coherent and shared decentralized messaging network that is open-access, useful for generalized messaging, privacy-preserving, scalable and accessible even to resource-restricted devices. We'll refer to this opinionated deployment simply as the public Waku Network, the Waku Network or, if the context is clear, the network in the rest of this document. All The Waku Network configuration parameters are listed here.
Theory / Semantics
Routing protocol
The Waku Network is built on the 17/WAKU2-RLN-RELAY routing protocol, which in turn is an extension of 11/WAKU2-RELAY with spam protection measures.
Network shards
Traffic in the Waku Network is sharded into eight 17/WAKU2-RLN-RELAY pubsub topics. Each pubsub topic is named according to the static shard naming format defined in WAKU2-RELAY-SHARDING with:
<cluster_id>set to1<shard_number>occupying the range0to7. In other words, the Waku Network is a 17/WAKU2-RLN-RELAY network routed on the combination of the eight pubsub topics:
/waku/2/rs/1/0
/waku/2/rs/1/1
...
/waku/2/rs/1/7
A node MUST use 66/WAKU2-METADATA
protocol to identify the <cluster_id> that every
inbound/outbound peer that attempts to connect supports.
In any of the following cases, the node MUST trigger a disconnection:
- 66/WAKU2-METADATA dial fails.
- 66/WAKU2-METADATA
reports an empty
<cluster_id>. - 66/WAKU2-METADATA
reports a
<cluster_id>different than1.
Roles
There are two distinct roles evident in the network, those of:
- nodes, and
- applications.
Nodes
Nodes are the individual software units using 10/WAKU2 protocols to form a p2p messaging network. Nodes, in turn, can participate in a shard as full relayers, i.e. relay nodes, or by running a combination of protocols suitable for resource-restricted environments, i.e. non-relay nodes. Nodes can also provide various services to the network, such as storing historical messages or protecting the network against spam. See the section on default services for more.
Relay nodes
Relay nodes MUST follow 17/WAKU2-RLN-RELAY to route messages to other nodes in the network for any of the pubsub topics defined as the Waku Network shards. Relay nodes MAY choose to subscribe to any of these shards, but MUST be subscribed to at least one defined shard. Each relay node SHOULD be subscribed to as many shards as it has resources to support. If a relay node supports an encapsulating application, it SHOULD be subscribed to all the shards servicing that application. If resource restrictions prevent a relay node from servicing all shards used by the encapsulating application, it MAY choose to support some shards as a non-relay node.
Bootstrapping and discovery
Nodes MAY use any method to bootstrap connection to the network, but it is RECOMMENDED that each node retrieves a list of bootstrap peers to connect to using EIP-1459 DNS-based discovery. Relay nodes SHOULD use 33/WAKU2-DISCV5 to continually discover other peers in the network. Each relay node MUST encode its supported shards into its discoverable ENR, as described in WAKU2-RELAY-SHARDING. The ENR MUST be updated if the set of supported shards change. A node MAY choose to ignore discovered peers that do not support any of the shards in its own subscribed set.
Transports
Relay nodes MUST follow 10/WAKU2 specifications with regards to supporting different transports. If TCP transport is available, each relay node MUST support it as transport for both dialing and listening. In addition, a relay node SHOULD support secure websockets for bidirectional communication streams, for example to allow connections from and to web browser-based clients. A relay node MAY support unsecure websockets if required by the application or running environment.
Default services
For each supported shard, each relay node SHOULD enable and support the following protocols as a service node:
- 12/WAKU2-FILTER to allow resource-restricted peers to subscribe to messages matching a specific content filter.
- 13/WAKU2-STORE to allow other peers to request historical messages from this node.
- 19/WAKU2-LIGHTPUSH to allow resource-restricted peers to request publishing a message to the network on their behalf.
- WAKU2-PEER-EXCHANGE to allow resource-restricted peers to discover more peers in a resource efficient way.
Store service nodes
Each relay node SHOULD support 13/WAKU2-STORE
as a store service node, for each supported shard.
The store SHOULD be configured to retain at least 12 hours of messages
per supported shard.
Store service nodes SHOULD only store messages
with a valid rate_limit_proof attribute.
Non-relay nodes
Nodes MAY opt out of relay functionality on any network shard and instead request services from relay nodes as clients using any of the defined service protocols:
- 12/WAKU2-FILTER to subscribe to messages matching a specific content filter.
- 13/WAKU2-STORE to request historical messages matching a specific content filter.
- 19/WAKU2-LIGHTPUSH to request publishing a message to the network.
- WAKU2-PEER-EXCHANGE to discover more peers in a resource efficient way.
Store client nodes
Nodes MAY request historical messages from 13/WAKU2-STORE
service nodes as store clients.
A store client SHOULD discard any messages retrieved from a store service node
that do not contain a valid rate_limit_proof attribute.
The client MAY consider service nodes returning messages
without a valid rate_limit_proof attribute as untrustworthy.
The mechanism by which this may happen is currently underdefined.
Applications
Applications are the higher-layer projects or platforms that make use of the generalized messaging capability of the network. In other words, an application defines a payload used in the various 10/WAKU2 protocols. Any participant in an application SHOULD make use of an underlying node in order to communicate on the network. Applications SHOULD make use of an autosharding API to allow the underlying node to automatically select the target shard on the Waku Network. See the section on autosharding for more.
RLN rate-limiting
The 17/WAKU2-RLN-RELAY protocol uses RLN-V2
proofs to ensure that a pre-agreed rate limit
of x messages every y seconds is not exceeded by any publisher.
While the network is under capacity,
individual relayers MAY choose to freely route messages without RLN proofs
up to a discretionary bandwidth limit,
after which messages without proofs MUST be discarded by relay nodes.
This bandwidth limit SHOULD be enforced using a bandwidth validation mechanism
separate from a RLN rate-limiting.
This implies that quality of service and
reliability is significantly lower for messages without proofs
and at times of high network utilization these messages may not be relayed at all.
RLN Parameters
The Waku Network uses the following RLN parameters:
rlnRelayUserMessageLimit=100: Amount of messages that a membership is allowed to publish per epoch. Configurable between0andMAX_MESSAGE_LIMIT.rlnEpochSizeSec=600: Size of the epoch in seconds.rlnRelayChainId=11155111: Network in which the RLN contract is deployed, aka Sepolia.rlnRelayEthContractAddress=0xCB33Aa5B38d79E3D9Fa8B10afF38AA201399a7e3: Network address where RLN memberships are stored.staked_fund=0: In other words, the Waku Network does not use RLN staking. Registering a membership just requires to pay gas.MAX_MESSAGE_LIMIT=100: Maximum amount of messages allowed per epoch for any membership. Enforced in the contract.max_epoch_gap=20: Maximum allowed gap in seconds into the past or future compared to the validator's clock.
Nodes MUST reject messages not respecting any of these parameters. Nodes SHOULD use Network Time Protocol (NTP) to synchronize their own clocks, thereby ensuring valid timestamps for proof generation and validation. Publishers to the Waku Network SHOULD register an RLN membership.
RLN Proofs
Each RLN member MUST generate and attach an RLN proof to every published message as described in 17/WAKU2-RLN-RELAY and RLN-V2. Slashing is not implemented for the Waku Network. Instead, validators will penalise peers forwarding messages exceeding the rate limit as specified for the rate-limiting validation mechanism. This incentivizes all relay nodes to validate RLN proofs and reject messages violating rate limits in order to continue participating in the network.
Network traffic
All payload on the Waku Network MUST be encapsulated in a 14/WAKU2-MESSAGE with rate limit proof extensions defined for 17/WAKU2-RLN-RELAY. Each message on the Waku Network SHOULD be validated by each relayer, according to the rules discussed under message validation.
Message Attributes
- The mandatory
payloadattribute MUST contain the message data payload as crafted by the application. - The mandatory
content_topicattribute MUST specify a string identifier that can be used for content-based filtering. This is also crafted by the application. See Autosharding for more on the content topic format. - The optional
metaattribute MAY be omitted. If present, will form part of the message uniqueness vector described in 14/WAKU2-MESSAGE. - The optional
versionattribute SHOULD be set to0. It MUST be interpreted as0if not present. - The mandatory
timestampattribute MUST contain the Unix epoch time at which the message was generated by the application. The value MUST be in nanoseconds. It MAY contain a fudge factor of up to 1 seconds in either direction to improve resistance to timing attacks. - The optional
ephemeralattribute MUST be set totrue, if the message should not be persisted by the Waku Network. - The optional
rate_limit_proofattribute SHOULD be populated with the RLN proof as set out in RLN Proofs. Messages with this field unpopulated MAY be discarded from the network by relayers. This field MUST be populated if the message should be persisted by the Waku Network.
Message Size
Any 14/WAKU2-MESSAGE published to the network
MUST NOT exceed an absolute maximum size of 150 kilobytes.
This limit applies to the entire message after protobuf serialization,
including attributes.
It is RECOMMENDED not to exceed an average size of 4 kilobytes
for 14/WAKU2-MESSAGE published to the network.
Message Validation
Relay nodes MUST apply gossipsub v1.1 validation to each relayed message and SHOULD apply all of the rules set out in the section below to determine the validity of a message. Validation has one of three outcomes, repeated here from the gossipsub specification for ease of reference:
- Accept - the message is considered valid and it MUST be delivered and forwarded to the network.
- Reject - the message is considered invalid, MUST be rejected and SHOULD trigger a gossipsub scoring penalty against the transmitting peer.
- Ignore - the message SHOULD NOT be delivered and forwarded to the network, but this MUST NOT trigger a gossipsub scoring penalty against the transmitting peer.
The following validation rules are defined:
Decoding failure
If a message fails to decode as a valid 14/WAKU2-MESSAGE, the relay node MUST reject the message. This SHOULD trigger a penalty against the transmitting peer.
Invalid timestamp
If a message has a timestamp deviating by more than 20 seconds
either into the past or the future
when compared to the relay node's internal clock,
the relay node MUST reject the message.
This allows for some deviation between internal clocks,
network routing latency and
an optional fudge factor when timestamping new messages.
Free bandwidth exceeded
If a message contains no RLN proof
and the current bandwidth utilization on the shard the message was published to
equals or exceeds 1 Mbps,
the relay node SHOULD ignore the message.
Invalid RLN epoch
If a message contains an RLN proof
and the epoch attached to the proof deviates by more than max_epoch_gap seconds
from the relay node's own epoch,
the relay node MUST reject the message.
max_epoch_gap is set to 20 seconds for the Waku Network.
Invalid RLN proof
If a message contains an RLN proof and the zero-knowledge proof is invalid according to the verification process described in RLN-V2, the relay node MUST ignore the message.
Rate limit exceeded
If a message contains an RLN proof
and the relay node detects double signaling
according to the verification process described in RLN-V2,
the relay node MUST reject the message
for violating the agreed rate limit of rlnRelayUserMessageLimit messages
every rlnEpochSizeSec second.
This SHOULD trigger a penalty against the transmitting peer.
Autosharding
Nodes in the Waku Network SHOULD allow encapsulating applications to use autosharding, as defined in WAKU2-RELAY-SHARDING by automatically determining the appropriate pubsub topic from the list of defined Waku Network shards. This allows the application to omit the target pubsub topic when invoking any Waku protocol function. Applications using autosharding MUST use content topics in the format defined in WAKU2-RELAY-SHARDING and SHOULD use the short length format:
/{application-name}/{version-of-the-application}/{content-topic-name}/{encoding}
When an encapsulating application makes use of autosharding the underlying node MUST determine the target pubsub topic(s) from the content topics provided by the application using the hashing mechanism defined in WAKU2-RELAY-SHARDING.
Copyright
Copyright and related rights waived via CC0.
References
- 10/WAKU2
- 17/WAKU2-RLN-RELAY
- 11/WAKU2-RELAY
- WAKU2-RELAY-SHARDING
- 66/WAKU2-METADATA
- EIP-1459 DNS-based discovery
- 33/WAKU2-DISCV5
- 12/WAKU2-FILTER
- 13/WAKU2-STORE
- 19/WAKU2-LIGHTPUSH
- 34/WAKU2-PEER-EXCHANGE
- 32/RLN-V1
- RLN-V2
- 14/WAKU2-MESSAGE
- gossipsub v1.1 validation
- WAKU2-RELAY-SHARDING
- The Waku Network Config
66/WAKU2-METADATA
| Field | Value |
|---|---|
| Name | Waku Metadata Protocol |
| Slug | 66 |
| Status | draft |
| Editor | Franck Royer [email protected] |
| Contributors | Filip Dimitrijevic [email protected], Alvaro Revuelta [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-07-31 —
4361e29— Add implementation recommendation for metadata (#168) - 2025-05-13 —
f829b12— waku/standards/core/66/metadata.md update (#148) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-04-17 —
d82eacc— Update WAKU2-METADATA: Move to draft (#6)
Abstract
This specification describes the metadata that can be associated with a 10/WAKU2 node.
Metadata Protocol
The keywords “MUST”, // List style “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Waku specifies a req/resp protocol that provides information about the node's capabilities. Such metadata MAY be used by other peers for subsequent actions such as light protocol requests or disconnection.
The node that makes the request, includes its metadata so that the receiver is aware of it, without requiring another round trip. The parameters are the following:
clusterId: Unique identifier of the cluster that the node is running in.shards: Shard indexes that the node is subscribed to via11/WAKU2-RELAY.
Protocol Identifier
/vac/waku/metadata/1.0.0
Request
message WakuMetadataRequest {
optional uint32 cluster_id = 1;
repeated uint32 shards = 2;
}
Response
message WakuMetadataResponse {
optional uint32 cluster_id = 1;
repeated uint32 shards = 2;
}
Implementation Suggestions
Triggering Metadata Request
A node SHOULD proceed with metadata request upon first connection to a remote node. A node SHOULD use the remote node's libp2p peer id as identifier for this heuristic.
A node MAY proceed with metadata request upon reconnection to a remote peer.
A node SHOULD store the remote peer's metadata information for future reference. A node MAY implement a TTL regarding a remote peer's metadata, and refresh it upon expiry by initiating another metadata request. It is RECOMMENDED to set the TTL to 6 hours.
A node MAY trigger a metadata request after receiving an error response from a remote note
stating they do not support a specific cluster or shard.
For example, when using a request-response service such as 19/WAKU2-LIGHTPUSH.
Providing Cluster Id
A node MUST include their cluster id into their metadata payload. It is RECOMMENDED for a node to operate on a single cluster id.
Providing Shard Information
- Nodes that mount
11/WAKU2-RELAYMAY include the shards they are subscribed to in their metadata payload. - Shard-relevant services are message related services,
such as
13/WAKU2-STORE, 12/WAKU2-FILTER and19/WAKU2-LIGHTPUSHbut not34/WAKU2-PEER-EXCHANGE - Nodes that mount
11/WAKU2-RELAYand a shard-relevant service SHOULD include the shards they are subscribed to in their metadata payload. - Nodes that do not mount
11/WAKU2-RELAYSHOULD NOT include any shard information
Using Cluster Id
When reading the cluster id of a remote peer, the local node MAY disconnect if their cluster id is different from the remote peer.
Using Shard Information
It is NOT RECOMMENDED to disconnect from a peer based on the fact that their shard information is different from the local node.
Ahead of doing a shard-relevant request, a node MAY use the previously received metadata shard information to select a peer that support the targeted shard.
For non-shard-relevant requests, a node SHOULD NOT discriminate a peer based on medata shard information.
Copyright
Copyright and related rights waived via CC0.
References
Waku Standards - Application
Application-layer specifications built on top of Waku core protocols.
20/TOY-ETH-PM
| Field | Value |
|---|---|
| Name | Toy Ethereum Private Message |
| Slug | 20 |
| Status | draft |
| Editor | Franck Royer [email protected] |
Timeline
- 2026-01-30 —
d5a9240— chore: removed archived (#283) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-09 —
3b152e4— 20/TOY-ETH-PM: Update (#141) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-01-31 —
89a94a5— Update toy-eth-pm.md - 2024-01-30 —
c4ff509— Create toy-eth-pm.md - 2024-01-30 —
8841f49— Update toy-eth-pm.md - 2024-01-29 —
a16a2b4— Create toy-eth-pm.md
Content Topics:
- Public Key Broadcast:
/eth-pm/1/public-key/proto - Private Message:
/eth-pm/1/private-message/proto
Abstract
This specification explains the Toy Ethereum Private Message protocol which enables a peer to send an encrypted message to another peer over the Waku network using the peer's Ethereum address.
Goal
Alice wants to send an encrypted message to Bob, where only Bob can decrypt the message. Alice only knows Bob's Ethereum Address.
The goal of this specification is to demonstrate how Waku can be used for encrypted messaging purposes, using Ethereum accounts for identity. This protocol caters to Web3 wallet restrictions, allowing it to be implemented using standard Web3 API. In the current state, Toy Ethereum Private Message, ETH-PM, has privacy and features limitations, has not been audited and hence is not fit for production usage. We hope this can be an inspiration for developers wishing to build on top of Waku.
Design Requirements
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Variables
Here are the variables used in the protocol and their definition:
Bis Bob's Ethereum address (or account),bis the private key ofB, and is only known by Bob.B'is Bob's Encryption Public Key, for whichb'is the private key.Mis the private message that Alice sends to Bob.
The proposed protocol MUST adhere to the following design requirements:
- Alice knows Bob's Ethereum address
- Bob is willing to participate to Eth-PM, and publishes
B' - Bob's ownership of
B'MUST be verifiable - Alice wants to send message
Mto Bob - Bob SHOULD be able to get
Musing 10/WAKU2 - Participants only have access to their Ethereum Wallet via the Web3 API
- Carole MUST NOT be able to read
M's content, even if she is storing it or relaying it - Waku Message Version 1 Asymmetric Encryption is used for encryption purposes.
Limitations
Alice's details are not included in the message's structure, meaning that there is no programmatic way for Bob to reply to Alice or verify her identity.
Private messages are sent on the same content topic for all users. As the recipient data is encrypted, all participants must decrypt all messages which can lead to scalability issues.
This protocol does not guarantee Perfect Forward Secrecy nor Future Secrecy: If Bob's private key is compromised, past and future messages could be decrypted. A solution combining regular X3DH bundle broadcast with Double Ratchet encryption would remove these limitations; See the Status secure transport specification for an example of a protocol that achieves this in a peer-to-peer setting.
Bob MUST decide to participate in the protocol before Alice can send him a message. This is discussed in more detail in Consideration for a non-interactive/uncoordinated protocol
The Protocol
Generate Encryption KeyPair
First, Bob needs to generate a keypair for Encryption purposes.
Bob SHOULD get 32 bytes from a secure random source as Encryption Private Key, b'.
Then Bob can compute the corresponding SECP-256k1 Public Key, B'.
Broadcast Encryption Public Key
For Alice to encrypt messages for Bob,
Bob SHOULD broadcast his Encryption Public Key B'.
To prove that the Encryption Public Key B'
is indeed owned by the owner of Bob's Ethereum Account B,
Bob MUST sign B' using B.
Sign Encryption Public Key
To prove ownership of the Encryption Public Key,
Bob must sign it using EIP-712 v3,
meaning calling eth_signTypedData_v3 on his wallet's API.
Note: While v4 also exists, it is not available on all wallets and the features brought by v4 is not needed for the current use case.
The TypedData to be passed to eth_signTypedData_v3 MUST be as follows, where:
encryptionPublicKeyis Bob's Encryption Public Key,B', in hex format, without0xprefix.bobAddressis Bob's Ethereum address, corresponding toB, in hex format, with0xprefix.
const typedData = {
domain: {
chainId: 1,
name: 'Ethereum Private Message over Waku',
version: '1',
},
message: {
encryptionPublicKey: encryptionPublicKey,
ownerAddress: bobAddress,
},
primaryType: 'PublishEncryptionPublicKey',
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
],
PublishEncryptionPublicKey: [
{ name: 'encryptionPublicKey', type: 'string' },
{ name: 'ownerAddress', type: 'string' },
],
},
}
Public Key Message
The resulting signature is then included in a PublicKeyMessage, where
encryption_public_keyis Bob's Encryption Public KeyB', not compressed,eth_addressis Bob's Ethereum AddressB,signatureis the EIP-712 as described above.
syntax = "proto3";
message PublicKeyMessage {
bytes encryption_public_key = 1;
bytes eth_address = 2;
bytes signature = 3;
}
This MUST be wrapped in a 14/WAKU-MESSAGE version 0, with the Public Key Broadcast content topic. Finally, Bob SHOULD publish the message on Waku.
Consideration for a non-interactive/uncoordinated protocol
Alice has to get Bob's public Key to send a message to Bob. Because an Ethereum Address is part of the hash of the public key's account, it is not enough in itself to deduce Bob's Public Key.
This is why the protocol dictates that Bob MUST send his Public Key first, and Alice MUST receive it before she can send him a message.
Moreover, nwaku, the reference implementation of 13/WAKU2-STORE, stores messages for a maximum period of 30 days. This means that Bob would need to broadcast his public key at least every 30 days to be reachable.
Below we are reviewing possible solutions to mitigate this "sign up" step.
Retrieve the public key from the blockchain
If Bob has signed at least one transaction with his account then his Public Key can be extracted from the transaction's ECDSA signature. The challenge with this method is that standard Web3 Wallet API does not allow Alice to specifically retrieve all/any transaction sent by Bob.
Alice would instead need to use the eth.getBlock API
to retrieve Ethereum blocks one by one.
For each block, she would need to check the from value of each transaction
until she finds a transaction sent by Bob.
This process is resource intensive and can be slow when using services such as Infura due to rate limits in place, which makes it inappropriate for a browser or mobile phone environment.
An alternative would be to either run a backend that can connect directly to an Ethereum node, use a centralized blockchain explorer or use a decentralized indexing service such as The Graph.
Note that these would resolve a UX issue only if a sender wants to proceed with air drops.
Indeed, if Bob does not publish his Public Key in the first place then it MAY be an indication that he does not participate in this protocol and hence will not receive messages.
However, these solutions would be helpful if the sender wants to proceed with an air drop of messages: Send messages over Waku for users to retrieve later, once they decide to participate in this protocol. Bob may not want to participate first but may decide to participate at a later stage and would like to access previous messages. This could make sense in an NFT offer scenario: Users send offers to any NFT owner, NFT owner may decide at some point to participate in the protocol and retrieve previous offers.
Publishing the public in long term storage
Another improvement would be for Bob not having to re-publish his public key every 30 days or less. Similarly to above, if Bob stops publishing his public key then it MAY be an indication that he does not participate in the protocol anymore.
In any case, the protocol could be modified to store the Public Key in a more permanent storage, such as a dedicated smart contract on the blockchain.
Send Private Message
Alice MAY monitor the Waku network to collect Ethereum Address and
Encryption Public Key tuples.
Alice SHOULD verify that the signatures of PublicKeyMessages she receives
are valid as per EIP-712.
She SHOULD drop any message without a signature or with an invalid signature.
Using Bob's Encryption Public Key, retrieved via 10/WAKU2, Alice MAY now send an encrypted message to Bob.
If she wishes to do so,
Alice MUST encrypt her message M using Bob's Encryption Public Key B',
as per 26/WAKU-PAYLOAD Asymmetric Encryption specs.
Alice SHOULD now publish this message on the Private Message content topic.
Copyright
Copyright and related rights waived via CC0.
References
- 10/WAKU2
- Waku Message Version 1
- X3DH
- Double Ratchet
- Status secure transport specification
- EIP-712
- 13/WAKU2-STORE
- The Graph
26/WAKU2-PAYLOAD
| Field | Value |
|---|---|
| Name | Waku Message Payload Encryption |
| Slug | 26 |
| Status | draft |
| Editor | Oskar Thoren [email protected] |
| Contributors | Oskar Thoren [email protected] |
Timeline
- 2026-01-30 —
d5a9240— chore: removed archived (#283) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-03-31 —
f08de10— 26/WAKU2-PAYLOADS: Update (#136) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-01-31 —
33cf551— Update payload.md - 2024-01-31 —
29acb80— Rename README.md to payload.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
7bd0712— Create README.md
Abstract
This specification describes how Waku provides confidentiality, authenticity, and integrity, as well as some form of unlinkability. Specifically, it describes how encryption, decryption and signing works in 6/WAKU1 and in 10/WAKU2 with 14/WAKU-MESSAGE.
This specification effectively replaces 7/WAKU-DATA as well as 6/WAKU1 Payload encryption but written in a way that is agnostic and self-contained for 6/WAKU1 and 10/WAKU2.
Large sections of the specification originate from EIP-627: Whisper spec as well from RLPx Transport Protocol spec (ECIES encryption) with some modifications.
Specification
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
For 6/WAKU1,
the data field is used in the waku envelope
and the field MAY contain the encrypted payload.
For 10/WAKU2,
the payload field is used in WakuMessage
and MAY contain the encrypted payload.
The fields that are concatenated and
encrypted as part of the data (Waku legacy) or
payload (Waku) field are:
flagspayload-lengthpayloadpaddingsignature
Design requirements
- Confidentiality: The adversary SHOULD NOT be able to learn what data is being sent from one Waku node to one or several other Waku nodes.
- Authenticity: The adversary SHOULD NOT be able to cause Waku endpoint to accept data from any third party as though it came from the other endpoint.
- Integrity: The adversary SHOULD NOT be able to cause a Waku endpoint to accept data that has been tampered with.
Notable, forward secrecy is not provided for at this layer. If this property is desired, a more fully featured secure communication protocol can be used on top.
It also provides some form of unlinkability since:
- only participants who are able to decrypt a message can see its signature
- payload are padded to a fixed length
Cryptographic primitives
- AES-256-GCM (for symmetric encryption)
- ECIES
- ECDSA
- KECCAK-256
ECIES is using the following cryptosystem:
- Curve: secp256k1
- KDF: NIST SP 800-56 Concatenation Key Derivation Function, with SHA-256 option
- MAC: HMAC with SHA-256
- AES: AES-128-CTR
ABNF
Using Augmented Backus-Naur form (ABNF) we have the following format:
; 1 byte; first two bits contain the size of payload-length field,
; third bit indicates whether the signature is present.
flags = 1OCTET
; contains the size of payload.
payload-length = 4*OCTET
; byte array of arbitrary size (may be zero).
payload = *OCTET
; byte array of arbitrary size (may be zero).
padding = *OCTET
; 65 bytes, if present.
signature = 65OCTET
data = flags payload-length payload padding [signature]
; This field is called payload in Waku
payload = data
Signature
Those unable to decrypt the payload/data are also unable to access the signature.
The signature, if provided,
SHOULD be the ECDSA signature of the Keccak-256 hash of the unencrypted data
using the secret key of the originator identity.
The signature is serialized as the concatenation of the r, s and v parameters
of the SECP-256k1 ECDSA signature, in that order.
r and s MUST be big-endian encoded, fixed-width 256-bit unsigned.
v MUST be an 8-bit big-endian encoded,
non-normalized and should be either 27 or 28.
See Ethereum "Yellow paper": Appendix F Signing transactions for more information on signature generation, parameters and public key recovery.
Encryption
Symmetric
Symmetric encryption uses AES-256-GCM for
authenticated encryption.
The output of encryption is of the form (ciphertext, tag, iv)
where ciphertext is the encrypted message,
tag is a 16 byte message authentication tag and
iv is a 12 byte initialization vector (nonce).
The message authentication tag and
initialization vector iv field MUST be appended to the resulting ciphertext,
in that order.
Note that previous specifications and
some implementations might refer to iv as nonce or salt.
Asymmetric
Asymmetric encryption uses the standard Elliptic Curve Integrated Encryption Scheme (ECIES) with SECP-256k1 public key.
ECIES
This section originates from the RLPx Transport Protocol spec specification with minor modifications.
The cryptosystem used is:
- The elliptic curve secp256k1 with generator
G. KDF(k, len): the NIST SP 800-56 Concatenation Key Derivation Function.MAC(k, m): HMAC using the SHA-256 hash function.AES(k, iv, m): the AES-128 encryption function in CTR mode.
Special notation used: X || Y denotes concatenation of X and Y.
Alice wants to send an encrypted message that can be decrypted by
Bob's static private key kB.
Alice knows about Bobs static public key KB.
To encrypt the message m, Alice generates a random number r and
corresponding elliptic curve public key R = r * G and
computes the shared secret S = Px where (Px, Py) = r * KB.
She derives key material for encryption and
authentication as kE || kM = KDF(S, 32)
as well as a random initialization vector iv.
Alice sends the encrypted message R || iv || c || d where c = AES(kE, iv , m)
and d = MAC(sha256(kM), iv || c) to Bob.
For Bob to decrypt the message R || iv || c || d,
he derives the shared secret S = Px where (Px, Py) = kB * R
as well as the encryption and authentication keys kE || kM = KDF(S, 32).
Bob verifies the authenticity of the message
by checking whether d == MAC(sha256(kM), iv || c)
then obtains the plaintext as m = AES(kE, iv || c).
Padding
The padding field is used to align data size,
since data size alone might reveal important metainformation.
Padding can be arbitrary size.
However, it is recommended that the size of data field
(excluding the iv and tag) before encryption (i.e. plain text)
SHOULD be a multiple of 256 bytes.
Decoding a message
In order to decode a message, a node SHOULD try to apply both symmetric and asymmetric decryption operations. This is because the type of encryption is not included in the message.
Copyright
Copyright and related rights waived via CC0.
References
- 6/WAKU1
- 10/WAKU2 spec
- 14/WAKU-MESSAGE version 1
- 7/WAKU-DATA
- EIP-627: Whisper spec
- RLPx Transport Protocol spec (ECIES encryption)
- Status 5/SECURE-TRANSPORT
- Augmented Backus-Naur form (ABNF)
- Ethereum "Yellow paper": Appendix F Signing transactions
- authenticated encryption
53/WAKU2-X3DH
| Field | Value |
|---|---|
| Name | X3DH usage for Waku payload encryption |
| Slug | 53 |
| Status | draft |
| Category | Standards Track |
| Editor | Aaryamann Challani [email protected] |
| Contributors | Andrea Piana [email protected], Pedro Pombeiro [email protected], Corey Petty [email protected], Oskar Thorén [email protected], Dean Eigenmann [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-07-01 —
b60abdb— update waku/standards/application/53/x3dh.md (#150) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
51567b1— Rename X3DH.md to x3dh.md - 2024-01-31 —
9fd3266— Update and rename README.md to X3DH.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
5e95a1a— Rename README.md to README.md - 2024-01-25 —
555eb20— Create README.md
Abstract
This document describes a method that can be used to provide a secure channel between two peers, and thus provide confidentiality, integrity, authenticity and forward secrecy. It is transport-agnostic and works over asynchronous networks.
It builds on the X3DH and Double Ratchet specifications, with some adaptations to operate in a decentralized environment.
Motivation
Nodes on a network may want to communicate with each other in a secure manner, without other nodes network being able to read their messages.
Specification
Definitions
-
Perfect Forward Secrecy is a feature of specific key-agreement protocols which provide assurances that session keys will not be compromised even if the private keys of the participants are compromised. Specifically, past messages cannot be decrypted by a third-party who manages to obtain those private key.
-
Secret channel describes a communication channel where a Double Ratchet algorithm is in use.
Design Requirements
- Confidentiality: The adversary should not be able to learn what data is being exchanged between two Status clients.
- Authenticity: The adversary should not be able to cause either endpoint to accept data from any third party as though it came from the other endpoint.
- Forward Secrecy: The adversary should not be able to learn what data was exchanged between two clients if, at some later time, the adversary compromises one or both of the endpoints.
- Integrity: The adversary should not be able to cause either endpoint to accept data that has been tampered with.
All of these properties are ensured by the use of Signal's Double Ratchet
Conventions
Types used in this specification are defined using the Protobuf wire format.
End-to-End Encryption
End-to-end encryption (E2EE) takes place between two clients. The main cryptographic protocol is a Double Ratchet protocol, which is derived from the Off-the-Record protocol, using a different ratchet. The Waku v2 protocol subsequently encrypts the message payload, using symmetric key encryption. Furthermore, the concept of prekeys (through the use of X3DH) is used to allow the protocol to operate in an asynchronous environment. It is not necessary for two parties to be online at the same time to initiate an encrypted conversation.
Cryptographic Protocols
This protocol uses the following cryptographic primitives:
- X3DH
- Elliptic curve Diffie-Hellman key exchange (secp256k1)
- KECCAK-256
- ECDSA
- ECIES
- Double Ratchet
-
HMAC-SHA-256 as MAC
-
Elliptic curve Diffie-Hellman key exchange (Curve25519)
-
AES-256-CTR with HMAC-SHA-256 and IV derived alongside an encryption key
The node achieves key derivation using HKDF.
-
Pre-keys
Every client SHOULD initially generate some key material which is stored locally:
- Identity keypair based on secp256k1 -
IK - A signed prekey based on secp256k1 -
SPK - A prekey signature -
Sig(IK, Encode(SPK))
More details can be found in the X3DH Prekey bundle creation section of 2/ACCOUNT.
Prekey bundles MAY be extracted from any peer's messages,
or found via searching for their specific topic, {IK}-contact-code.
The following methods can be used to retrieve prekey bundles from a peer's messages:
- contact codes;
- public and one-to-one chats;
- QR codes;
- ENS record;
- Decentralized permanent storage (e.g. Swarm, IPFS).
- Waku
Waku SHOULD be used for retrieving prekey bundles.
Since bundles stored in QR codes or ENS records cannot be updated to delete already used keys, the bundle MAY be rotated every 24 hours, and distributed via Waku.
Flow
The key exchange can be summarized as follows:
-
Initial key exchange: Two parties, Alice and Bob, exchange their prekey bundles, and derive a shared secret.
-
Double Ratchet: The two parties use the shared secret to derive a new encryption key for each message they send.
-
Chain key update: The two parties update their chain keys. The chain key is used to derive new encryption keys for future messages.
-
Message key derivation: The two parties derive a new message key from their chain key, and use it to encrypt a message.
1. Initial key exchange flow (X3DH)
Section 3 of the X3DH protocol describes the initial key exchange flow, with some additional context:
- The peers' identity keys
IK_AandIK_Bcorrespond to their public keys; - Since it is not possible to guarantee that a prekey will be used only once
in a decentralized world, the one-time prekey
OPK_Bis not used in this scenario; - Nodes SHOULD not send Bundles to a centralized server, but instead provide them in a decentralized way as described in the Pre-keys section.
Alice retrieves Bob's prekey bundle, however it is not specific to Alice. It contains:
Wire format:
// X3DH prekey bundle
message Bundle {
// Identity key 'IK_B'
bytes identity = 1;
// Signed prekey 'SPK_B' for each device, indexed by 'installation-id'
map<string,SignedPreKey> signed_pre_keys = 2;
// Prekey signature 'Sig(IK_B, Encode(SPK_B))'
bytes signature = 4;
// When the bundle was created locally
int64 timestamp = 5;
}
message SignedPreKey {
bytes signed_pre_key = 1;
uint32 version = 2;
}
The signature is generated by sorting installation-id in lexicographical order,
and concatenating the signed-pre-key and version:
installation-id-1signed-pre-key1version1installation-id2signed-pre-key2-version-2
2. Double Ratchet
Having established the initial shared secret SK through X3DH,
it SHOULD be used to seed a Double Ratchet exchange between Alice and Bob.
Refer to the Double Ratchet spec for more details.
The initial message sent by Alice to Bob is sent as a top-level ProtocolMessage
(reference wire format)
containing a map of DirectMessageProtocol indexed by installation-id
(reference wire format):
message ProtocolMessage {
// The installation id of the sender
string installation_id = 2;
// A sequence of bundles
repeated Bundle bundles = 3;
// One to one message, encrypted, indexed by installation_id
map<string,DirectMessageProtocol> direct_message = 101;
// Public message, not encrypted
bytes public_message = 102;
}
message EncryptedMessageProtocol {
X3DHHeader X3DH_header = 1;
DRHeader DR_header = 2;
DHHeader DH_header = 101;
// Encrypted payload
// if a bundle is available, contains payload encrypted with the Double Ratchet algorithm;
// otherwise, payload encrypted with output key of DH exchange (no Perfect Forward Secrecy).
bytes payload = 3;
}
Where:
-
X3DH_header: theX3DHHeaderfield inDirectMessageProtocolcontains:
message X3DHHeader {
// Alice's ephemeral key `EK_A`
bytes key = 1;
// Bob's bundle signed prekey
bytes id = 4;
}
DR_header: Double ratchet header (reference wire format). Used when Bob's public bundle is available:
message DRHeader {
// Alice's current ratchet public key
bytes key = 1;
// number of the message in the sending chain
uint32 n = 2;
// length of the previous sending chain
uint32 pn = 3;
// Bob's bundle ID
bytes id = 4;
}
Alice's current ratchet public key (above) is mentioned in DR spec section 2.2
DH_header: Diffie-Hellman header (used when Bob's bundle is not available): (reference wire format)
message DHHeader {
// Alice's compressed ephemeral public key.
bytes key = 1;
}
3. Chain key update
The chain key MUST be updated according to the DR_Header
received in the EncryptedMessageProtocol message,
described in 2.Double Ratchet.
4. Message key derivation
The message key MUST be derived from a single ratchet step in the symmetric-key ratchet as described in Symmetric key ratchet
The message key MUST be used to encrypt the next message to be sent.
Security Considerations
-
Inherits the security considerations of X3DH and Double Ratchet.
-
Inherits the security considerations of the Waku v2 protocol.
-
The protocol is designed to be used in a decentralized manner, however, it is possible to use a centralized server to serve prekey bundles. In this case, the server is trusted.
Privacy Considerations
- This protocol does not provide message unlinkability. It is possible to link messages signed by the same keypair.
Copyright
Copyright and related rights waived via CC0.
References
- X3DH
- Double Ratchet
- Signal's Double Ratchet
- Protobuf
- Off-the-Record protocol
- The Waku v2 protocol
- HKDF
- 2/ACCOUNT
- reference wire format
- Symmetric key ratchet
54/WAKU2-X3DH-SESSIONS
| Field | Value |
|---|---|
| Name | Session management for Waku X3DH |
| Slug | 54 |
| Status | draft |
| Category | Standards Track |
| Editor | Aaryamann Challani [email protected] |
| Contributors | Andrea Piana [email protected], Pedro Pombeiro [email protected], Corey Petty [email protected], Oskar Thorén [email protected], Dean Eigenmann [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-24 —
db365cb— update waku/standards/application/54/x3dh-sessions.md (#151) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
0e490d4— Rename X3DH-sessions.md to x3dh-sessions.md - 2024-01-31 —
7f8b187— Update and rename README.md to X3DH-sessions.md - 2024-01-27 —
a22c2a0— Rename README.md to README.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
484df92— Create README.md
Abstract
This document specifies how to manage sessions based on an X3DH key exchange. This includes how to establish new sessions, how to re-establish them, how to maintain them, and how to close them.
53/WAKU2-X3DH specifies the Waku X3DH protocol
for end-to-end encryption.
Once two peers complete an X3DH handshake, they SHOULD establish an X3DH session.
Session Establishment
A node identifies a peer by their installation-id
which MAY be interpreted as a device identifier.
Discovery of pre-key bundles
The node's pre-key bundle MUST be broadcast on a content topic derived from the node's public key, so that the first message may be PFS-encrypted. Each peer MUST publish their pre-key bundle periodically to this topic, otherwise they risk not being able to perform key-exchanges with other peers. Each peer MAY publish to this topic when their metadata changes, so that the other peer can update their local record.
If peer A wants to send a message to peer B, it MUST derive the topic from peer B's public key, which has been shared out of band. Partitioned topics have been used to balance privacy and efficiency of broadcasting pre-key bundles.
The number of partitions that MUST be used is 5000.
The topic MUST be derived as follows:
var partitionsNum *big.Int = big.NewInt(5000)
var partition *big.Int = big.NewInt(0).Mod(peerBPublicKey, partitionsNum)
partitionTopic := "contact-discovery-" + strconv.FormatInt(partition.Int64(), 10)
var hash []byte = keccak256(partitionTopic)
var topicLen int = 4
if len(hash) < topicLen {
topicLen = len(hash)
}
var contactCodeTopic [4]byte
for i = 0; i < topicLen; i++ {
contactCodeTopic[i] = hash[i]
}
Initialization
A node initializes a new session once a successful X3DH exchange has taken place. Subsequent messages will use the established session until re-keying is necessary.
Negotiated topic to be used for the session
After the peers have performed the initial key exchange, they MUST derive a topic from their shared secret to send messages on. To obtain this value, take the first four bytes of the keccak256 hash of the shared secret encoded in hexadecimal format.
sharedKey, err := ecies.ImportECDSA(myPrivateKey).GenerateShared(
ecies.ImportECDSAPublic(theirPublicKey),
16,
16,
)
hexEncodedKey := hex.EncodeToString(sharedKey)
var hash []byte = keccak256(hexEncodedKey)
var topicLen int = 4
if len(hash) < topicLen {
topicLen = len(hash)
}
var topic [4]byte
for i = 0; i < topicLen; i++ {
topic[i] = hash[i]
}
To summarize, following is the process for peer B to establish a session with peer A:
- Listen to peer B's Contact Code Topic to retrieve their bundle information, including a list of active devices
- Peer A sends their pre-key bundle on peer B's partitioned topic
- Peer A and peer B perform the key-exchange using the shared pre-key bundles
- The negotiated topic is derived from the shared secret
- Peers A & B exchange messages on the negotiated topic
Concurrent sessions
If a node creates two sessions concurrently between two peers, the one with the symmetric key first in byte order SHOULD be used, this marks that the other has expired.
Re-keying
On receiving a bundle from a given peer with a higher version, the old bundle SHOULD be marked as expired and a new session SHOULD be established on the next message sent.
Multi-device support
Multi-device support is quite challenging
as there is not a central place where information on which and how many devices
(identified by their respective installation-id) a peer has, is stored.
Furthermore, account recovery always needs to be taken into consideration, where a user wipes clean the whole device and the node loses all the information about any previous sessions. Taking these considerations into account, the way the network propagates multi-device information using X3DH bundles, which will contain information about paired devices as well as information about the sending device. This means that every time a new device is paired, the bundle needs to be updated and propagated with the new information, the user has the responsibility to make sure the pairing is successful.
The method is loosely based on Signal's Sesame Algorithm.
Pairing
A new installation-id MUST be generated on a per-device basis.
The device should be paired as soon as possible if other devices are present.
If a bundle is received, which has the same IK as the keypair present on the device,
the devices MAY be paired.
Once a user enables a new device,
a new bundle MUST be generated which includes pairing information.
The bundle MUST be propagated to contacts through the usual channels.
Removal of paired devices is a manual step that needs to be applied on each device, and consist simply in disabling the device, at which point pairing information will not be propagated anymore.
Sending messages to a paired group
When sending a message,
the peer SHOULD send a message to other installation-id that they have seen.
The node caps the number of devices to n, ordered by last activity.
The node sends messages using pairwise encryption, including their own devices.
Where n is the maximum number of devices that can be paired.
Account recovery
Account recovery is the same as adding a new device, and it MUST be handled the same way.
Partitioned devices
In some cases
(i.e. account recovery when no other pairing device is available, device not paired),
it is possible that a device will receive a message
that is not targeted to its own installation-id.
In this case an empty message containing bundle information MUST be sent back,
which will notify the receiving end not to include the device in any further communication.
Security Considerations
- Inherits all security considerations from 53/WAKU2-X3DH.
Recommendations
- The value of
nSHOULD be configured by the app-protocol.- The default value SHOULD be 3, since a larger number of devices will result in a larger bundle size, which may not be desirable in a peer-to-peer network.
Copyright
Copyright and related rights waived via CC0.
References
Waku Standards - Legacy
Legacy Waku standards retained for reference and historical compatibility.
6/WAKU1
| Field | Value |
|---|---|
| Name | Waku v1 |
| Slug | 6 |
| Status | stable |
| Editor | Oskar Thorén [email protected] |
| Contributors | Adam Babik [email protected], Andrea Maria Piana [email protected], Dean Eigenmann [email protected], Kim De Mey [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-12 —
be052c8— Rename waku/standards/core/waku_legacy/6/waku1.md to waku/standards/legacy/6/waku1.md - 2024-02-12 —
7d83b3d— Rename waku/standards/core/6/waku1.md to waku/standards/core/waku_legacy/6/waku1.md - 2024-02-01 —
161b35a— Update and rename WAKU1.md to waku1.md - 2024-01-27 —
4c666c6— Create WAKU1.md - 2024-01-27 —
61f7641— Create WAKU0.md
This specification describes the format of Waku packets within the ÐΞVp2p Wire Protocol. This spec substitutes EIP-627. Waku is a fork of the original Whisper protocol that enables better usability for resource restricted devices, such as mostly-offline bandwidth-constrained smartphones. It does this through (a) light node support, (b) historic envelopes (with a mailserver) (c) expressing topic interest for better bandwidth usage and (d) basic rate limiting.
Motivation
Waku was created to incrementally improve in areas that Whisper is lacking in, with special attention to resource restricted devices. We specify the standard for Waku packets in order to ensure forward compatibility of different Waku clients, backwards compatibility with Whisper clients, as well as to allow multiple implementations of Waku and its capabilities. We also modify the language to be more unambiguous, concise and consistent.
Definitions
| Term | Definition |
|---|---|
| Batch Ack | An abbreviated term for Batch Acknowledgment |
| Light node | A Waku node that does not forward any envelopes through the Messages packet. |
| Envelope | Messages sent and received by Waku nodes. Described in ABNF spec waku-envelope |
| Node | Some process that is able to communicate for Waku. |
Underlying Transports and Prerequisites
Use of DevP2P
For nodes to communicate, they MUST implement devp2p and run RLPx. They MUST have some way of connecting to other nodes. Node discovery is largely out of scope for this spec, but see the appendix for some suggestions on how to do this.
This protocol needs to advertise the waku/1 capability.
Gossip based routing
In Whisper, envelopes are gossiped between peers. Whisper is a form of rumor-mongering protocol that works by flooding to its connected peers based on some factors. Envelopes are eligible for retransmission until their TTL expires. A node SHOULD relay envelopes to all connected nodes if an envelope matches their PoW and bloom filter settings. If a node works in light mode, it MAY choose not to forward envelopes. A node MUST NOT send expired envelopes, unless the envelopes are sent as a 8/WAKU-MAIL response. A node SHOULD NOT send an envelope to a peer that it has already sent before.
Maximum Packet Size
Nodes SHOULD limit the maximum size of both packets and envelopes. If a packet or envelope exceeds its limit, it MUST be dropped.
- RLPx Packet Size - This size MUST be checked before a message is decoded.
- Waku Envelope Size - Each envelope contained in an RLPx packet MUST then separately be checked against the maximum envelope size.
Clients MAY use their own maximum packet and envelope sizes.
The default values are 1.5mb for the RLPx Packet and 1mb for a Waku envelope.
Wire Specification
Use of RLPx transport protocol
All Waku packets are sent as devp2p RLPx transport protocol, version 51 packets. These packets MUST be RLP-encoded arrays of data containing two objects: packet code followed by another object (whose type depends on the packet code). See informal RLP spec and the Ethereum Yellow Paper, appendix B for more details on RLP.
Waku is a RLPx subprotocol called waku with version 0.
The version number corresponds to the major version in the header spec.
Minor versions should not break compatibility of waku,
this would result in a new major.
(Some exceptions to this apply in the Draft stage
of where client implementation is rapidly change).
ABNF specification
Using Augmented Backus-Naur form (ABNF) we have the following format:
; Packet codes 0 - 127 are reserved for Waku protocol
packet-code = 1*3DIGIT
; rate limits per packet
packet-limit-ip = 1*DIGIT
packet-limit-peerid = 1*DIGIT
packet-limit-topic = 1*DIGIT
; rate limits by size in bytes
bytes-limit-ip = 1*DIGIT
bytes-limit-peerid = 1*DIGIT
bytes-limit-topic = 1*DIGIT
packet-rate-limits = "[" packet-limit-ip packet-limit-peerid packet-limit-topic "]"
bytes-rate-limits = "[" bytes-limit-ip bytes-limit-peerid bytes-limit-topic "]"
pow-requirement-key = 0
bloom-filter-key = 1
light-node-key = 2
confirmations-enabled-key = 3
packet-rate-limits-key = 4
topic-interest-key = 5
bytes-rate-limits-key = 6
status-options = "["
[ pow-requirement-key pow-requirement ]
[ bloom-filter-key bloom-filter ]
[ light-node-key light-node ]
[ confirmations-enabled-key confirmations-enabled ]
[ packet-rate-limits-key packet-rate-limits ]
[ topic-interest-key topic-interest ]
[ bytes-limits-key bytes-rate-limits ]
"]"
status = status-options
status-update = status-options
confirmations-enabled = BIT
light-node = BIT
; pow is "a single floating point value of PoW.
; This value is the IEEE 754 binary representation
; of a 64-bit floating point number packed as a uint64.
; Values of qNAN, sNAN, INF and -INF are not allowed.
; Negative values are also not allowed."
pow = 1*DIGIT "." 1*DIGIT
pow-requirement = pow
; bloom filter is "a byte array"
bloom-filter = *OCTET
waku-envelope = "[" expiry ttl topic data nonce "]"
; List of topics interested in
topic-interest = "[" *10000topic "]"
; 4 bytes (UNIX time in seconds)
expiry = 4OCTET
; 4 bytes (time-to-live in seconds)
ttl = 4OCTET
; 4 bytes of arbitrary data
topic = 4OCTET
; byte array of arbitrary size
; (contains encrypted payload)
data = *OCTET
; 8 bytes of arbitrary data
; (used for PoW calculation)
nonce = 8OCTET
messages = 1*waku-envelope
; version of the confirmation packet
version = 1*DIGIT
; keccak256 hash of the envelopes batch data (raw bytes)
; for which the confirmation is sent
hash = *OCTET
hasherror = *OCTET
; error code
code = 1*DIGIT
; a descriptive error message
description = *ALPHA
error = "[" hasherror code description "]"
errors = *error
response = "[" hash errors "]"
confirmation = "[" version response "]"
; message confirmation packet types
batch-ack = confirmation
message-response = confirmation
; mail server / client specific
p2p-request = waku-envelope
p2p-message = 1*waku-envelope
p2p-request-complete = *OCTET
; packet-format needs to be paired with its
; corresponding packet-format
packet-format = "[" packet-code packet-format "]"
required-packet = 0 status /
1 messages /
22 status-update /
optional-packet = 11 batch-ack /
12 message-response /
126 p2p-request-complete /
126 p2p-request /
127 p2p-message
packet = "[" required-packet [ optional-packet ] "]"
All primitive types are RLP encoded. Note that, per RLP specification,
integers are encoded starting from 0x00.
Packet Codes
The packet codes reserved for Waku protocol: 0 - 127.
Packets with unknown codes MUST be ignored without generating any error, for forward compatibility of future versions.
The Waku sub-protocol MUST support the following packet codes:
| Name | Int Value |
|---|---|
| Status | 0 |
| Messages | 1 |
| Status Update | 22 |
The following message codes are optional, but they are reserved for specific purpose.
| Name | Int Value | Comment |
|---|---|---|
| Batch Ack | 11 | |
| Message Response | 12 | |
| P2P Request Complete | 125 | |
| P2P Request | 126 | |
| P2P Message | 127 |
Packet usage
Status
The Status packet serves as a Waku handshake and peers MUST exchange this packet upon connection. It MUST be sent after the RLPx handshake and prior to any other Waku packets.
A Waku node MUST await the Status packet from a peer before engaging in other Waku protocol activity with that peer. When a node does not receive the Status packet from a peer, before a configurable timeout, it SHOULD disconnect from that peer.
Upon retrieval of the Status packet, the node SHOULD validate the packet received and validated the Status packet. Note that its peer might not be in the same state.
When a node is receiving other Waku packets from a peer before a Status packet is received, the node MUST ignore these packets and SHOULD disconnect from that peer. Status packets received after the handshake is completed MUST also be ignored.
The Status packet MUST contain an association list containing various options. All options within this association list are OPTIONAL, ordering of the key-value pairs is not guaranteed and therefore MUST NOT be relied on. Unknown keys in the association list SHOULD be ignored.
Messages
This packet is used for sending the standard Waku envelopes.
Status Update
The Status Update packet is used to communicate an update of the settings of the node. The format is the same as the Status packet, all fields are optional. If none of the options are specified the packet MUST be ignored and considered a noop. Fields that are omitted are considered unchanged, fields that haven't changed SHOULD not be transmitted.
PoW Requirement Field
When PoW Requirement is updated, peers MUST NOT deliver envelopes with PoW lower than the PoW Requirement specified.
PoW is defined as average number of iterations, required to find the current BestBit (the number of leading zero bits in the hash), divided by envelope size and TTL:
PoW = (2**BestBit) / (size * TTL)
PoW calculation:
fn short_rlp(envelope) = rlp of envelope, excluding env_nonce field. fn pow_hash(envelope, env_nonce) = sha3(short_rlp(envelope) ++ env_nonce) fn pow(pow_hash, size, ttl) = 2**leading_zeros(pow_hash) / (size * ttl)
where size is the size of the RLP-encoded envelope,
excluding env_nonce field (size of short_rlp(envelope)).
Bloom Filter Field
The bloom filter is used to identify a number of topics to a peer without compromising (too much) privacy over precisely what topics are of interest. Precise control over the information content (and thus efficiency of the filter) may be maintained through the addition of bits.
Blooms are formed by the bitwise OR operation on a number of bloomed topics. The bloom function takes the topic and projects them onto a 512-bit slice. At most, three bits are marked for each bloomed topic.
The projection function is defined as a mapping from a 4-byte slice S to a 512-bit slice D; for ease of explanation, S will dereference to bytes, whereas D will dereference to bits.
LET D[*] = 0 FOREACH i IN { 0, 1, 2 } DO LET n = S[i] IF S[3] & (2 ** i) THEN n += 256 D[n] = 1 END FOR
A full bloom filter (all the bits set to 1)
means that the node is to be considered a Full Node and it will accept any topic.
If both topic interest and bloom filter are specified, topic interest always takes precedence and bloom filter MUST be ignored.
If only bloom filter is specified, the current topic interest MUST be discarded and only the updated bloom filter MUST be used when forwarding or posting envelopes.
A bloom filter with all bits set to 0 signals that the node is not currently interested in receiving any envelope.
Topic Interest Field
Topic interest is used to share a node's interest in envelopes with specific topics. It does this in a more bandwidth considerate way, at the expense of some metadata protection. Peers MUST only send envelopes with specified topics.
It is currently bounded to a maximum of 10000 topics. If you are interested in more topics than that, this is currently underspecified and likely requires updating it. The constant is subject to change.
If only topic interest is specified, the current bloom filter MUST be discarded and only the updated topic interest MUST be used when forwarding or posting envelopes.
An empty array signals that the node is not currently interested in receiving any envelope.
Rate Limits Field
Rate limits is used to inform other nodes of their self defined rate limits.
In order to provide basic Denial-of-Service attack protection, each node SHOULD define its own rate limits. The rate limits SHOULD be applied on IPs, peer IDs, and envelope topics.
Each node MAY decide to whitelist, i.e. do not rate limit, selected IPs or peer IDs.
If a peer exceeds node's rate limits, the connection between them MAY be dropped.
Each node SHOULD broadcast its rate limits to its peers
using the status-update packet.
The rate limits MAY also be sent as an optional parameter in the handshake.
Each node SHOULD respect rate limits advertised by its peers. The number of packets SHOULD be throttled in order not to exceed peer's rate limits. If the limit gets exceeded, the connection MAY be dropped by the peer.
Two rate limits strategies are applied:
- Number of packets per second
- Size of packets (in bytes) per second
Both strategies SHOULD be applied per IP address, peer id and topic.
The size limit SHOULD be greater or equal than the maximum packet size.
Light Node Field
When the node's light-node field is set to true,
the node SHOULD NOT forward Envelopes from its peers.
A node connected to a peer with the light-node field set to true
MUST NOT depend on the peer for forwarding Envelopes.
Confirmations Enabled Field
When the node's confirmations-enabled field is set to true,
the node SHOULD send message confirmations
to its peers.
Batch Ack and Message Response
Message confirmations tell a node that an envelope originating from it has been received by its peers, allowing a node to know whether an envelope has or has not been received.
A node MAY send a message confirmation for any batch of envelopes
received with a Messages packet (0x01).
A message confirmation is sent using Batch Ack packet (0x0B) or
Message Response packet (0x0C).
The message confirmation is specified in the ABNF specification.
The current version in the confirmation is 1.
The supported error codes:
1: time sync error which happens when an envelope is too old or was created in the future (typically because of an unsynchronized clock of a node).
The drawback of sending message confirmations is that it increases the noise in the network because for each sent envelope, a corresponding confirmation is broadcast by one or more peers.
P2P Request
This packet is used for sending Dapp-level peer-to-peer requests, e.g. Waku Mail Client requesting historic (expired) envelopes from the Waku Mail Server.
P2P Message
This packet is used for sending the peer-to-peer envelopes, which are not supposed to be forwarded any further. E.g. it might be used by the Waku Mail Server for delivery of historic (expired) envelopes, which is otherwise not allowed.
P2P Request Complete
This packet is used to indicate that all envelopes,
requested earlier with a P2P Request packet (0x7E),
have been sent via one or more P2P Message packets (0x7F).
The content of the packet is explained in the Waku Mail Server specification.
Payload Encryption
Asymmetric encryption uses the standard Elliptic Curve Integrated Encryption Scheme with SECP-256k1 public key.
Symmetric encryption uses AES GCM algorithm with random 96-bit nonce.
Packet code Rationale
Packet codes 0x00 and 0x01 are already used in all Waku / Whisper versions.
Packet code 0x02 and 0x03 were previously used in Whisper but
are deprecated as of Waku v0.4
Packet code 0x22 is used to dynamically change the settings of a node.
Packet codes 0x7E and 0x7F may be used to implement Waku Mail Server and
Client.
Without the P2P Message packet it would be impossible to deliver the historic envelopes,
since they will be recognized as expired, and
the peer will be disconnected for violating the Waku protocol.
They might be useful for other purposes
when it is not possible to spend time on PoW,
e.g. if a stock exchange will want to provide live feed about the latest trades.
Additional capabilities
Waku supports multiple capabilities. These include light node, rate limiting and bridging of traffic. Here we list these capabilities, how they are identified, what properties they have and what invariants they must maintain.
Additionally, there is the capability of a mailserver which is documented in its on specification.
Light node
The rationale for light nodes is to allow for interaction with waku on resource restricted devices as bandwidth can often be an issue.
Light nodes MUST NOT forward any incoming envelopes, they MUST only send their own envelopes. When light nodes happen to connect to each other, they SHOULD disconnect. As this would result in envelopes being dropped between the two.
Light nodes are identified by the light_node value in the Status packet.
Accounting for resources (experimental)
Nodes MAY implement accounting, keeping track of resource usage. It is heavily inspired by Swarm's SWAP protocol, and works by doing pairwise accounting for resources.
Each node keeps track of resource usage with all other nodes. Whenever an envelope is received from a node that is expected (fits bloom filter or topic interest, is legal, etc) this is tracked.
Every epoch (say, every minute or every time an event happens) statistics SHOULD be aggregated and saved by the client:
| peer | sent | received |
|---|---|---|
| peer1 | 0 | 123 |
| peer2 | 10 | 40 |
In later versions this will be amended by nodes communication thresholds, settlements and disconnect logic.
Upgradability and Compatibility
General principles and policy
The currently advertised capability is waku/1.
This needs to be advertised in the hello ÐΞVp2p packet.
If a node supports multiple versions of waku, those needs to be explicitly advertised.
For example if both waku/0 and waku/1 are supported,
both waku/0 and waku/1 MUST be advertised.
These are policies that guide how we make decisions when it comes to upgradability, compatibility, and extensibility:
-
Waku aims to be compatible with previous and future versions.
-
In cases where we want to break this compatibility, we do so gracefully and as a single decision point.
-
To achieve this, we employ the following two general strategies:
- a) Accretion (including protocol negotiation) over changing data
- b) When we want to change things, we give it a new name (for example, a version number).
Examples:
- We enable bridging between
shh/6andwaku/1until such a time as when we are ready to gracefully drop support forshh/6(1, 2, 3). - When we add parameter fields, we (currently) do so by accreting them in a list, so old clients can ignore new fields (dynamic list) and new clients can use new capabilities (1, 3).
- To better support (2) and (3) in the future, we will likely release a new version that gives better support for open, growable maps (association lists or native map type) (3)
- When we we want to provide a new set of packets that have different requirements, we do so under a new protocol version and employ protocol versioning. This is a form of accretion at a level above - it ensures a client can support both protocols at once and drop support for legacy versions gracefully. (1,2,3)
Backwards Compatibility
Waku is a different subprotocol from Whisper so it isn't directly compatible. However, the data format is the same, so compatibility can be achieved by the use of a bridging mode as described below. Any client which does not implement certain packet codes should gracefully ignore the packets with those codes. This will ensure the forward compatibility.
Waku-Whisper bridging
waku/1 and shh/6 are different DevP2P subprotocols,
however they share the same data format making their envelopes compatible.
This means we can bridge the protocols naively, this works as follows.
Roles:
- Waku client A, only Waku capability
- Whisper client B, only Whisper capability
- WakuWhisper bridge C, both Waku and Whisper capability
Flow:
- A posts envelope; B posts envelope.
- C picks up envelope from A and B and relays them both to Waku and Whisper.
- A receives envelope on Waku; B on Whisper.
Note: This flow means if another bridge C1 is active, we might get duplicate relaying for a envelope between C1 and C2. I.e. Whisper(<>Waku<>Whisper)<>Waku, A-C1-C2-B. Theoretically this bridging chain can get as long as TTL permits.
Forward Compatibility
It is desirable to have a strategy for maintaining forward compatibility
between waku/1 and future version of waku.
Here we outline some concerns and strategy for this.
- Connecting to nodes with multiple versions:
The way this SHOULD be accomplished is by negotiating the versions of subprotocols,
within the
hellopacket nodes transmit their capabilities along with a version. The highest common version should then be used. - Adding new packet codes: New packet codes can be added easily due to the available packet codes. Unknown packet codes SHOULD be ignored. Upgrades that add new packet codes SHOULD implement some fallback mechanism if no response was received for nodes that do not yet understand this packet.
- Adding new options in
status-options: New options can be added to thestatus-optionsassociation list in thestatusandstatus-updatepacket as options are OPTIONAL and unknown option keys SHOULD be ignored. A node SHOULD NOT disconnect from a peer when receivingstatus-optionswith unknown option keys.
Appendix A: Security considerations
There are several security considerations to take into account when running Waku. Chief among them are: scalability, DDoS-resistance and privacy. These also vary depending on what capabilities are used. The security considerations for extra capabilities, such as mailservers can be found in their respective specifications.
Scalability and UX
Bandwidth usage
In version 0 of Waku, bandwidth usage is likely to be an issue. For more investigation into this, see the theoretical scaling model described here.
Gossip-based routing
Use of gossip-based routing doesn't necessarily scale. It means each node can see an envelope multiple times, and having too many light nodes can cause propagation probability that is too low. See Whisper vs PSS for more and a possible Kademlia based alternative.
Lack of incentives
Waku currently lacks incentives to run nodes, which means node operators are more likely to create centralized choke points.
Privacy
Light node privacy
The main privacy concern with a light node is that it has to reveal its topic interests (in addition to its IP/ID) to its directed peers. This is because when a light node publishes an envelope, its directed peers will know that the light node owns that envelope (as light nodes do not relay other envelopes). Therefore, the directed peers of a light node can make assumptions about what envelopes (topics) the light node is interested in.
Mailserver client privacy
A mailserver client fetches archival envelopes from a mailserver through a direct connection. In this direct connection, the client discloses its IP/ID as well as the topics/ bloom filter it is interested in to the mailserver. The collection of such information allows the mailserver to link clients' IP/IDs to their topic interests and build a profile for each client over time. As such, the mailserver client has to trust the mailserver with this level of information.
Bloom filter privacy
By having a bloom filter where only the topics you are interested in are set, you reveal which envelopes you are interested in. This is a fundamental tradeoff between bandwidth usage and privacy, though the tradeoff space is likely suboptimal in terms of the Anonymity trilemma.
Privacy guarantees not rigorous
Privacy for Whisper / Waku haven't been studied rigorously for various threat models like global passive adversary, local active attacker, etc. This is unlike e.g. Tor and mixnets.
Topic hygiene
Similar to bloom filter privacy, if you use a very specific topic you reveal more information. See scalability model linked above.
Spam resistance
PoW bad for heterogeneous devices:
Proof of work is a poor spam prevention mechanism. A mobile device can only have a very low PoW in order not to use too much CPU / burn up its phone battery. This means someone can spin up a powerful node and overwhelm the network.
Censorship resistance
Devp2p TCP port blockable:
By default Devp2p runs on port 30303,
which is not commonly used for any other service.
This means it is easy to censor, e.g. airport WiFi.
This can be mitigated somewhat by running on e.g. port 80 or 443,
but there are still outstanding issues.
See libp2p and Tor's Pluggable Transport for how this can be improved.
Appendix B: Implementation Notes
Implementation Matrix
Recommendations for clients
Notes useful for implementing Waku mode.
1.Avoid duplicate envelopes
To avoid duplicate envelopes, only connect to one Waku node. Benign duplicate envelopes is an intrinsic property of Whisper which often leads to a N factor increase in traffic, where N is the number of peers you are connected to.
2.Topic specific recommendations
Consider partition topics based on some usage, to avoid too much traffic on a single topic.
Node discovery
Resource restricted devices SHOULD use EIP-1459 to discover nodes.
Known static nodes MAY also be used.
Changelog
Initial Release
- Add section on P2P Request Complete packet and update packet code table.
- Correct the header hierarchy for the status-options fields.
- Consistent use of the words packet, message and envelope.
- Added section on max packet size
- Complete the ABNF specification and minor ABNF fixes.
Version 1.1
Released June 09, 2020
- Add rate limit per bytes
Version 1.0
Released April 21,2020
- Removed
versionfrom handshake - Changed
RLPkeys from 48,49.. to 0,1.. - Upgraded to
waku/1
Version 0.6
Released April 21,2020
- Mark spec as Deprecated mode in terms of its lifecycle.
Version 0.5
Released March 17,2020
- Clarify the preferred way of handling unknown keys
in the
status-optionsassociation list. - Correct spec/implementation mismatch: Change RLP keys to be the their int values in order to reflect production behavior
Version 0.4
Released February 21, 2020.
- Simplify implementation matrix with latest state
- Introduces a new required packet code Status Code (
0x22) for communicating option changes - Deprecates the following packet codes: PoW Requirement (
0x02), Bloom Filter (0x03), Rate limits (0x20), Topic interest (0x21) - all superseded by the new Status Code (0x22) - Increased
topic-interestcapacity from 1000 to 10000
Version 0.3
Released February 13, 2020.
- Recommend DNS based node discovery over other Discovery methods.
- Mark spec as Draft mode in terms of its lifecycle.
- Simplify Changelog and misc formatting.
- Handshake/Status packet not compatible with shh/6 nodes; specifying options as association list.
- Include topic-interest in Status handshake.
- Upgradability policy.
topic-interestpacket code.
Version 0.2
Released December 10, 2019.
- General style improvements.
- Fix ABNF grammar.
- Mailserver requesting/receiving.
- New packet codes: topic-interest (experimental), rate limits (experimental).
- More details on handshake modifications.
- Accounting for resources mode (experimental)
- Appendix with security considerations: scalability and UX, privacy, and spam resistance.
- Appendix with implementation notes and implementation matrix across various clients with breakdown per capability.
- More details on handshake and parameters.
- Describe rate limits in more detail.
- More details on mailserver and mail client API.
- Accounting for resources mode (very experimental).
- Clarify differences with Whisper.
Version 0.1
Initial version. Released November 21, 2019.
Differences between shh/6 and waku/1
Summary of main differences between this spec and Whisper v6, as described in EIP-627:
- RLPx subprotocol is changed from
shh/6towaku/1. - Light node capability is added.
- Optional rate limiting is added.
- Status packet has following additional parameters: light-node, confirmations-enabled and rate-limits
- Mail Server and Mail Client functionality is now part of the specification.
- P2P Message packet contains a list of envelopes instead of a single envelope.
Copyright
Copyright and related rights waived via CC0.
Footnotes
Felix Lange et al. The RLPx Transport Protocol. Ethereum.
7/WAKU-DATA
| Field | Value |
|---|---|
| Name | Waku Envelope data field |
| Slug | 7 |
| Status | stable |
| Editor | Oskar Thorén [email protected] |
| Contributors | Dean Eigenmann [email protected], Kim De Mey [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-12 —
a57d7b4— Rename data.md to data.md - 2024-01-31 —
900a3e9— Update and rename DATA.md to data.md - 2024-01-27 —
662eb12— Rename README.md to DATA.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
93c3896— Create README.md
This specification describes the encryption, decryption and signing of the content in the data field used in Waku.
Specification
The data field is used within the waku envelope,
the field MUST contain the encrypted payload of the envelope.
The fields that are concatenated and encrypted as part of the data field are:
- flags
- auxiliary field
- payload
- padding
- signature
In case of symmetric encryption, a salt
(a.k.a. AES Nonce, 12 bytes) field MUST be appended.
ABNF
Using Augmented Backus-Naur form (ABNF) we have the following format:
; 1 byte; first two bits contain the size of auxiliary field,
; third bit indicates whether the signature is present.
flags = 1OCTET
; contains the size of payload.
auxiliary-field = 4*OCTET
; byte array of arbitrary size (may be zero)
payload = *OCTET
; byte array of arbitrary size (may be zero).
padding = *OCTET
; 65 bytes, if present.
signature = 65OCTET
; 2 bytes, if present (in case of symmetric encryption).
salt = 2OCTET
data = flags auxiliary-field payload padding [signature] [salt]
Signature
Those unable to decrypt the envelope data are also unable to access the signature.
The signature, if provided,
is the ECDSA signature of the Keccak-256 hash of the unencrypted data
using the secret key of the originator identity.
The signature is serialized as the concatenation of the R, S and
V parameters of the SECP-256k1 ECDSA signature, in that order.
R and S MUST be big-endian encoded, fixed-width 256-bit unsigned.
V MUST be an 8-bit big-endian encoded,
non-normalized and should be either 27 or 28.
Padding
The padding field is used to align data size, since data size alone might reveal important metainformation. Padding can be arbitrary size. However, it is recommended that the size of Data Field (excluding the Salt) before encryption (i.e. plain text) SHOULD be factor of 256 bytes.
Copyright
Copyright and related rights waived via CC0.
8/WAKU-MAIL
| Field | Value |
|---|---|
| Name | Waku Mailserver |
| Slug | 8 |
| Status | stable |
| Editor | Andrea Maria Piana [email protected] |
| Contributors | Adam Babik [email protected], Dean Eigenmann [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-12 —
fa77279— Rename mail.md to mail.md - 2024-01-31 —
0c8e39b— Rename MAIL.md to mail.md - 2024-01-27 —
de5cfa2— Rename README.md to MAIL.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
28e7862— Create README.md
Abstract
In this specification, we describe Mailservers. These are nodes responsible for archiving envelopes and delivering them to peers on-demand.
Specification
A node which wants to provide mailserver functionality MUST store envelopes
from incoming Messages packets (Waku packet-code 0x01).
The envelopes can be stored in any format,
however they MUST be serialized and deserialized to the Waku envelope format.
A mailserver SHOULD store envelopes for all topics to be generally useful for any peer, however for specific use cases it MAY store envelopes for a subset of topics.
Requesting Historic Envelopes
In order to request historic envelopes,
a node MUST send a packet P2P Request (0x7e)
to a peer providing mailserver functionality.
This packet requires one argument which MUST be a Waku envelope.
In the Waku envelope's payload section, there MUST be RLP-encoded information about the details of the request:
; UNIX time in seconds; oldest requested envelope's creation time
lower = 4OCTET
; UNIX time in seconds; newest requested envelope's creation time
upper = 4OCTET
; array of Waku topics encoded in a bloom filter to filter envelopes
bloom = 64OCTET
; unsigned integer limiting the number of returned envelopes
limit = 4OCTET
; array of a cursor returned from the previous request (optional)
cursor = *OCTET
; List of topics interested in
topics = "[" *1000topic "]"
; 4 bytes of arbitrary data
topic = 4OCTET
payload-without-topic = "[" lower upper bloom limit [ cursor ] "]"
payload-with-topic = "[" lower upper bloom limit cursor [ topics ] "]"
payload = payload-with-topic | payload-without-topic
The Cursor field SHOULD be filled in if a number of envelopes between Lower and
Upper is greater than Limit so that the requester can send another request
using the obtained Cursor value.
What exactly is in the Cursor is up to the implementation.
The requester SHOULD NOT use a Cursor obtained from one mailserver in a request
to another mailserver because the format or the result MAY be different.
The envelope MUST be encrypted with a symmetric key agreed between the requester and Mailserver.
If Topics is used the Cursor field MUST be specified
for the argument order to be unambiguous.
However, it MAY be set to null.
Topics is used to specify which topics a node is interested in.
If Topics is not empty,
a mailserver MUST only send envelopes that belong to a topic from Topics list and
Bloom value MUST be ignored.
Receiving Historic Envelopes
Historic envelopes MUST be sent to a peer as a packet with a P2P Message code (0x7f)
followed by an array of Waku envelopes.
A Mailserver MUST limit the amount of messages sent,
either by the Limit specified in the request or
limited to the maximum RLPx packet size,
whichever limit comes first.
In order to receive historic envelopes from a mailserver, a node MUST trust the selected mailserver, that is allow to receive expired packets with the P2P Message code. By default, such packets are discarded.
Received envelopes MUST be passed through the Whisper envelope pipelines so that they are picked up by registered filters and passed to subscribers.
For a requester, to know that all envelopes have been sent by mailserver,
it SHOULD handle P2P Request Complete code (0x7d).
This code is followed by a list with:
; array with a Keccak-256 hash of the envelope containing the original request.
request-id = 32OCTET
; array with a Keccak-256 hash of the last sent envelope for the request.
last-envelope-hash = 32OCTET
; array of a cursor returned from the previous request (optional)
cursor = *OCTET
payload = "[" request-id last-envelope-hash [ cursor ] "]"
If Cursor is not empty,
it means that not all envelopes were sent due to the set Limit in the request.
One or more consecutive requests MAY be sent with Cursor field filled
in order to receive the rest of the envelopes.
Security considerations
There are several security considerations to take into account when running or interacting with Mailservers. Chief among them are: scalability, DDoS-resistance and privacy.
Mailserver High Availability requirement:
A mailserver has to be online to receive envelopes for other nodes, this puts a high availability requirement on it.
Mailserver client privacy:
A mailserver client fetches archival envelopes from a mailserver through a direct connection. In this direct connection, the client discloses its IP/ID as well as the topics/ bloom filter it is interested in to the mailserver. The collection of such information allows the mailserver to link clients' IP/IDs to their topic interests and build a profile for each client over time. As such, the mailserver client has to trust the mailserver with this level of information. A similar concern exists for the light nodes and their direct peers which is discussed in the security considerations of 6/WAKU1.
Mailserver trusted connection:
A mailserver has a direct TCP connection, which means they are trusted to send traffic. This means a malicious or malfunctioning mailserver can overwhelm an individual node.
Changelog
| Version | Comment |
|---|---|
| 1.0.0 | marked stable as it is in use. |
| 0.2.0 | Add topic interest to reduce bandwidth usage |
| 0.1.0 | Initial Release |
Difference between wms 0.1 and wms 0.2
topicsoption
Copyright
Copyright and related rights waived via CC0.
9/WAKU-RPC
| Field | Value |
|---|---|
| Name | Waku RPC API |
| Slug | 9 |
| Status | stable |
| Editor | Andrea Maria Piana [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-12 —
5eb393f— Rename waku/legacy/9/rpc.md to waku/standards/legacy/9/rpc.md - 2024-02-12 —
9617146— Rename waku/standards/core/waku_legacy/9/waku2-rpc.md to waku/legacy/9/rpc.md - 2024-02-12 —
75705cd— Rename waku/standards/core/9/waku2-rpc.md to waku/standards/core/waku_legacy/9/waku2-rpc.md - 2024-02-01 —
e808e36— Create waku2-rpc.md
This specification describes the RPC API that Waku nodes MAY adhere to. The unified API allows clients to easily be able to connect to any node implementation. The API described is privileged as a node stores the keys of clients.
Introduction
This API is based off the Whisper V6 RPC API.
Wire Protocol
Transport
Nodes SHOULD expose a JSON RPC API
that can be accessed.
The JSON RPC version SHOULD be 2.0.
Below is an example request:
{
"jsonrpc":"2.0",
"method":"waku_version",
"params":[],
"id":1
}
Fields
| Field | Description |
|---|---|
jsonrpc | Contains the used JSON RPC version (Default: 2.0) |
method | Contains the JSON RPC method that is being called |
params | An array of parameters for the request |
id | The request ID |
Objects
In this section you will find objects used throughout the JSON RPC API.
Message
The message object represents a Waku message. Below you will find the description of the attributes contained in the message object. A message is the decrypted payload and padding of an envelope along with all of its metadata and other extra information such as the hash.
| Field | Type | Description |
|---|---|---|
sig | string | Public Key that signed this message |
recipientPublicKey | string | The recipients public key |
ttl | number | Time-to-live in seconds |
timestamp | number | Unix timestamp of the message generation |
topic | string | 4 bytes, the message topic |
payload | string | Decrypted payload |
padding | string | Optional padding, byte array of arbitrary length |
pow | number | The proof of work value |
hash | string | Hash of the enveloped message |
Filter
The filter object represents filters that can be applied to retrieve messages. Below you will find the description of the attributes contained in the filter object.
| Field | Type | Description |
|---|---|---|
symKeyID | string | ID of the symmetric key for message decryption |
privateKeyID | string | ID of private (asymmetric) key for message decryption |
sig | string | Public key of the signature |
minPow | number | Minimal PoW requirement for incoming messages |
topics | array | Array of possible topics, this can also contain partial topics |
allowP2P | boolean | Indicates if this filter allows processing of direct peer-to-peer messages |
All fields are optional, however symKeyID or privateKeyID must be present,
it cannot be both.
Additionally, the topics field is only optional when an asymmetric key is used.
Methods
waku_version
The waku_version method returns the current version number.
Parameters
none
Response
- string - The version number.
waku_info
The waku_info method returns information about a Waku node.
Parameters
none
Response
The response is an Object containing the following fields:
minPow[number] - The current PoW requirement.maxEnvelopeSize[float] - The current maximum envelope size in bytes.memory[number] - The memory size of the floating messages in bytes.envelopes[number] - The number of floating envelopes.
waku_setMaxEnvelopeSize
Sets the maximum envelope size allowed by this node.
Any envelopes larger than this size both incoming and outgoing will be rejected.
The envelope size can never exceed the underlying envelope size of 10mb.
Parameters
- number - The message size in bytes.
Response
- bool -
trueon success or an error on failure.
waku_setMinPoW
Sets the minimal PoW required by this node.
Parameters
- number - The new PoW requirement.
Response
- bool -
trueon success or an error on failure.
waku_markTrustedPeer
Marks a specific peer as trusted allowing it to send expired messages.
Parameters
- string -
enodeof the peer.
Response
- bool -
trueon success or an error on failure.
waku_newKeyPair
Generates a keypair used for message encryption and decryption.
Parameters
none
Response
- string - Key ID on success or an error on failure.
waku_addPrivateKey
Stores a key and returns its ID.
Parameters
- string - Private key as hex bytes.
Response
- string - Key ID on success or an error on failure.
waku_deleteKeyPair
Deletes a specific key if it exists.
Parameters
- string - ID of the Key pair.
Response
- bool -
trueon success or an error on failure.
waku_hasKeyPair
Checks if the node has a private key of a key pair matching the given ID.
Parameters
- string - ID of the Key pair.
Response
- bool -
trueorfalseor an error on failure.
waku_getPublicKey
Returns the public key for an ID.
Parameters
- string - ID of the Key.
Response
- string - The public key or an error on failure.
waku_getPrivateKey
Returns the private key for an ID.
Parameters
- string - ID of the Key.
Response
- string - The private key or an error on failure.
waku_newSymKey
Generates a random symmetric key and stores it under an ID. This key can be used to encrypt and decrypt messages where the key is known to both parties.
Parameters
none
Response
- string - The key ID or an error on failure.
waku_addSymKey
Stores the key and returns its ID.
Parameters
- string - The raw key for symmetric encryption hex encoded.
Response
- string - The key ID or an error on failure.
waku_generateSymKeyFromPassword
Generates the key from a password and stores it.
Parameters
- string - The password.
Response
- string - The key ID or an error on failure.
waku_hasSymKey
Returns whether there is a key associated with the ID.
Parameters
- string - ID of the Key.
Response
- bool -
trueorfalseor an error on failure.
waku_getSymKey
Returns the symmetric key associated with an ID.
Parameters
- string - ID of the Key.
Response
- string - Raw key on success or an error of failure.
waku_deleteSymKey
Deletes the key associated with an ID.
Parameters
- string - ID of the Key.
Response
- bool -
trueorfalseor an error on failure.
waku_subscribe
Creates and registers a new subscription to receive notifications for inbound Waku messages.
Parameters
The parameters for this request is an array containing the following fields:
- string - The ID of the function call, in case of Waku this must contain the value "messages".
- object - The message filter.
Response
- string - ID of the subscription or an error on failure.
Notifications
Notifications received by the client contain a message matching the filter. Below is an example notification:
{
"jsonrpc": "2.0",
"method": "waku_subscription",
"params": {
"subscription": "02c1f5c953804acee3b68eda6c0afe3f1b4e0bec73c7445e10d45da333616412",
"result": {
"sig": "0x0498ac1951b9078a0549c93c3f6088ec7c790032b17580dc3c0c9e900899a48d89eaa27471e3071d2de6a1f48716ecad8b88ee022f4321a7c29b6ffcbee65624ff",
"recipientPublicKey": null,
"ttl": 10,
"timestamp": 1498577270,
"topic": "0xffaadd11",
"payload": "0xffffffdddddd1122",
"padding": "0x35d017b66b124dd4c67623ed0e3f23ba68e3428aa500f77aceb0dbf4b63f69ccfc7ae11e39671d7c94f1ed170193aa3e327158dffdd7abb888b3a3cc48f718773dc0a9dcf1a3680d00fe17ecd4e8d5db31eb9a3c8e6e329d181ecb6ab29eb7a2d9889b49201d9923e6fd99f03807b730780a58924870f541a8a97c87533b1362646e5f573dc48382ef1e70fa19788613c9d2334df3b613f6e024cd7aadc67f681fda6b7a84efdea40cb907371cd3735f9326d02854",
"pow": 0.6714754098360656,
"hash": "0x17f8066f0540210cf67ef400a8a55bcb32a494a47f91a0d26611c5c1d66f8c57"
}
}
}
waku_unsubscribe
Cancels and removes an existing subscription. The node MUST stop sending the client notifications.
Parameters
- string - The subscription ID.
Response
- bool -
trueorfalse
waku_newMessageFilter
Creates a new message filter within the node. This filter can be used to poll for new messages that match the criteria.
Parameters
The request must contain a message filter as its parameter.
Response
- string - The ID of the filter.
waku_deleteMessageFilter
Removes a message filter from the node.
Parameters
- string - ID of the filter created with
waku_newMessageFilter.
Response
- bool -
trueon success or an error on failure.
waku_getFilterMessages
Retrieves messages that match a filter criteria and were received after the last time this function was called.
Parameters
- string - ID of the filter created with
waku_newMessageFilter.
Response
The response contains an array of messages or an error on failure.
waku_post
The waku_post method creates a waku envelope and propagates it to the network.
Parameters
The parameters is an Object containing the following fields:
symKeyID[string]optional- The ID of the symmetric key used for encryptionpubKey[string]optional- The public key for message encryption.sig[string]optional- The ID of the signing key.ttl[number] - The time-to-live in seconds.topic[string] - 4 bytes message topic.payload[string] - The payload to be encrypted.padding[string]optional- The padding, a byte array of arbitrary length.powTime[number] - Maximum time in seconds to be spent on the proof of work.powTarget[number] - Minimal PoW target required for this message.targetPeer[string]optional- The optional peer ID for peer-to-peer messages.
Either the symKeyID or the pubKey need to be present.
It can not be both.
Response
- bool -
trueon success or an error on failure.
Changelog
| Version | Comment |
|---|---|
| 1.0.0 | Initial release. |
Copyright
Copyright and related rights waived via CC0.
Waku Informational LIPs
Informational Waku documents covering guidance, examples, and supporting material.
22/TOY-CHAT
| Field | Value |
|---|---|
| Name | Waku v2 Toy Chat |
| Slug | 22 |
| Status | draft |
| Editor | Franck Royer [email protected] |
| Contributors | Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-01-31 —
722c3d2— Rename TOY-CHAT.md to toy-chat.md - 2024-01-29 —
5c5ea36— Update TOY-CHAT.md - 2024-01-27 —
411e135— Create TOY-CHAT.md
Content Topic: /toy-chat/2/huilong/proto.
This specification explains a toy chat example using Waku v2. This protocol is mainly used to:
- Dogfood Waku v2,
- Show an example of how to use Waku v2.
Currently, all main Waku v2 implementations support the toy chat protocol: nim-waku, js-waku (NodeJS and web) and go-waku.
Note that this is completely separate from the protocol the Status app is using for its chat functionality.
Design
The chat protocol enables sending and receiving messages in a chat room. There is currently only one chat room, which is tied to the content topic. The messages SHOULD NOT be encrypted.
The contentTopic MUST be set to /toy-chat/2/huilong/proto.
Payloads
syntax = "proto3";
message Chat2Message {
uint64 timestamp = 1;
string nick = 2;
bytes payload = 3;
}
timestamp: The time at which the message was sent, in Unix Epoch seconds,nick: The nickname of the user sending the message,payload: The text of the messages, UTF-8 encoded.
Copyright
Copyright and related rights waived via CC0.
23/WAKU2-TOPICS
| Field | Value |
|---|---|
| Name | Waku v2 Topic Usage Recommendations |
| Slug | 23 |
| Status | draft |
| Category | Informational |
| Editor | Oskar Thoren [email protected] |
| Contributors | Hanno Cornelius [email protected], Daniel Kaiser [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-22 —
4df2d5f— update waku/informational/23/topics.md (#144) - 2025-01-02 —
dc7497a— add usage guidelines for waku content topics (#117) - 2024-11-20 —
ff87c84— Update Waku Links (#104) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-07 —
e63d8a0— Update topics.md - 2024-01-31 —
b8f088c— Update and rename README.md to topics.md - 2024-01-31 —
2b693e8— Update README.md - 2024-01-29 —
055c525— Update README.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
a11dfed— Create README.md
This document outlines recommended usage of topic names in Waku v2. In 10/WAKU2 spec there are two types of topics:
- Pubsub topics, used for routing
- Content topics, used for content-based filtering
Pubsub Topics
Pubsub topics are used for routing of messages (see 11/WAKU2-RELAY), and can be named implicitly by Waku sharding (see RELAY-SHARDING). This document comprises recommendations for explicitly naming pubsub topics (e.g. when choosing named sharding as specified in RELAY-SHARDING).
Pubsub Topic Format
Pubsub topics SHOULD follow the following structure:
/waku/2/{topic-name}
This namespaced structure makes compatibility, discoverability, and automatic handling of new topics easier.
The first two parts indicate:
- it relates to the Waku protocol domain, and
- the version is 2.
If applicable, it is RECOMMENDED to structure {topic-name}
in a hierarchical way as well.
Note: In previous versions of this document, the structure was
/waku/2/{topic-name}/{encoding}. The now deprecated/{encoding}was always set to/proto, which indicated that the data field in pubsub is serialized/encoded as protobuf. The inspiration for this format was taken from Ethereum 2 P2P spec. However, because the payload of messages transmitted over 11/WAKU2-RELAY must be a 14/WAKU2-MESSAGE, which specifies the wire format as protobuf,/protois the only valid encoding. This makes the/protoindication obsolete. The encoding of thepayloadfield of a WakuMessage is indicated by the/{encoding}part of the content topic name. Specifying an encoding is only significant for the actual payload/data field. Waku preserves this option by allowing to specify an encoding for the WakuMessage payload field as part of the content topic name.
Default PubSub Topic
The Waku v2 default pubsub topic is:
/waku/2/default-waku/proto
The {topic name} part is default-waku/proto,
which indicates it is default topic for exchanging WakuMessages;
/proto remains for backwards compatibility.
Application Specific Names
Larger apps can segregate their pubsub meshes using topics named like:
/waku/2/status/
/waku/2/walletconnect/
This indicates that these networks carry WakuMessages, but for different domains completely.
Named Topic Sharding Example
The following is an example of named sharding, as specified in RELAY-SHARDING.
waku/2/waku-9_shard-0/
...
waku/2/waku-9_shard-9/
This indicates explicitly that the network traffic has been partitioned into 10 buckets.
Content Topics
The other type of topic that exists in Waku v2 is a content topic. This is used for content based filtering. See 14/WAKU2-MESSAGE spec for where this is specified. Note that this doesn't impact routing of messages between relaying nodes, but it does impact using request/reply protocols such as 12/WAKU2-FILTER and 13/WAKU2-STORE.
This is especially useful for nodes that have limited bandwidth, and only want to pull down messages that match this given content topic.
Since all messages are relayed using the relay protocol regardless of content topic, you MAY use any content topic you wish without impacting how messages are relayed.
Content Topic Format
The format for content topics is as follows:
/{application-name}/{version-of-the-application}/{content-topic-name}/{encoding}
The name of a content topic is application-specific. As an example, here's the content topic used for an upcoming testnet:
/toychat/2/huilong/proto
Content Topic Naming Recommendations
Application names SHOULD be unique to avoid conflicting issues with other protocols.
Application version (if applicable) SHOULD be specified in the version field.
The {content-topic-name} portion of the content topic is up to the application,
and depends on the problem domain.
It can be hierarchical, for instance to separate content, or
to indicate different bandwidth and privacy guarantees.
The encoding field indicates the serialization/encoding scheme
for the WakuMessage payload field.
Content Topic usage guidelines
Applications SHOULD be mindful while designing/using content topics so that a bloat of content-topics does not happen. A content-topic bloat causes performance degradation in Store and Filter protocols while trying to retrieve messages.
Store queries have been noticed to be considerably slow (e.g doubling of response-time when content-topic count is increased from 10 to 100) when a lot of content-topics are involved in a single query. Similarly, a number of filter subscriptions increase, which increases complexity on client side to maintain and manage these subscriptions.
Applications SHOULD analyze the query/filter criteria for fetching messages from the network and select/design content topics to match such filter criteria. e.g: even though applications may want to segregate messages into different sets based on some application logic, if those sets of messages are always fetched/queried together from the network, then all those messages SHOULD use a single content-topic.
Differences with Waku v1
In 5/WAKU1 there is no actual routing. All messages are sent to all other nodes. This means that we are implicitly using the same pubsub topic that would be something like:
/waku/1/default-waku/rlp
Topics in Waku v1 correspond to Content Topics in Waku v2.
Bridging Waku v1 and Waku v2
To bridge Waku v1 and Waku v2 we have a 15/WAKU-BRIDGE. For mapping Waku v1 topics to Waku v2 content topics, the following structure for the content topic SHOULD be used:
/waku/1/<4bytes-waku-v1-topic>/rfc26
The <4bytes-waku-v1-topic> SHOULD be the lowercase hex representation
of the 4-byte Waku v1 topic.
A 0x prefix SHOULD be used.
/rfc26 indicates that the bridged content is encoded according to RFC 26/WAKU2-PAYLOAD.
See 15/WAKU-BRIDGE
for a description of the bridged fields.
This creates a direct mapping between the two protocols. For example:
/waku/1/0x007f80ff/rfc26
Copyright
Copyright and related rights waived via CC0.
References
- 10/WAKU2 spec
- 11/WAKU2-RELAY
- RELAY-SHARDING
- Ethereum 2 P2P spec
- 14/WAKU2-MESSAGE
- 12/WAKU2-FILTER
- 13/WAKU2-STORE
- 6/WAKU1
- 15/WAKU-BRIDGE
- 26/WAKU-PAYLOAD
27/WAKU2-PEERS
| Field | Value |
|---|---|
| Name | Waku v2 Client Peer Management Recommendations |
| Slug | 27 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-22 —
af7c413— update waku/informational/27/peers.md (#145) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-01-31 —
4b77d10— Update and rename README.md to peers.md - 2024-01-31 —
e65c359— Update README.md - 2024-01-31 —
4a78cac— Update README.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
7daec2f— Create README.md
27/WAKU2-PEERS describes a recommended minimal set of peer storage and
peer management features to be implemented by Waku v2 clients.
In this context, peer storage refers to a client's ability to keep track of discovered or statically-configured peers and their metadata. It also deals with matters of peer persistence, or the ability to store peer data on disk to resume state after a client restart.
Peer management is a closely related concept and refers to the set of actions a client MAY choose to perform based on its knowledge of its connected peers, e.g. triggering reconnects/disconnects, keeping certain connections alive, etc.
Peer store
The peer store SHOULD be an in-memory data structure where information about discovered or configured peers are stored. It SHOULD be considered the main source of truth for peer-related information in a Waku v2 client. Clients MAY choose to persist this store on-disk.
Tracked peer metadata
It is RECOMMENDED that a Waku v2 client tracks at least the following information about each of its peers in a peer store:
| Metadata | Description |
|---|---|
| Public key | The public key for this peer. This is related to the libp2p Peer ID. |
| Addresses | Known transport layer multiaddrs for this peer. |
| Protocols | The libp2p protocol IDs supported by this peer. This can be used to track the client's connectivity to peers supporting different Waku v2 protocols, e.g. 11/WAKU2-RELAY or 13/WAKU2-STORE. |
| Connectivity | Tracks the peer's current connectedness state. See Peer connectivity below. |
| Disconnect time | The timestamp at which this peer last disconnected. This becomes important when managing peer reconnections |
Peer connectivity
A Waku v2 client SHOULD track at least the following connectivity states for each of its peers:
NotConnected: The peer has been discovered or configured on this client, but no attempt has yet been made to connect to this peer. This is the default state for a new peer.CannotConnect: The client attempted to connect to this peer, but failed.CanConnect: The client was recently connected to this peer and disconnected gracefully.Connected: The client is actively connected to this peer.
This list does not preclude clients from tracking more advanced connectivity metadata,
such as a peer's blacklist status (see 18/WAKU2-SWAP).
Persistence
A Waku v2 client MAY choose to persist peers across restarts, using any offline storage technology, such as an on-disk database. Peer persistence MAY be used to resume peer connections after a client restart.
Peer management
Waku v2 clients will have different requirements when it comes to managing the peers tracked in the peer store. It is RECOMMENDED that clients support:
- automatic reconnection to peers under certain conditions
- connection keep-alive
Reconnecting peers
A Waku v2 client MAY choose to reconnect to previously connected, managed peers under certain conditions. Such conditions include, but are not limited to:
- Reconnecting to all
relay-capable peers after a client restart. This will require persistent peer storage.
If a client chooses to automatically reconnect to previous peers, it MUST respect the backing off period specified for GossipSub v1.1 before attempting to reconnect. This requires keeping track of the last time each peer was disconnected.
Connection keep-alive
A Waku v2 client MAY choose to implement a keep-alive mechanism to certain peers.
If a client chooses to implement keep-alive on a connection,
it SHOULD do so by sending periodic libp2p pings
as per 10/WAKU2 client recommendations.
The recommended period between pings SHOULD be at most 50%
of the shortest idle connection timeout for the specific client and transport.
For example, idle TCP connections often times out after 10 to 15 minutes.
Implementation note: the
nim-wakuclient currently implements a keep-alive mechanism every5 minutes, in response to a TCP connection timeout of10 minutes.
Copyright
Copyright and related rights waived via CC0.
References
Peer IDmultiaddrsprotocol IDs11/WAKU2-RELAY13/WAKU2-STORE18/WAKU2-SWAP- backing off period
- libp2p pings
10/WAKU2client recommendations
29/WAKU2-CONFIG
| Field | Value |
|---|---|
| Name | Waku v2 Client Parameter Configuration Recommendations |
| Slug | 29 |
| Status | draft |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-22 —
7408956— update waku/informational/29/config.md (#146) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-01-31 —
c506eac— Update and rename CONFIG.md to config.md - 2024-01-31 —
930f84d— Update and rename README.md to CONFIG.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
e6396b9— Create README.md
29/WAKU2-CONFIG describes the RECOMMENDED values
to assign to configurable parameters for Waku v2 clients.
Since Waku v2 is built on libp2p,
most of the parameters and reasonable defaults are derived from there.
Waku v2 relay messaging is specified in 11/WAKU2-RELAY,
a minor extension of the libp2p GossipSub protocol.
GossipSub behaviour is controlled by a series of adjustable parameters.
Waku v2 clients SHOULD configure these parameters to the recommended values below.
GossipSub v1.0 parameters
GossipSub v1.0 parameters are defined in the corresponding libp2p specification.
We repeat them here with RECOMMMENDED values for 11/WAKU2-RELAY implementations.
| Parameter | Purpose | RECOMMENDED value |
|---|---|---|
D | The desired outbound degree of the network | 6 |
D_low | Lower bound for outbound degree | 4 |
D_high | Upper bound for outbound degree | 8 |
D_lazy | (Optional) the outbound degree for gossip emission | D |
heartbeat_interval | Time between heartbeats | 1 second |
fanout_ttl | Time-to-live for each topic's fanout state | 60 seconds |
mcache_len | Number of history windows in message cache | 5 |
mcache_gossip | Number of history windows to use when emitting gossip | 3 |
seen_ttl | Expiry time for cache of seen message ids | 2 minutes |
GossipSub v1.1 parameters
GossipSub v1.1 extended GossipSub v1.0 and
introduced several new parameters.
We repeat the global parameters here
with RECOMMMENDED values for 11/WAKU2-RELAY implementations.
| Parameter | Description | RECOMMENDED value |
|---|---|---|
PruneBackoff | Time after pruning a mesh peer before we consider grafting them again. | 1 minute |
FloodPublish | Whether to enable flood publishing | true |
GossipFactor | % of peers to send gossip to, if we have more than D_lazy available | 0.25 |
D_score | Number of peers to retain by score when pruning from oversubscription | D_low |
D_out | Number of outbound connections to keep in the mesh. | D_low - 1 |
11/WAKU2-RELAY clients SHOULD implement a peer scoring mechanism
with the parameter constraints as
specified by libp2p.
Other configuration
The following behavioural parameters are not specified by libp2p,
but nevertheless describes constraints that 11/WAKU2-RELAY clients
MAY choose to implement.
| Parameter | Description | RECOMMENDED value |
|---|---|---|
BackoffSlackTime | Slack time to add to prune backoff before attempting to graft again | 2 seconds |
IWantPeerBudget | Maximum number of IWANT messages to accept from a peer within a heartbeat | 25 |
IHavePeerBudget | Maximum number of IHAVE messages to accept from a peer within a heartbeat | 10 |
IHaveMaxLength | Maximum number of messages to include in an IHAVE message | 5000 |
Copyright
Copyright and related rights waived via CC0.
References
- libp2p
- 11/WAKU2-RELAY
- libp2p GossipSub protocol
- corresponding libp2p specification
- several new parameters
30/ADAPTIVE-NODES
| Field | Value |
|---|---|
| Name | Adaptive nodes |
| Slug | 30 |
| Status | draft |
| Editor | Oskar Thorén [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-29 —
91c9679— update waku/informational/30/adaptive-nodes.md (#147) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-01-31 —
b35846a— Update and rename README.md to adaptive-nodes.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
04036ad— Create README.md
This is an informational spec that show cases the concept of adaptive nodes.
Node types - a continuum
We can look at node types as a continuum, from more restricted to less restricted, fewer resources to more resources.

Possible limitations
- Connectivity: Not publicly connectable vs static IP and DNS
- Connectivity: Mostly offline to mostly online to always online
- Resources: Storage, CPU, Memory, Bandwidth
Accessibility and motivation
Some examples:
- Opening browser window: costs nothing, but contribute nothing
- Desktop: download, leave in background, contribute somewhat
- Cluster: expensive, upkeep, but can contribute a lot
These are also illustrative, so a node in a browser in certain environment might contribute similarly to Desktop.
Adaptive nodes
We call these nodes adaptive nodes to highlights different modes of contributing, such as:
- Only leeching from the network
- Relaying messages for one or more topics
- Providing services for lighter nodes such as lightpush and filter
- Storing historical messages to various degrees
- Ensuring relay network can't be spammed with RLN
Planned incentives
Incentives to run a node is currently planned around:
- SWAP for accounting and settlement of services provided
- RLN RELAY for spam protection
- Other incentivization schemes are likely to follow and is an area of active research
Node protocol selection
Each node can choose which protocols to support, depending on its resources and goals.

Protocols like 11/WAKU2-RELAY, as well as [12], [13], [19], and [21], correspond to libp2p protocols.
However, other protocols like 16/WAKU2-RPC (local HTTP JSON-RPC), 25/LIBP2P-DNS-DISCOVERY, Discovery v5 (DevP2P) or interfacing with distributed storage, are running on different network stacks.
This is in addition to protocols that specify payloads, such as 14/WAKU2-MESSAGE, 26/WAKU2-PAYLOAD, or application specific ones. As well as specs that act more as recommendations, such as 23/WAKU2-TOPICS or 27/WAKU2-PEERS.
Waku network visualization
We can better visualize the network with some illustrative examples.
Topology and topics
This illustration shows an example topology with different PubSub topics for the relay protocol.

Legend
This illustration shows an example of content topics a node is interested in.

The dotted box shows what content topics (application-specific) a node is interested in.
A node that is purely providing a service to the network might not care.
In this example, we see support for toy chat, a topic in Waku v1 (Status chat), WalletConnect, and SuperRare community.
Auxiliary network
This is a separate component with its own topology.
Behavior and interaction with other protocols specified in Logos LIPs, e.g. 25/LIBP2P-DNS-DISCOVERY and 15/WAKU-BRIDGE.
Node Cross Section
This one shows a cross-section of nodes in different dimensions and shows how the connections look different for different protocols.

Copyright
Copyright and related rights waived via CC0.
References
Deprecated LIPs
Deprecated specifications are no longer used in Waku products. This subdirectory is for achrive purpose and should not be used in production ready implementations. Visit Waku LIPs for new Waku specifications under discussion.
5/WAKU0
| Field | Value |
|---|---|
| Name | Waku v0 |
| Slug | 5 |
| Status | deprecated |
| Editor | Oskar Thorén [email protected] |
| Contributors | Adam Babik [email protected], Andrea Maria Piana [email protected], Dean Eigenmann [email protected], Kim De Mey [email protected] |
Timeline
- 2026-01-30 —
d5a9240— chore: removed archived (#283) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-01-31 —
9770963— Rename WAKU0.md to waku0.md - 2024-01-31 —
ac8fe6d— Rename waku/rfc/deprecated/5/WAKU0.md to waku/deprecated/5/WAKU0.md - 2024-01-27 —
61f7641— Create WAKU0.md
This specification describes the format of Waku messages within the ÐΞVp2p Wire Protocol. This spec substitutes EIP-627. Waku is a fork of the original Whisper protocol that enables better usability for resource restricted devices, such as mostly-offline bandwidth-constrained smartphones. It does this through (a) light node support, (b) historic messages (with a mailserver) (c) expressing topic interest for better bandwidth usage and (d) basic rate limiting.
Motivation
Waku was created to incrementally improve in areas that Whisper is lacking in, with special attention to resource restricted devices. We specify the standard for Waku messages in order to ensure forward compatibility of different Waku clients, backwards compatibility with Whisper clients, as well as to allow multiple implementations of Waku and its capabilities. We also modify the language to be more unambiguous, concise and consistent.
Definitions
| Term | Definition |
|---|---|
| Light node | A Waku node that does not forward any messages. |
| Envelope | Messages sent and received by Waku nodes. |
| Node | Some process that is able to communicate for Waku. |
Underlying Transports and Prerequisites
Use of DevP2P
For nodes to communicate, they MUST implement devp2p and run RLPx. They MUST have some way of connecting to other nodes. Node discovery is largely out of scope for this spec, but see the appendix for some suggestions on how to do this.
Gossip based routing
In Whisper, messages are gossiped between peers. Whisper is a form of rumor-mongering protocol that works by flooding to its connected peers based on some factors. Messages are eligible for retransmission until their TTL expires. A node SHOULD relay messages to all connected nodes if an envelope matches their PoW and bloom filter settings. If a node works in light mode, it MAY choose not to forward envelopes. A node MUST NOT send expired envelopes, unless the envelopes are sent as a mailserver response. A node SHOULD NOT send a message to a peer that it has already sent before.
Wire Specification
Use of RLPx transport protocol
All Waku messages are sent as devp2p RLPx transport protocol, version 51 packets. These packets MUST be RLP-encoded arrays of data containing two objects: packet code followed by another object (whose type depends on the packet code). See informal RLP spec and the Ethereum Yellow Paper, appendix B for more details on RLP.
Waku is a RLPx subprotocol called waku with version 0.
The version number corresponds to the major version in the header spec.
Minor versions should not break compatibility of waku,
this would result in a new major.
(Some exceptions to this apply in the Draft stage
of where client implementation is rapidly change).
ABNF specification
Using Augmented Backus-Naur form (ABNF) we have the following format:
; Packet codes 0 - 127 are reserved for Waku protocol
packet-code = 1*3DIGIT
; rate limits
limit-ip = 1*DIGIT
limit-peerid = 1*DIGIT
limit-topic = 1*DIGIT
rate-limits = "[" limit-ip limit-peerid limit-topic "]"
pow-requirement-key = 48
bloom-filter-key = 49
light-node-key = 50
confirmations-enabled-key = 51
rate-limits-key = 52
topic-interest-key = 53
status-options = "["
[ pow-requirement-key pow-requirement ]
[ bloom-filter-key bloom-filter ]
[ light-node-key light-node ]
[ confirmations-enabled-key confirmations-enabled ]
[ rate-limits-key rate-limits ]
[ topic-interest-key topic-interest ]
"]"
status = "[" version status-options "]"
status-update = status-options
; version is "an integer (as specified in RLP)"
version = DIGIT
confirmations-enabled = BIT
light-node = BIT
; pow is "a single floating point value of PoW.
; This value is the IEEE 754 binary representation
; of a 64-bit floating point number.
; Values of qNAN, sNAN, INF and -INF are not allowed.
; Negative values are also not allowed."
pow = 1*DIGIT "." 1*DIGIT
pow-requirement = pow
; bloom filter is "a byte array"
bloom-filter = *OCTET
waku-envelope = "[" expiry ttl topic data nonce "]"
; List of topics interested in
topic-interest = "[" *10000topic "]"
; 4 bytes (UNIX time in seconds)
expiry = 4OCTET
; 4 bytes (time-to-live in seconds)
ttl = 4OCTET
; 4 bytes of arbitrary data
topic = 4OCTET
; byte array of arbitrary size
; (contains encrypted message)
data = OCTET
; 8 bytes of arbitrary data
; (used for PoW calculation)
nonce = 8OCTET
messages = 1*waku-envelope
; mail server / client specific
p2p-request = waku-envelope
p2p-message = 1*waku-envelope
; packet-format needs to be paired with its
; corresponding packet-format
packet-format = "[" packet-code packet-format "]"
required-packet = 0 status /
1 messages /
22 status-update /
optional-packet = 126 p2p-request / 127 p2p-message
packet = "[" required-packet [ optional-packet ] "]"
All primitive types are RLP encoded. Note that, per RLP specification,
integers are encoded starting from 0x00.
Packet Codes
The message codes reserved for Waku protocol: 0 - 127.
Messages with unknown codes MUST be ignored without generating any error, for forward compatibility of future versions.
The Waku sub-protocol MUST support the following packet codes:
| Name | Int Value |
|---|---|
| Status | 0 |
| Messages | 1 |
| Status Update | 22 |
The following message codes are optional, but they are reserved for specific purpose.
| Name | Int Value | Comment |
|---|---|---|
| Batch Ack | 11 | |
| Message Response | 12 | |
| P2P Request | 126 | |
| P2P Message | 127 |
Packet usage
Status
The Status message serves as a Waku handshake and peers MUST exchange this message upon connection. It MUST be sent after the RLPx handshake and prior to any other Waku messages.
A Waku node MUST await the Status message from a peer before engaging in other Waku protocol activity with that peer. When a node does not receive the Status message from a peer, before a configurable timeout, it SHOULD disconnect from that peer.
Upon retrieval of the Status message, the node SHOULD validate the message received and validated the Status message. Note that its peer might not be in the same state.
When a node is receiving other Waku messages from a peer before a Status message is received, the node MUST ignore these messages and SHOULD disconnect from that peer. Status messages received after the handshake is completed MUST also be ignored.
The status message MUST contain an association list containing various options. All options within this association list are OPTIONAL, ordering of the key-value pairs is not guaranteed and therefore MUST NOT be relied on. Unknown keys in the association list SHOULD be ignored.
Messages
This packet is used for sending the standard Waku envelopes.
Status Update
The Status Update message is used to communicate an update of the settings of the node. The format is the same as the Status message, all fields are optional. If none of the options are specified the message MUST be ignored and considered a noop. Fields that are omitted are considered unchanged, fields that haven't changed SHOULD not be transmitted.
PoW Requirement update
When PoW is updated, peers MUST NOT deliver the sender envelopes with PoW lower than specified in this message.
PoW is defined as average number of iterations, required to find the current BestBit (the number of leading zero bits in the hash), divided by message size and TTL:
PoW = (2**BestBit) / (size * TTL)
PoW calculation:
#![allow(unused)] fn main() { fn short_rlp(envelope) = rlp of envelope, excluding env_nonce field. fn pow_hash(envelope, env_nonce) = sha3(short_rlp(envelope) ++ env_nonce) fn pow(pow_hash, size, ttl) = 2**leading_zeros(pow_hash) / (size * ttl) }
where size is the size of the RLP-encoded envelope,
excluding env_nonce field (size of short_rlp(envelope)).
Bloom filter update
The bloom filter is used to identify a number of topics to a peer without compromising (too much) privacy over precisely what topics are of interest. Precise control over the information content (and thus efficiency of the filter) may be maintained through the addition of bits.
Blooms are formed by the bitwise OR operation on a number of bloomed topics. The bloom function takes the topic and projects them onto a 512-bit slice. At most, three bits are marked for each bloomed topic.
The projection function is defined as a mapping from a 4-byte slice S to a 512-bit slice D; for ease of explanation, S will dereference to bytes, whereas D will dereference to bits.
LET D[*] = 0
FOREACH i IN { 0, 1, 2 } DO
LET n = S[i]
IF S[3] & (2 ** i) THEN n += 256
D[n] = 1
END FOR
A full bloom filter (all the bits set to 1)
means that the node is to be considered a Full Node and it will accept any topic.
If both Topic Interest and bloom filter are specified, Topic Interest always takes precedence and bloom filter MUST be ignored.
If only bloom filter is specified, the current Topic Interest MUST be discarded and only the updated bloom filter MUST be used when forwarding or posting envelopes.
A bloom filter with all bits set to 0 signals that the node is not currently interested in receiving any envelope.
Topic Interest update
This packet is used by Waku nodes for sharing their interest in messages with specific topics. It does this in a more bandwidth considerate way, at the expense of some metadata protection. Peers MUST only send envelopes with specified topics.
It is currently bounded to a maximum of 10000 topics. If you are interested in more topics than that, this is currently underspecified and likely requires updating it. The constant is subject to change.
If only Topic Interest is specified, the current bloom filter MUST be discarded and only the updated Topic Interest MUST be used when forwarding or posting envelopes.
An empty array signals that the node is not currently interested in receiving any envelope.
Rate Limits update
This packet is used for informing other nodes of their self defined rate limits.
In order to provide basic Denial-of-Service attack protection, each node SHOULD define its own rate limits. The rate limits SHOULD be applied on IPs, peer IDs, and envelope topics.
Each node MAY decide to whitelist, i.e. do not rate limit, selected IPs or peer IDs.
If a peer exceeds node's rate limits, the connection between them MAY be dropped.
Each node SHOULD broadcast its rate limits to its peers using the rate limits packet. The rate limits MAY also be sent as an optional parameter in the handshake.
Each node SHOULD respect rate limits advertised by its peers. The number of packets SHOULD be throttled in order not to exceed peer's rate limits. If the limit gets exceeded, the connection MAY be dropped by the peer.
Message Confirmations update
Message confirmations tell a node that a message originating from it has been received by its peers, allowing a node to know whether a message has or has not been received.
A node MAY send a message confirmation for any batch of messages received with a packet Messages Code.
A message confirmation is sent using Batch Acknowledge packet or Message Response packet. The Batch Acknowledge packet is followed by a keccak256 hash of the envelopes batch data.
The current version of the message response is 1.
Using Augmented Backus-Naur form (ABNF) we have the following format:
; a version of the Message Response
version = 1*DIGIT
; keccak256 hash of the envelopes batch data (raw bytes) for which the confirmation is sent
hash = *OCTET
hasherror = *OCTET
; error code
code = 1*DIGIT
; a descriptive error message
description = *ALPHA
error = "[" hasherror code description "]"
errors = *error
response = "[" hash errors "]"
confirmation = "[" version response "]"
The supported codes:
1: means time sync error which happens when an envelope is too old or
created in the future (the root cause is no time sync between nodes).
The drawback of sending message confirmations is that it increases the noise in the network because for each sent message, a corresponding confirmation is broadcast by one or more peers.
P2P Request
This packet is used for sending Dapp-level peer-to-peer requests, e.g. Waku Mail Client requesting old messages from the Waku Mail Server.
P2P Message
This packet is used for sending the peer-to-peer messages, which are not supposed to be forwarded any further. E.g. it might be used by the Waku Mail Server for delivery of old (expired) messages, which is otherwise not allowed.
Payload Encryption
Asymmetric encryption uses the standard Elliptic Curve Integrated Encryption Scheme with SECP-256k1 public key.
Symmetric encryption uses AES GCM algorithm with random 96-bit nonce.
Packet code Rationale
Packet codes 0x00 and 0x01 are already used in all Waku / Whisper versions.
Packet code 0x02 and 0x03 were previously used in Whisper but
are deprecated as of Waku v0.4
Packet code 0x22 is used to dynamically change the settings of a node.
Packet codes 0x7E and 0x7F may be used to implement Waku Mail Server and Client.
Without P2P messages it would be impossible to deliver the old messages,
since they will be recognized as expired,
and the peer will be disconnected for violating the Whisper protocol.
They might be useful for other purposes
when it is not possible to spend time on PoW,
e.g. if a stock exchange will want to provide live feed about the latest trades.
Additional capabilities
Waku supports multiple capabilities. These include light node, rate limiting and bridging of traffic. Here we list these capabilities, how they are identified, what properties they have and what invariants they must maintain.
Additionally there is the capability of a mailserver which is documented in its on specification.
Light node
The rationale for light nodes is to allow for interaction with waku on resource restricted devices as bandwidth can often be an issue.
Light nodes MUST NOT forward any incoming messages, they MUST only send their own messages. When light nodes happen to connect to each other, they SHOULD disconnect. As this would result in messages being dropped between the two.
Light nodes are identified by the light_node value in the status message.
Accounting for resources (experimental)
Nodes MAY implement accounting, keeping track of resource usage. It is heavily inspired by Swarm's SWAP protocol, and works by doing pairwise accounting for resources.
Each node keeps track of resource usage with all other nodes. Whenever an envelope is received from a node that is expected (fits bloom filter or topic interest, is legal, etc) this is tracked.
Every epoch (say, every minute or every time an event happens) statistics SHOULD be aggregated and saved by the client:
| peer | sent | received |
|---|---|---|
| peer1 | 0 | 123 |
| peer2 | 10 | 40 |
In later versions this will be amended by nodes communication thresholds, settlements and disconnect logic.
Upgradability and Compatibility
General principles and policy
These are policies that guide how we make decisions when it comes to upgradability, compatibility, and extensibility:
-
Waku aims to be compatible with previous and future versions.
-
In cases where we want to break this compatibility, we do so gracefully and as a single decision point.
-
To achieve this, we employ the following two general strategies:
- a) Accretion (including protocol negotiation) over changing data
- b) When we want to change things, we give it a new name (for example, a version number).
Examples:
- We enable bridging between
shh/6andwaku/0until such a time as when we are ready to gracefully drop support forshh/6(1, 2, 3). - When we add parameter fields, we (currently) do so by accreting them in a list, so old clients can ignore new fields (dynamic list) and new clients can use new capabilities (1, 3).
- To better support (2) and (3) in the future, we will likely release a new version that gives better support for open, growable maps (association lists or native map type) (3)
- When we we want to provide a new set of messages that have different requirements, we do so under a new protocol version and employ protocol versioning. This is a form of accretion at a level above - it ensures a client can support both protocols at once and drop support for legacy versions gracefully. (1,2,3)
Backwards Compatibility
Waku is a different subprotocol from Whisper so it isn't directly compatible. However, the data format is the same, so compatibility can be achieved by the use of a bridging mode as described below. Any client which does not implement certain packet codes should gracefully ignore the packets with those codes. This will ensure the forward compatibility.
Waku-Whisper bridging
waku/0 and shh/6 are different DevP2P subprotocols,
however they share the same data format making their envelopes compatible.
This means we can bridge the protocols naively, this works as follows.
Roles:
- Waku client A, only Waku capability
- Whisper client B, only Whisper capability
- WakuWhisper bridge C, both Waku and Whisper capability
Flow:
- A posts message; B posts message.
- C picks up message from A and B and relays them both to Waku and Whisper.
- A receives message on Waku; B on Whisper.
Note: This flow means if another bridge C1 is active, we might get duplicate relaying for a message between C1 and C2. I.e. Whisper(<>Waku<>Whisper)<>Waku, A-C1-C2-B. Theoretically this bridging chain can get as long as TTL permits.
Forward Compatibility
It is desirable to have a strategy for maintaining forward compatibility
between waku/0 and future version of waku.
Here we outline some concerns and strategy for this.
-
Connecting to nodes with multiple versions: The way this SHOULD be accomplished in the future is by negotiating the versions of subprotocols, within the
hellomessage nodes transmit their capabilities along with a version. As suggested in EIP-8, if a node connects that has a higher version number for a specific capability, the node with a lower number SHOULD assume backwards compatibility. The node with the higher version will decide if compatibility can be assured between versions, if this is not the case it MUST disconnect. -
Adding new packet codes: New packet codes can be added easily due to the available packet codes. Unknown packet codes SHOULD be ignored. Upgrades that add new packet codes SHOULD implement some fallback mechanism if no response was received for nodes that do not yet understand this packet.
-
Adding new options in
status-options: New options can be added to thestatus-optionsassociation list in thestatusandstatus-updatepacket as options are OPTIONAL and unknown option keys SHOULD be ignored. A node SHOULD NOT disconnect from a peer when receivingstatus-optionswith unknown option keys.
Appendix A: Security considerations
There are several security considerations to take into account when running Waku. Chief among them are: scalability, DDoS-resistance and privacy. These also vary depending on what capabilities are used. The security considerations for extra capabilities such as mailservers can be found in their respective specifications.
Scalability and UX
Bandwidth usage:
In version 0 of Waku, bandwidth usage is likely to be an issue. For more investigation into this, see the theoretical scaling model described here.
Gossip-based routing:
Use of gossip-based routing doesn't necessarily scale. It means each node can see a message multiple times, and having too many light nodes can cause propagation probability that is too low. See Whisper vs PSS for more and a possible Kademlia based alternative.
Lack of incentives:
Waku currently lacks incentives to run nodes, which means node operators are more likely to create centralized choke points.
Privacy
Light node privacy:
The main privacy concern with light nodes is that directly connected peers will know that a message originates from them (as it are the only ones it sends). This means nodes can make assumptions about what messages (topics) their peers are interested in.
Bloom filter privacy:
By having a bloom filter where only the topics you are interested in are set, you reveal which messages you are interested in. This is a fundamental tradeoff between bandwidth usage and privacy, though the tradeoff space is likely suboptimal in terms of the Anonymity trilemma.
Privacy guarantees not rigorous:
Privacy for Whisper / Waku haven't been studied rigorously for various threat models like global passive adversary, local active attacker, etc. This is unlike e.g. Tor and mixnets.
Topic hygiene:
Similar to bloom filter privacy, if you use a very specific topic you reveal more information. See scalability model linked above.
Spam resistance
PoW bad for heterogeneous devices:
Proof of work is a poor spam prevention mechanism. A mobile device can only have a very low PoW in order not to use too much CPU / burn up its phone battery. This means someone can spin up a powerful node and overwhelm the network.
Censorship resistance
Devp2p TCP port blockable:
By default Devp2p runs on port 30303,
which is not commonly used for any other service.
This means it is easy to censor, e.g. airport WiFi.
This can be mitigated somewhat by running on e.g. port 80 or 443,
but there are still outstanding issues.
See libp2p and Tor's Pluggable Transport for how this can be improved.
Appendix B: Implementation Notes
Implementation Matrix
Recommendations for clients
Notes useful for implementing Waku mode.
- Avoid duplicate envelopes:
To avoid duplicate envelopes, only connect to one Waku node. Benign duplicate envelopes is an intrinsic property of Whisper which often leads to a N factor increase in traffic, where N is the number of peers you are connected to.
- Topic specific recommendations -
Consider partition topics based on some usage, to avoid too much traffic on a single topic.
Node discovery
Resource restricted devices SHOULD use EIP-1459 to discover nodes.
Known static nodes MAY also be used.
Changelog
Version 0.6
Released April 21,2020
- Mark spec as Deprecated mode in terms of its lifecycle.
Version 0.5
Released March 17,2020
- Clarify the preferred way of handling unknown keys
in the
status-optionsassociation list. - Correct spec/implementation mismatch: Change RLP keys to be the their int values in order to reflect production behavior
Version 0.4
Released February 21, 2020.
- Simplify implementation matrix with latest state
- Introduces a new required packet code Status Code (
0x22) for communicating option changes - Deprecates the following packet codes:
PoW Requirement (
0x02), Bloom Filter (0x03), Rate limits (0x20), Topic interest (0x21) - all superseded by the new Status Code (0x22) - Increased
topic-interestcapacity from 1000 to 10000
Version 0.3
Released February 13, 2020.
- Recommend DNS based node discovery over other Discovery methods.
- Mark spec as Draft mode in terms of its lifecycle.
- Simplify Changelog and misc formatting.
- Handshake/Status message not compatible with shh/6 nodes; specifying options as association list.
- Include topic-interest in Status handshake.
- Upgradability policy.
topic-interestpacket code.
Version 0.2
Released December 10, 2019.
- General style improvements.
- Fix ABNF grammar.
- Mailserver requesting/receiving.
- New packet codes: topic-interest (experimental), rate limits (experimental).
- More details on handshake modifications.
- Accounting for resources mode (experimental)
- Appendix with security considerations: scalability and UX, privacy, and spam resistance.
- Appendix with implementation notes and implementation matrix across various clients with breakdown per capability.
- More details on handshake and parameters.
- Describe rate limits in more detail.
- More details on mailserver and mail client API.
- Accounting for resources mode (very experimental).
- Clarify differences with Whisper.
Version 0.1
Initial version. Released November 21, 2019.
Differences between shh/6 and waku/0
Summary of main differences between this spec and Whisper v6, as described in EIP-627:
- RLPx subprotocol is changed from
shh/6towaku/0. - Light node capability is added.
- Optional rate limiting is added.
- Status packet has following additional parameters: light-node, confirmations-enabled and rate-limits
- Mail Server and Mail Client functionality is now part of the specification.
- P2P Message packet contains a list of envelopes instead of a single envelope.
Copyright
Copyright and related rights waived via CC0.
Footnotes
Felix Lange et al. The RLPx Transport Protocol. Ethereum.
16/WAKU2-RPC
| Field | Value |
|---|---|
| Name | Waku v2 RPC API |
| Slug | 16 |
| Status | deprecated |
| Editor | Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-04-16 —
8b552ba— chore: mark 16/WAKU2-RPC as deprecated (#30) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
87b56de— Update and rename RPC.md to rpc.md - 2024-01-27 —
9042acf— Rename README.md to RPC.md - 2024-01-27 —
eef961b— remove rfs folder - 2024-01-25 —
8a53f24— Create README.md
Introduction
This specification describes the JSON-RPC API that Waku v2 nodes MAY adhere to. Refer to the Waku v2 specification for more information on Waku v2.
Wire Protocol
Transport
Nodes SHOULD expose an accessible
JSON-RPC API.
The JSON-RPC version SHOULD be 2.0. Below is an example request:
{
"jsonrpc":"2.0",
"method":"get_waku_v2_debug_info",
"params":[],
"id":1
}
Fields
| Field | Description |
|---|---|
jsonrpc | Contains the used JSON-RPC version (Default: 2.0) |
method | Contains the JSON-RPC method that is being called |
params | An array of parameters for the request |
id | The request ID |
Types
In this specification, the primitive types Boolean, String,
Number and Null, as well as the structured types Array and Object,
are to be interpreted according to the JSON-RPC specification.
It also adopts the same capitalisation conventions.
The following structured types are defined for use throughout the document:
WakuMessage
Refer to Waku Message specification for more information.
WakuMessage is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
payload | String | mandatory | The message payload as a base64 (with padding) encoded data string |
contentTopic | String | optional | Message content topic for optional content-based filtering |
version | Number | optional | Message version. Used to indicate type of payload encryption. Default version is 0 (no payload encryption). |
timestamp | Number | optional | The time at which the message is generated by its sender. This field holds the Unix epoch time in nanoseconds as a 64-bits integer value. |
ephemeral | Boolean | optional | This flag indicates the transient nature of the message. Indicates if the message is eligible to be stored by the store protocol, 13/WAKU2-STORE. |
Method naming
The JSON-RPC methods in this document are designed to be mappable to HTTP REST endpoints.
Method names follow the pattern <method_type>_waku_<protocol_version>_<api>_<api_version>_<resource>
<method_type>: prefix of the HTTP method type that most closely matches the JSON-RPC function. Supportedmethod_typevalues areget,post,put,deleteorpatch.<protocol_version>: Waku version. Currently v2.<api>: one of the listed APIs below, e.g.store,debug, orrelay.<api_version>: API definition version. Currently v1 for all APIs.<resource>: the resource or resource path being addressed
The method post_waku_v2_relay_v1_message, for example,
would map to the HTTP REST endpoint POST /waku/v2/relay/v1/message.
Debug API
Types
The following structured types are defined for use on the Debug API:
WakuInfo
WakuInfo is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
listenAddresses | Array[String] | mandatory | Listening addresses of the node |
enrUri | String | optional | ENR URI of the node |
get_waku_v2_debug_v1_info
The get_waku_v2_debug_v1_info method retrieves information about a Waku v2 node
Parameters
none
Response
WakuInfo- information about a Waku v2 node
get_waku_v2_debug_v1_version
The get_waku_v2_debug_v1_version method retrieves the version of a Waku v2 node
as a string.
The version SHOULD follow semantic versioning.
In case the node's current build is based on a git commit between semantic versions,
the retrieved version string MAY contain the git commit hash alone or
in combination with the latest semantic version.
Parameters
none
Response
string- represents the version of a Waku v2 node
Relay API
Refer to the Waku Relay specification for more information on the relaying of messages.
post_waku_v2_relay_v1_message
The post_waku_v2_relay_v1_message method publishes a message to be relayed on a
PubSub topic
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic being published on |
message | WakuMessage | mandatory | The message being relayed |
Response
Bool-trueon success or an error on failure.
post_waku_v2_relay_v1_subscriptions
The post_waku_v2_relay_v1_subscriptions method subscribes a node to an array of
PubSub topics.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topics | Array[String] | mandatory | The PubSub topics being subscribed to |
Response
Bool-trueon success or an error on failure.
delete_waku_v2_relay_v1_subscriptions
The delete_waku_v2_relay_v1_subscriptions method unsubscribes a node from an array
of PubSub topics.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topics | Array[String] | mandatory | The PubSub topics being unsubscribed from |
Response
Bool-trueon success or an error on failure.
get_waku_v2_relay_v1_messages
The get_waku_v2_relay_v1_messages method returns a list of messages
that were received on a subscribed
PubSub topic
after the last time this method was called.
The server MUST respond with an error
if no subscription exists for the polled topic.
If no message has yet been received on the polled topic,
the server SHOULD return an empty list.
This method can be used to poll a topic for new messages.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic to poll for the latest messages |
Response
Array[WakuMessage] - the latestmessageson the polledtopicor an error on failure.
Relay Private API
The Private API provides functionality to encrypt/decrypt WakuMessage payloads
using either symmetric or asymmetric cryptography.
This allows backwards compatibility with Waku v1 nodes.
It is the API client's responsibility to keep track of the keys
used for encrypted communication.
Since keys must be cached by the client and
provided to the node to encrypt/decrypt payloads,
a Private API SHOULD NOT be exposed on non-local or untrusted nodes.
Types
The following structured types are defined for use on the Private API:
KeyPair
KeyPair is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
privateKey | String | mandatory | Private key as hex encoded data string |
publicKey | String | mandatory | Public key as hex encoded data string |
get_waku_v2_private_v1_symmetric_key
Generates and returns a symmetric key that can be used for message encryption and decryption.
Parameters
none
Response
String- A new symmetric key as hex encoded data string
get_waku_v2_private_v1_asymmetric_keypair
Generates and returns a public/private key pair that can be used for asymmetric message encryption and decryption.
Parameters
none
Response
KeyPair- A new public/private key pair as hex encoded data strings
post_waku_v2_private_v1_symmetric_message
The post_waku_v2_private_v1_symmetric_message method publishes a message
to be relayed on a PubSub topic.
Before being relayed, the message payload is encrypted using the supplied symmetric key. The client MUST provide a symmetric key.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic being published on |
message | WakuMessage | mandatory | The (unencrypted) message being relayed |
symkey | String | mandatory | The hex encoded symmetric key to use for payload encryption. This field MUST be included if symmetric key cryptography is selected |
Response
Bool-trueon success or an error on failure.
post_waku_v2_private_v1_asymmetric_message
The post_waku_v2_private_v1_asymmetric_message method publishes a message
to be relayed on a PubSub topic.
Before being relayed, the message payload is encrypted using the supplied public key. The client MUST provide a public key.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic being published on |
message | WakuMessage | mandatory | The (unencrypted) message being relayed |
publicKey | String | mandatory | The hex encoded public key to use for payload encryption. This field MUST be included if asymmetric key cryptography is selected |
Response
Bool-trueon success or an error on failure.
get_waku_v2_private_v1_symmetric_messages
The get_waku_v2_private_v1_symmetric_messages method decrypts and
returns a list of messages that were received on a subscribed
PubSub topic
after the last time this method was called.
The server MUST respond with an error
if no subscription exists for the polled topic.
If no message has yet been received on the polled topic,
the server SHOULD return an empty list.
This method can be used to poll a topic for new messages.
Before returning the messages, the server decrypts the message payloads using the supplied symmetric key. The client MUST provide a symmetric key.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic to poll for the latest messages |
symkey | String | mandatory | The hex encoded symmetric key to use for payload decryption. This field MUST be included if symmetric key cryptography is selected |
Response
Array[WakuMessage] - the latestmessageson the polledtopicor an error on failure.
get_waku_v2_private_v1_asymmetric_messages
The get_waku_v2_private_v1_asymmetric_messages method decrypts and
returns a list of messages that were received on a subscribed PubSub topic
after the last time this method was called.
The server MUST respond with an error
if no subscription exists for the polled topic.
If no message has yet been received on the polled topic,
the server SHOULD return an empty list.
This method can be used to poll a topic for new messages.
Before returning the messages, the server decrypts the message payloads using the supplied private key. The client MUST provide a private key.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
topic | String | mandatory | The PubSub topic to poll for the latest messages |
privateKey | String | mandatory | The hex encoded private key to use for payload decryption. This field MUST be included if asymmetric key cryptography is selected |
Response
Array[WakuMessage] - the latestmessageson the polledtopicor an error on failure.
Store API
Refer to the Waku Store specification for more information on message history retrieval.
The following structured types are defined for use on the Store API:
StoreResponse
StoreResponse is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
messages | Array[WakuMessage] | mandatory | Array of retrieved historical messages |
pagingOptions | PagingOptions | conditional | Paging information from which to resume further historical queries |
PagingOptions
pagingOptions is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
pageSize | Number | mandatory | Number of messages to retrieve per page |
cursor | Index | optional | Message Index from which to perform pagination. If not included and forward is set to true, paging will be performed from the beginning of the list. If not included and forward is set to false, paging will be performed from the end of the list. |
forward | Bool | mandatory | true if paging forward, false if paging backward |
Index
Index is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
digest | String | mandatory | A hash for the message at this Index |
receivedTime | Number | mandatory | UNIX timestamp in nanoseconds at which the message at this Index was received |
ContentFilter
ContentFilter is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
contentTopic | String | mandatory | The content topic of a WakuMessage |
get_waku_v2_store_v1_messages
The get_waku_v2_store_v1_messages method retrieves historical messages
on specific content topics.
This method MAY be called with PagingOptions,
to retrieve historical messages on a per-page basis.
If the request included PagingOptions,
the node MUST return messages on a per-page basis and
include PagingOptions in the response.
These PagingOptions MUST contain a cursor pointing
to the Index from which a new page can be requested.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
pubsubTopic | String | optional | The pubsub topic on which a WakuMessage is published |
contentFilters | Array[ContentFilter] | optional | Array of content filters to query for historical messages |
startTime | Number | optional | The inclusive lower bound on the timestamp of queried WakuMessages. This field holds the Unix epoch time in nanoseconds as a 64-bits integer value. |
endTime | Number | optional | The inclusive upper bound on the timestamp of queried WakuMessages. This field holds the Unix epoch time in nanoseconds as a 64-bits integer value. |
pagingOptions | PagingOptions | optional | Pagination information |
Response
StoreResponse- the response to aqueryfor historical messages.
Filter API
Refer to the Waku Filter specification for more information on content filtering.
Types
The following structured types are defined for use on the Filter API:
ContentFilter
ContentFilter is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
contentTopic | String | mandatory | message content topic |
post_waku_v2_filter_v1_subscription
The post_waku_v2_filter_v1_subscription method creates a subscription in a
light node for messages that matches a content filter
and, optionally, a PubSub topic.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
contentFilters | Array[ContentFilter] | mandatory | Array of content filters being subscribed to |
topic | String | optional | Message topic |
Response
Bool-trueon success or an error on failure.
delete_waku_v2_filter_v1_subscription
The delete_waku_v2_filter_v1_subscription method removes subscriptions
in a light node matching a content filter and,
optionally, a PubSub topic.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
contentFilters | Array[ContentFilter] | mandatory | Array of content filters being unsubscribed from |
topic | String | optional | Message topic |
Response
Bool-trueon success or an error on failure.
get_waku_v2_filter_v1_messages
The get_waku_v2_filter_v1_messages method returns a list of messages
that were received on a subscribed content topic
after the last time this method was called.
The server MUST respond with an
error
if no subscription exists for the polled content topic.
If no message has yet been received on the polled content topic,
the server SHOULD respond with an empty list.
This method can be used to poll a content topic for new messages.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
contentTopic | String | mandatory | The content topic to poll for the latest messages |
Response
Array[WakuMessage] - the latestmessageson the polled contenttopicor an error on failure.
Admin API
The Admin API provides privileged accesses to the internal operations of a Waku v2 node.
The following structured types are defined for use on the Admin API:
WakuPeer
WakuPeer is an Object containing the following fields:
| Field | Type | Inclusion | Description |
|---|---|---|---|
multiaddr | String | mandatory | Multiaddress containing this peer's location and identity |
protocol | String | mandatory | Protocol that this peer is registered for |
connected | bool | mandatory | true if peer has active connection for this protocol, false if not |
get_waku_v2_admin_v1_peers
The get_waku_v2_admin_v1_peers method returns an array of peers
registered on this node.
Since a Waku v2 node may open either continuous or ad hoc connections,
depending on the negotiated protocol,
these peers may have different connected states.
The same peer MAY appear twice in the returned array,
if it is registered for more than one protocol.
Parameters
- none
Response
Array[WakuPeer] - Array of peers registered on this node
post_waku_v2_admin_v1_peers
The post_waku_v2_admin_v1_peers method connects a node to a list of peers.
Parameters
| Field | Type | Inclusion | Description |
|---|---|---|---|
peers | Array[String] | mandatory | Array of peer multiaddrs to connect to. Each multiaddr must contain the location and identity addresses of a peer. |
Response
Bool-trueon success or an error on failure.
Example usage
Store API
get_waku_v2_store_v1_messages
This method is part of the store API and
the specific resources to retrieve are (historical) messages.
The protocol (waku) is on v2, whereas the Store API definition is on v1.
1.get all the historical messages for content topic
"/waku/2/default-content/proto"; no paging required
Request
curl -d '{"jsonrpc":"2.0","id":"id","method":"get_waku_v2_store_v1_messages", "params":["", [{"contentTopic":"/waku/2/default-content/proto"}]]}' --header "Content-Type: application/json" http://localhost:8545
{
"jsonrpc": "2.0",
"id": "id",
"method": "get_waku_v2_store_v1_messages",
"params": [
"",
[
{"contentTopic": "/waku/2/default-content/proto"}
]
]
}
Response
{
"jsonrpc": "2.0",
"id": "id",
"result": {
"messages": [
{
"payload": dGVzdDE,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
},
{
"payload": dGVzdDI,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
},
{
"payload": dGVzdDM,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
}
],
"pagingInfo": null
},
"error": null
}
2.get a single page of historical messages for content topic "/waku/2/default-content/proto";
2 messages per page, backward direction.
Since this is the initial query, no cursor is provided,
so paging will be performed from the end of the list.
Request
curl -d '{"jsonrpc":"2.0","id":"id","method":"get_waku_v2_store_v1_messages", "params":[ "", [{"contentTopic":"/waku/2/default-content/proto"}],{"pageSize":2,"forward":false}]}' --header "Content-Type: application/json" http://localhost:8545
{
"jsonrpc": "2.0",
"id": "id",
"method": "get_waku_v2_store_v1_messages",
"params": [
"",
[
{"contentTopic": "/waku/2/default-content/proto"}
],
{
"pageSize": 2,
"forward": false
}
]
}
Response
{
"jsonrpc": "2.0",
"id": "id",
"result": {
"messages": [
{
"payload": dGVzdDI,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
},
{
"payload": dGVzdDM,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
}
],
"pagingInfo": {
"pageSize": 2,
"cursor": {
"digest": "abcdef",
"receivedTime": 1605887187000000000
},
"forward": false
}
},
"error": null
}
3.get the next page of historical messages for content topic "/waku/2/default-content/proto",
using the cursor received above; 2 messages per page, backward direction.
Request
curl -d '{"jsonrpc":"2.0","id":"id","method":"get_waku_v2_store_v1_messages", "params":[ "", [{"contentTopic":"/waku/2/default-content/proto"}],{"pageSize":2,"cursor":{"digest":"abcdef","receivedTime":1605887187000000000},"forward":false}]}' --header "Content-Type: application/json" http://localhost:8545
{
"jsonrpc": "2.0",
"id": "id",
"method": "get_waku_v2_store_v1_messages",
"params": [
"",
[
{"contentTopic": "/waku/2/default-content/proto"}
],
{
"pageSize": 2,
"cursor": {
"digest": "abcdef",
"receivedTime": 1605887187000000000
},
"forward": false
}
]
}
Response
{
"jsonrpc": "2.0",
"id": "id",
"result": {
"messages": [
{
"payload": dGVzdDE,
"contentTopic": "/waku/2/default-content/proto",
"version": 0
},
],
"pagingInfo": {
"pageSize": 2,
"cursor": {
"digest": "123abc",
"receivedTime": 1605866187000000000
},
"forward": false
}
},
"error": null
}
Copyright
Copyright and related rights waived via CC0.
References
- JSON-RPC specification
- LibP2P Addressing
- LibP2P PubSub specification - topic descriptor
- Waku v2 specification
- IETF RFC 4648 - The Base16, Base32, and Base64 Data Encodings
18/WAKU2-SWAP
| Field | Value |
|---|---|
| Name | Waku SWAP Accounting |
| Slug | 18 |
| Status | deprecated |
| Editor | Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-04-18 —
8f94e97— docs: deprecate swap protocol (#31) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-01-31 —
0d8ad08— Update and rename SWAP.md to swap.md - 2024-01-31 —
3c8410c— Create SWAP.md
Abstract
This specification outlines how we do accounting and settlement based on the provision and usage of resources, most immediately bandwidth usage and/or storing and retrieving of Waku message. This enables nodes to cooperate and efficiently share resources, and in the case of unequal nodes to settle the difference through a relaxed payment mechanism in the form of sending cheques.
Protocol identifier*: /vac/waku/swap/2.0.0-beta1
Motivation
The Waku network makes up a service network, and some nodes provide a useful service to other nodes. We want to account for that, and when imbalances arise, settle this. The core of this approach has some theoretical backing in game theory, and variants of it have practically been proven to work in systems such as Bittorrent. The specific model use was developed by the Swarm project (previously part of Ethereum), and we re-use contracts that were written for this purpose.
By using a delayed payment mechanism in the form of cheques, a barter-like mechanism can arise, and nodes can decide on their own policy as opposed to be strictly tied to a specific payment scheme. Additionally, this delayed settlement eases requirements on the underlying network in terms of transaction speed or costs.
Theoretically, nodes providing and using resources over a long, indefinite, period of time can be seen as an iterated form of Prisoner's Dilemma (PD). Specifically, and more intuitively, since we have a cost and benefit profile for each provision/usage (of Waku Message's, e.g.), and the pricing can be set such that mutual cooperation is incentivized, this can be analyzed as a form of donations game.
Game Theory - Iterated prisoner's dilemma / donation game
What follows is a sketch of what the game looks like between two nodes.
We can look at it as a special case of iterated prisoner's dilemma called a
Donation game
where each node can cooperate with some benefit b at a personal cost c,
where b>c.
From A's point of view:
| A/B | Cooperate | Defect |
|---|---|---|
| Cooperate | b-c | -c |
| Defect | b | 0 |
What this means is that if A and B cooperates,
A gets some benefit b minus a cost c.
If A cooperates and B defects she only gets the cost,
and if she defects and B cooperates A only gets the benefit.
If both defect they get neither benefit nor cost.
The generalized form of PD is:
| A/B | Cooperate | Defect |
|---|---|---|
| Cooperate | R | S |
| Defect | T | P |
With R=reward, S=Sucker's payoff, T=temptation, P=punishment
And the following holds:
T>R>P>S2R>T+S
In our case, this means b>b-c>0>-c and 2(b-c)> b-c which is trivially true.
As this is an iterated game with no clear finishing point in most circumstances, a tit-for-tat strategy is simple, elegant and functional. To be more theoretically precise, this also requires reasonable assumptions on error rate and discount parameter. This captures notions such as "does the perceived action reflect the intended action" and "how much do you value future (uncertain) actions compared to previous actions". See Axelrod - Evolution of Cooperation (book) for more details. In specific circumstances, nodes can choose slightly different policies if there's a strong need for it. A policy is simply how a node chooses to act given a set of circumstances.
A tit-for-tat strategy basically means:
- cooperate first (perform service/beneficial action to other node)
- defect when node stops cooperating (disconnect and similar actions), i.e. when it stops performing according to set parameters re settlement
- resume cooperation if other node does so
This can be complemented with node selection mechanisms.
SWAP protocol overview
We use SWAP for accounting and settlement in conjunction with other request/reply protocols in Waku v2, where accounting is done in a pairwise manner. It is an acronym with several possible meanings (as defined in the Book of Swarm), for example:
- service wanted and provided
- settle with automated payments
- send waiver as payment
- start without a penny
This approach is based on communicating payment thresholds and sending cheques as indications of later payments. Communicating payment thresholds MAY be done out-of-band or as part of the handshake. Sending cheques is done once payment threshold is hit.
See Book of Swarm section 3.2. on Peer-to-peer accounting etc., for more context and details.
Accounting
Nodes perform their own accounting for each relevant peer
based on some "volume"/bandwidth metric.
For now we take this to mean the number of WakuMessages exchanged.
Additionally, a price is attached to each unit. Currently, this is simply a "karma counter" and equal to 1 per message.
Each accounting balance SHOULD be w.r.t. to a given protocol it is accounting for.
NOTE: This may later be complemented with other metrics, either as part of SWAP or more likely outside of it. For example, online time can be communicated and attested to as a form of enhanced quality of service to inform peer selection.
Flow
Assuming we have two store nodes, one operating mostly as a client (A) and another as server (B).
- Node A performs a handshake with B node. B node responds and both nodes communicate their payment threshold.
- Node A and B creates an accounting entry for the other peer, keep track of peer and current balance.
- Node A issues a
HistoryRequest, and B responds with aHistoryResponse. Based on the number of WakuMessages in the response, both nodes update their accounting records. - When payment threshold is reached, Node A sends over a cheque to reach a neutral balance. Settlement of this is currently out of scope, but would occur through a SWAP contract (to be specified). (mock and hard phase).
- If disconnect threshold is reached, Node B disconnects Node A (mock and hard phase).
Note that not all of these steps are mandatory in initial stages, see below for more details. For example, the payment threshold MAY initially be set out of bounds, and policy is only activated in the mock and hard phase.
Protobufs
We use protobuf to specify the handshake and signature. This current protobuf is a work in progress. This is needed for mock and hard phase.
A handshake gives initial information about payment thresholds and possibly other information. A cheque is best thought of as a promise to pay at a later date.
message Handshake {
bytes payment_threshold = 1;
}
// TODO Signature?
// Should probably be over the whole Cheque type
message Cheque {
bytes beneficiary = 1;
// TODO epoch time or block time?
uint32 date = 2;
// TODO ERC20 extension?
// For now karma counter
uint32 amount = 3;
}
Incremental integration and roll-out
To incrementally integrate this into Waku v2, we have divided up the roll-out into three phases:
- Soft - accounting only
- Mock - send mock cheques and take word for it
- Hard Test - blockchain integration and deployed to public testnet (Goerli, Optimism testnet or similar)
- Hard Main - deployed to a public mainnet
An implementation MAY support any of these phases.
Soft phase
In the soft phase only accounting is performed, without consequence for the peers. No disconnect or sending of cheques is performed at this tage.
SWAP protocol is performed in conjunction with another request-reply protocol to account for its usage. It SHOULD be done for 13/WAKU2-STORE and it MAY be done for other request/reply protocols.
A client SHOULD log accounting state per peer and SHOULD indicate when a peer is out of bounds (either of its thresholds met).
Mock phase
In the mock phase, we send mock cheques and send cheques/disconnect peers as appropriate.
- If a node reaches a disconnect threshold, which MUST be outside the payment threshold, it SHOULD disconnect the other peer.
- If a node is within payment balance, the other node SHOULD stay connected to it.
- If a node receives a valid Cheque it SHOULD update its internal accounting records.
- If any node behaves badly, the other node is free to disconnect and
pick another node.
- Peer rating is out of scope of this specification.
Hard phase
In the hard phase, in addition to sending cheques and activating policy, this is done with blockchain integration on a public testnet. More details TBD.
This also includes settlements where cheques can be redeemed.
Copyright
Copyright and related rights waived via CC0.
References
21/WAKU2-FAULT-TOLERANT-STORE
| Field | Value |
|---|---|
| Name | Waku v2 Fault-Tolerant Store |
| Slug | 21 |
| Status | deleted |
| Editor | Sanaz Taheri [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-11-04 —
cb4d0de— Update 21/WAKU2-FAULT-TOLERANT-STORE: Deleted (#181) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-01-31 —
5da8a11— Update and rename FAULT-TOLERANT-STORE.md to fault-tolerant-store.md - 2024-01-27 —
206133e— Create FAULT-TOLERANT-STORE.md
The reliability of 13/WAKU2-STORE protocol heavily relies on the fact that full nodes i.e., those who persist messages have high availability and uptime and do not miss any messages. If a node goes offline, then it will risk missing all the messages transmitted in the network during that time. In this specification, we provide a method that makes the store protocol resilient in presence of faulty nodes. Relying on this method, nodes that have been offline for a time window will be able to fix the gap in their message history when getting back online. Moreover, nodes with lower availability and uptime can leverage this method to reliably provide the store protocol services as a full node.
Method description
As the first step towards making the 13/WAKU2-STORE protocol fault-tolerant, we introduce a new type of time-based query through which nodes fetch message history from each other based on their desired time window. This method operates based on the assumption that the querying node knows some other nodes in the store protocol which have been online for that targeted time window.
Security Consideration
The main security consideration to take into account while using this method is that a querying node has to reveal its offline time to the queried node. This will gradually result in the extraction of the node's activity pattern which can lead to inference attacks.
Wire Specification
We extend the HistoryQuery protobuf message
with two fields of start_time and end_time to signify the time range to be queried.
Payloads
syntax = "proto3";
message HistoryQuery {
// the first field is reserved for future use
string pubsubtopic = 2;
repeated ContentFilter contentFilters = 3;
PagingInfo pagingInfo = 4;
+ sint64 start_time = 5;
+ sint64 end_time = 6;
}
HistoryQuery
RPC call to query historical messages.
start_time: this field MAY be filled out to signify the starting point of the queried time window. This field holds the Unix epoch time in nanoseconds.
Themessagesfield of the correspondingHistoryResponseMUST contain historical waku messages whosetimestampis larger than or equal to thestart_time.end_time: this field MAY be filled out to signify the ending point of the queried time window. This field holds the Unix epoch time in nanoseconds. Themessagesfield of the correspondingHistoryResponseMUST contain historical waku messages whosetimestampis less than or equal to theend_time.
A time-based query is considered valid if
its end_time is larger than or equal to the start_time.
Queries that do not adhere to this condition will not get through e.g.
an open-end time query in which the start_time is given but
no end_time is supplied is not valid.
If both start_time and
end_time are omitted then no time-window filter takes place.
In order to account for nodes asynchrony, and
assuming that nodes may be out of sync for at most 20 seconds
(i.e., 20000000000 nanoseconds),
the querying nodes SHOULD add an offset of 20 seconds to their offline time window.
That is if the original window is [l,r]
then the history query SHOULD be made for [start_time: l - 20s, end_time: r + 20s].
Note that HistoryQuery preserves AND operation among the queried attributes.
As such, the messages field of the corresponding
HistoryResponse
MUST contain historical waku messages that satisfy the indicated pubsubtopic AND
contentFilters AND the time range [start_time, end_time].
Copyright
Copyright and related rights waived via CC0.
References
Blockchain LIPs
Logos Blockchain is building a secure, flexible, and scalable infrastructure for developers creating applications for the network state. Published Specifications are currently available here, Blockchain Specifications.
Blockchain Raw Specifications
Early-stage Blockchain specifications that have not yet progressed beyond raw status.
BEDROCK-ANONYMOUS-LEADERS-REWARD
| Field | Value |
|---|---|
| Name | Bedrock Anonymous Leaders Reward Protocol |
| Slug | 85 |
| Status | raw |
| Category | Standards Track |
| Editor | Thomas Lavaur [email protected] |
| Contributors | David Rusu [email protected], Mehmet Gonen [email protected], Álvaro Castro-Castilla [email protected], Frederico Teixeira [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the mechanism for anonymous reward distribution based on voucher commitments, nullifiers, and zero-knowledge (ZK) proofs. Block leaders can claim their rewards without linking them to specific blocks and without revealing their identities. The protocol removes any direct link between block production and the recipient of the reward, preventing self-censorship behaviors.
Keywords: anonymous rewards, voucher commitment, nullifier, zero-knowledge proof, leader reward, Merkle tree, block production, self-censorship
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Voucher | A one-time random secret used to claim a block reward anonymously. |
| Voucher Commitment | A cryptographic commitment (zkhash) to a voucher secret. |
| Voucher Nullifier | A unique identifier derived from a voucher, prevents double claims. |
| Leader Claim Operation | A Mantle Operation allowing a leader to claim their reward. |
| Reward Voucher Set | A Merkle tree containing all voucher commitments. |
| Voucher Nullifier Set | A searchable database of nullifiers for claimed vouchers. |
| Anonymity Set | The set of unclaimed vouchers from which a claim could originate. |
Background
In many blockchain designs, leaders receive rewards for producing valid blocks. Traditionally, this reward is linked directly to the block or its producer, potentially opening the door to manipulation or self-censorship, where leaders may avoid including certain transactions or messages out of fear of retaliation or reputational harm. As Nomos protects its nodes and ensures that they do not need to engage in self-censorship, the reward mechanism preserves the anonymity of block leaders while maintaining correctness and preventing double rewards.
Design Overview
The protocol introduces a concept of vouchers to unlink the block reward claim from the block itself. Instead of directly crediting themselves in the block, leaders include a commitment (a zkhash in this protocol) to a secret voucher. These commitments are gathered into a Merkle tree. In the first block of an epoch, all vouchers from the previous epoch are added to the voucher Merkle tree, accumulating the vouchers together in a set and guaranteeing a minimal anonymity set. Leaders MAY anonymously claim their reward using a ZK proof later, proving the ownership of their voucher.
┌──────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Leader block │───▶│ Reward voucher │───▶│ Wait until next │
└──────────────┘ └─────────────────┘ │ epoch │
└──────────┬──────────┘
│
▼
┌──────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Reward │◀───│ Claim with │◀───│ Voucher added to │
│ │ │ ZK proof │ │ Merkle tree │
└──────────────┘ └─────────────────┘ └─────────────────────┘
By anonymizing the identity of block leaders at the time of reward claiming, the protocol removes any direct link between block production and the recipient of the reward. This is essential to prevent self-censorship behaviors. With anonymous claiming, leaders are free to act honestly according to protocol rules without concern for external consequences, thus improving the overall neutrality and robustness of the network.
Key properties of the protocol:
- Anonymity: Block rewards are unlinkable to the blocks they originate from (avoiding deanonymization).
- Soundness: No reward can be claimed twice.
In parallel, the blockchain maintains the value leaders_rewards
accumulating the rewards for leaders over time.
Each voucher included in the Merkle tree represents the same share of leaders_rewards.
Just like for voucher inclusion,
more rewards are added to this variable on an epoch-by-epoch basis,
which guarantees a stable and equal claimable reward for leaders over an epoch.
Protocol Specification
Voucher Creation and Inclusion
When producing a block, a leader performs the following:
-
Generate a one-time random secret $voucher \xleftarrow{$} \mathbb{F}_{p}$.
-
Compute the commitment:
voucher_cm := zkHash(b"LEAD_VOUCHER_CM_V1", voucher). -
Include the
voucher_cmin the block header.
Each voucher_cm is added to a Merkle tree of voucher commitments by validators
during the execution of the first block of the following epoch,
maintained throughout the entire blockchain history by everyone.
Claiming the Reward
Each leader MAY submit a Leader Claim Operation to claim their reward. This Operation includes:
- The Merkle root of the global voucher set when the Mantle Transaction containing the claim is submitted.
- A Proof of Claim.
This Operation increases the balance of a Mantle Transaction by the leader reward amount, letting the leader move the funds as desired through the Ledger transaction or another Operation.
Note: This means that a leader MAY use their funds directly, getting their reward and using them atomically.
Every leader receives a reward that is independent of the block content to avoid de-anonymization. This means that the fees of the block cannot be collected by the leader directly, or need to be pooled for all the leaders.
Leaders Reward Calculation
At the start of epoch N+1,
validators aggregate the leaders rewards of epoch N into the leader rewards variable.
The amount of the reward claimable with a voucher
corresponds to a share of the leaders_rewards.
This share is exactly equal to the total value of rewards
divided by the size of the anonymity set of leaders, that is:
$$ share = \begin{cases} 0 & \textbf{if } |voucher_cm| = |voucher_nf| \ \frac{leader_rewards}{|voucher_cm| - |voucher_nf|} & \textbf{if } |voucher_cm| \neq |voucher_nf| \end{cases} $$
This amount is stable through an epoch because when a leader withdraws, both the pool value and the number of unclaimed vouchers decrease proportionally, so the price per share remains unchanged. However, the share value will vary across epochs if the leader rewards are variable.
LEADER_CLAIM Validation
Nodes validate a LEADER_CLAIM Operation by:
-
Verifying the ZK proof.
-
Checking that
voucher_nfis not already in the voucher nullifier set. -
Executing the reward logic:
- Add the
voucher_nfto the voucher nullifier set to prevent claiming the same reward more than once. - Increase the balance of the Mantle Transaction by the share amount.
- Decrease the value of the
leaders_rewardsby the same amount.
- Add the
Design Details
Unlinking Block Rewards from Proposals
Each reward voucher is a cryptographic commitment derived from a voucher secret. This commitment, when included in the block header, reveals no information about the block producer's identity or the actual secret voucher. It is computationally infeasible to reverse the commitment to retrieve the voucher secret.
Crucially, when the leader reward is claimed and the voucher nullifier revealed, a third party cannot link this nullifier to the initial voucher commitment. A reward is claimable if its reward voucher is in the reward voucher set and its voucher nullifier is not in the voucher nullifier set.
The reward voucher set is maintained as a Merkle tree of depth 32, and validators are required to hold the frontier of the MMR in memory to continue appending to the set. The voucher nullifier set is maintained as a searchable database.
ZK Proof of Membership
When claiming a reward, the leader provides a ZK proof that they know a leaf in the global Merkle tree of reward vouchers and the preimage of that leaf. Crucially, the ZK proof does not reveal which leaf is being proven. The verifier only learns that some valid leaf exists in the tree for which the prover knows the secret voucher. This property ensures that the claim cannot be linked to any specific block header or reward voucher commitment.
Preventing Double Claims Without Breaking Privacy
To prevent double claiming, the leader derives a voucher nullifier. This nullifier is unique to the voucher but reveals nothing about the original reward voucher or block. It acts as a one-way identifier that allows nodes to track whether a voucher has already been claimed, without compromising the anonymity of the claim.
Security Considerations
Anonymity Guarantees
The protocol provides anonymity through the following mechanisms:
- Voucher commitments reveal no information about the block producer's identity.
- ZK proofs do not reveal which leaf in the Merkle tree is being claimed.
- Nullifiers cannot be linked back to voucher commitments.
The anonymity set size is determined by the number of unclaimed vouchers. Implementations SHOULD ensure a sufficient anonymity set size before allowing claims to prevent timing-based deanonymization attacks.
Double Claim Prevention
The nullifier mechanism ensures that each voucher can only be claimed once.
Nodes MUST verify that a nullifier is not in the voucher nullifier set
before accepting a LEADER_CLAIM Operation.
Reward Independence
Leaders receive a reward independent of block content to prevent correlation attacks based on block fees or transaction patterns.
References
Normative
- BEDROCK-MANTLE-SPECIFICATION - Mantle Transaction and Operation specification
Informative
- Anonymous Leaders Reward Protocol - Original specification document
Copyright
Copyright and related rights waived via CC0.
BEDROCK-ARCHITECTURE-OVERVIEW
| Field | Value |
|---|---|
| Name | Bedrock Architecture Overview |
| Slug | 146 |
| Status | raw |
| Category | Informational |
| Editor | David Rusu [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Daniel Kashepava [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
0ef87b1— New RFC: CODEX-MANIFEST (#191) - 2026-01-30 —
5c123d6— Nomos/raw/bedrock architecture overview raw (#257)
Abstract
Bedrock enables high-performance Sovereign Rollups to leverage the security guarantees of Nomos. Sovereign Rollups build on Nomos through Bedrock Mantle, Bedrock's minimal execution layer which in turn runs on Cryptarchia, the Nomos consensus protocol. Taken together, Bedrock provides a private, highly scalable, and resilient substrate for high-performance decentralized applications.
Keywords: Bedrock, Sovereign Rollups, Mantle, Cryptarchia, channels, NomosDA, Blend Network, consensus, privacy
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Bedrock | The foundational layer of Nomos composed of Cryptarchia and Bedrock Mantle. |
| Mantle | The minimal execution layer of Nomos for Sovereign Rollups. |
| Cryptarchia | The Nomos consensus protocol, a Private Proof of Stake (PPoS) protocol. |
| Sovereign Rollup | A virtual chain overlaid on top of the Nomos blockchain. |
| Channel | A permissioned, ordered log of messages forming a virtual chain. |
| Inscription | A channel message stored permanently in the ledger. |
| Blob | A channel message with only a commitment stored on-chain; data stored in NomosDA. |
| NomosDA | The Data Availability layer providing temporary storage for Blob data. |
| Blend Network | A privacy-preserving network layer for routing block proposals. |
Background
Bedrock is composed of Cryptarchia and Bedrock Mantle. Bedrock is in turn supported by the Bedrock Services: Blend Network and NomosDA. Together they provide an interface for building high-performance Sovereign Rollups that leverage the security and resilience of Nomos.
┌─────────────┐ ┌──────────────────────┐ ┌─────────────────────────────────────────┐
│ Clients │ │ Sovereign Zone │ │ Nomos Blockchain │
├─────────────┤ ├──────────────────────┤ ├───────────────────┬─────────────────────┤
│ │ │ │ │ Bedrock │ Services │
│ Alice ──┼──>│ DeFi Exchange SZ │ │ ┌─────────┐ │ ┌───────────────┐ │
│ │ │ │ │ │ Mantle │<───┼──│ Blend Network │ │
│ Bob ──┼──>│ Land Registry SZ │──>│ └────┬────┘ │ └───────────────┘ │
│ │ │ │ │ │ │ │
│ Charlie ──┼──>│ Prediction Market SZ │ │ v │ │
│ │ │ │ │ ┌─────────────┐ │ │
└─────────────┘ └──────────────────────┘ │ │ Cryptarchia │ │ │
│ └──────┬──────┘ │ │
└─────────┼─────────┴─────────────────────┘
│
┌────────────┼────────────┐
│ │ │
v v v
┌────────┐ ┌────────┐ ┌────────┐
│ Node_1 │ │ Node_2 │ │ Node_3 │
└────────┘ └────────┘ └────────┘
Bedrock Mantle
Mantle forms the minimal execution layer of Nomos. Mantle Transactions consist of a sequence of Operations together with a Ledger Transaction used for paying fees and transferring funds.
┌─────────────────────────────────┐
│ Mantle Transaction │
├─────────────────────────────────┤
│ Operations │
│ ┌───────────────────────────┐ │
│ │ CHANNEL_DEPOSIT(...) │ │
│ ├───────────────────────────┤ │
│ │ CHANNEL_INSCRIBE(...) │ │
│ ├───────────────────────────┤ │
│ │ ... │ │
│ └───────────────────────────┘ │
├─────────────────────────────────┤
│ Ledger Transaction │
└─────────────────────────────────┘
Sovereign Rollups make use of Mantle Transactions when posting their updates to Nomos. This is done through the use of Mantle Channels and Channel Operations.
Mantle Channels
Mantle Channels are lightweight virtual chains overlaid on top of the Nomos blockchain. Sovereign Rollups are built on top of these channels, allowing them to outsource the hard parts of running a decentralized service to Nomos, namely ordering and replicating state updates.
Channels are permissioned, ordered logs of messages. These messages are signed by the Channel owner and come in two types: Inscriptions or Blobs. Inscriptions store the message data permanently in-ledger, while Blobs store only a commitment to the message data permanently. The actual message data is stored temporarily in NomosDA, just long enough for interested parties to fetch a copy for themselves.
┌───────────┐ ┌──────────────────┐ ┌───────────┐
│ Channel A │ │ Nomos Blockchain │ │ Channel B │
├───────────┤ ├──────────────────┤ ├───────────┤
│ A_3 │ │ Block_6 <─┼───│ B_4 │
│ │ │ │ │ │ │ │ │
│ v │ │ v │ │ v │
│ A_2 │ │ Block_5 │ │ B_3 │
│ │ │ │ │ │ │ │ │
│ v │──>│ v │ │ v │
│ A_1 │ │ Block_4 <─┼───│ B_2 │
│ │ │ │ │ │ │ │
│ │ │ v │ │ v │
│ │ │ Block_3 │ │ B_1 │
│ │ │ │ │ │ │
│ │ │ v │ │ │
│ │ │ Block_2 │ │ │
│ │ │ │ │ │ │
│ │ │ v │ │ │
│ │ │ Block_1 │ │ │
└───────────┘ └──────────────────┘ └───────────┘
Channels A and B form virtual chains on top of the Nomos blockchain. Channel messages are included in blocks on the Nomos blockchain in such a way that they respect the ordering of channel messages. For example, $B_4$ MUST come after $B_3$ in the Nomos blockchain.
Transient Blobs
The fact that Blobs are stored only temporarily in NomosDA allows Nomos to provide cheap, temporary storage for Sovereign Rollups without incurring long-term scalability concerns. The network can serve a large amount of data without the risk of bloating with obsolete data after years of operations.
At the same time, the transient nature of Blobs shifts the burden of long-term replication from the Nomos Network to the parties interested in that Blob data— that is, the Sovereign Rollup operators, their clients, and other interested parties (archival nodes, block explorers, etc.). So long as at least one party holds a copy of a Blob and is willing to provide it to the network, the Sovereign Rollup can continue to be verified by checking provided Blobs against their corresponding on-chain Blob commitments, which are stored permanently on the Nomos blockchain.
Cryptarchia
Bedrock Mantle is powered by Cryptarchia, a highly scalable, permissionless consensus protocol optimized for privacy and resilience. Cryptarchia is a Private Proof of Stake (PPoS) consensus protocol with properties very similar to Bitcoin. Just like in Bitcoin, where a miner's hashing power is not revealed when they win a block, Cryptarchia ensures privacy for block proposers by breaking the link between a proposal and its proposer. Unlike Bitcoin, Nomos extends block proposer confidentiality to the network layer by routing proposals through the Blend Network, making network analysis attacks prohibitively expensive.
Sovereign Rollups
Sovereign Rollups bridge the gap between traditional server-based applications and decentralized, permissionless applications.
Sovereign Rollups alleviate the contention caused by decentralized applications competing for the limited resources of a single-threaded VM (e.g., EVM in Ethereum) while still remaining auditable and fault tolerant. This is achieved through shifting transaction ordering and execution off of the main chain into Sovereign Rollup nodes, with Sovereign Rollup nodes posting only a state diff or batch of transactions to Nomos as an opaque data Blob.
Transaction Flow
┌─────────────┐ ┌─────────────────────────────────────────────┐ ┌──────────────────┐
│ Clients │ │ Sovereign Zone │ │ Nomos Blockchain │
├─────────────┤ ├──────────────┬──────────────────────────────┤ ├──────────────────┤
│ │ │ Sequencer │ Inscription │ │ │
│ Alice ──┼──>│ │ ┌──────────────────────┐ │ │ │
│ │ │ Orders Txs │ │ tx_Alice │ │ │ │
│ Bob ──┼──>│ │ │ ├──────────────────────┤ │ │ Bedrock Mantle │
│ │ │ v │ │ tx_Bob │──>│──>│ │
│ Charlie ──┼──>│ Bundle Txs │ ├──────────────────────┤ │ │ │
│ │ │ │ │ tx_Charlie │ │ │ │
│ │ │ │ └──────────────────────┘ │ │ │
└─────────────┘ └──────────────┴──────────────────────────────┘ └──────────────────┘
The following sequence describes the flow of transactions from clients through a Sovereign Rollup to finality on Nomos:
-
Clients submit transactions to the Sovereign Rollup.
-
The Sovereign Rollup orders, executes, and bundles the transactions into a Blob.
-
The Sovereign Rollup submits the Blob Mantle Transaction along with DA Shares to NomosDA.
-
NomosDA begins replicating the Blob and forwards the Blob Mantle Transaction to the Nomos Mempool.
-
A leader includes the transaction in the next block via Cryptarchia.
-
NomosDA observes the Blob inclusion on-chain (Blob confirmed).
-
The client observes their transaction in a Sovereign Rollup Blob included in Nomos (weak confirmation).
-
The block finalizes after being buried by 2160 blocks.
-
The client observes the Sovereign Rollup Blob finalized (finality).
sequenceDiagram
participant C as Clients
participant SZ as Sovereign Zone
box Nomos Blockchain
participant Mempool as Nomos Mempool
participant Cryptarchia
end
C->>SZ: Alice's Tx
C->>SZ: Bob's Tx
C->>SZ: Charlie's Tx
SZ-->>SZ: Order, Execute and Bundle Txs into a Blob
SZ->>Mempool: Blob Mantle Transaction
Mempool->>Cryptarchia: Leader includes transaction in next block
Cryptarchia-->>Cryptarchia: Block finalizes after being buried by 2160 blocks
Cryptarchia->>C: Client observes the SR Blob finalized (finality)
Architecture Benefits
Sovereign Rollups form a virtual chain overlaid on top of the Nomos blockchain. This architecture allows application developers to easily spin up high-performance applications while taking advantage of the security of Nomos to distribute the application state widely for auditing and resilience purposes.
References
Normative
- NOMOS-CRYPTARCHIA-V1-PROTOCOL - Cryptarchia consensus protocol specification
- BEDROCK-V1.1-MANTLE-SPECIFICATION - Mantle Transaction and Operation specification
Informative
- Bedrock Architecture Overview - Original architecture overview document
Copyright
Copyright and related rights waived via CC0.
BEDROCK-GENESIS-BLOCK
| Field | Value |
|---|---|
| Name | Bedrock Genesis Block Specification |
| Slug | 90 |
| Status | raw |
| Category | Standards Track |
| Editor | David Rusu [email protected] |
| Contributors | Hong-Sheng Zhou, Thomas Lavaur [email protected], Marcin Pawlowski [email protected], Mehmet Gonen [email protected], Álvaro Castro-Castilla [email protected], Daniel Sanchez Quiros [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Genesis Block for the Bedrock chain, including the initial bedrock service providers, NMO token distribution, and protocol parameters. The Genesis Block is the root of trust for all subsequent protocol operations and must be constructed in a way that is deterministic, verifiable, and robust against long-range or bootstrap attacks.
Keywords: genesis block, token distribution, epoch nonce, service providers, Cryptarchia initialization, ledger state
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Genesis Block | The first block in the Bedrock chain establishing the initial state. |
| NMO | The native token of the Nomos network. |
| Epoch Nonce | The source of randomness for the Cryptarchia lottery. |
| Service Provider | A node participating in DA or Blend network services. |
| Ledger Transaction | A transaction that modifies the token ledger state. |
| Mantle Transaction | A transaction containing operations and a ledger transaction. |
Background
The block body is a single Mantle Transaction
containing a Ledger Transaction distributing the notes to initial token holders.
The bedrock services are initialized through SDP_DECLARE Operations
embedded in the Mantle Transaction's Operations list
and protocol initializing constants are encoded through a CHANNEL_INSCRIBE Operation
also embedded in the Operations list.
Not all protocol constants are encoded in the Genesis block. The principle used to decide whether a value should be in the Genesis block or not is whether it is a value that is derived from blockchain activity or whether it is updated through a protocol update (hard / soft fork). For example, the epoch nonce is updated through normal blockchain Operations and therefore it should be specified in the Genesis block. Gas constants are only changed through protocol updates and hard forks and therefore they will be hardcoded in the node implementation.
Genesis Block Data Structure
The Genesis Block is composed of the Genesis Block Header and the Genesis Mantle Transaction (there is a single transaction in the genesis block). The Mantle Transaction contains all information necessary for initializing Bedrock Services and Cryptarchia state, as well as distributing the initial tokens to stakeholders.
Initial Token Distribution
Initial tokens will be distributed through a Ledger Transaction containing zero inputs and one output note for each initial stakeholder. Note that since the Ledger is transparent, the initial stake allocation is visible to everyone. Those wishing to hide their initial stake may opt to subdivide their note into a few different notes of equal value.
In order to participate in the Cryptarchia lottery, stakeholders must generate their note keys in accordance with the Proof of Leadership protocol specified at Proof of Leadership Specification - Protocol.
The initial state of the Ledger will be derived through normal execution of this Ledger Transaction, that is, each output's note ID will be added to the unspent notes set.
Initial Token Distribution Example
STAKE_DISTRIBUTION_TX = LedgerTx(
inputs=[],
outputs=[
Note(value=1000, public_key=STAKE_HOLDER_0_PK),
Note(value=2000, public_key=STAKE_HOLDER_1_PK),
Note(value=1500, public_key=STAKE_HOLDER_2_PK),
# ...
]
)
Initial Service Declarations
Data Availability (DA) and Blend Network MUST initialize their set of providers.
This is done through a set of SDP_DECLARE Operations
in the Genesis Mantle Transaction.
Both Blend and DA enforce a minimal network size for the service to be active. Thus, in order to have active Blend and DA services at Genesis, there MUST be at least as many declarations for each service in the Genesis block to meet each service's minimal network size:
Initial Service Declarations Example
DA_DECLARATIONS = [
Declaration(
msg=DeclarationMessage(
ServiceType.DA,
["ip://1.1.1.1:3000"],
PROVIDER_ID_0,
ZK_ID_0
),
locked_note_id=STAKE_DISTRIBUTION_TX.output_note_id(0)
),
# ... 40 total declarations
]
BLEND_DECLARATIONS = [
Declaration(
msg=DeclarationMessage(
ServiceType.BLEND,
["ip://1.1.1.1:3000"],
PROVIDER_ID_0,
ZK_ID_0
),
locked_note_id=STAKE_DISTRIBUTION_TX.output_note_id(0)
),
# ... 32 total declarations
]
SERVICE_DECLARATIONS = DA_DECLARATIONS + BLEND_DECLARATIONS
Cryptarchia Parameters
Cryptarchia is initialized with the following parameters:
-
genesis_time: ISO 8601 encoded timestamp.Cryptarchia uses slots as a measure of time offset from some start time. This timestamp must be agreed upon by all nodes in order to have a common clock.
-
chain_id: string.It is useful to differentiate testnets from mainnet. To avoid confusion, the chain ID is placed in the Genesis block to guarantee that the networks are disjoint.
-
genesis_epoch_nonce: 32 bytes, hex encoded.The initial source of randomness for the Cryptarchia lottery. The process for selecting this value is described in detail at Epoch Nonce Ceremony.
These parameters are encoded in the Genesis block as an inscription sent to the null channel.
Cryptarchia Parameters Example
CRYPTARCHIA_PARAMS = {
"chain_id": "nomos-mainnet",
"genesis_time": "2026-01-05T19:20:35Z",
"genesis_epoch_nonce": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
}
CRYPTARCHIA_INSCRIPTION = Inscribe(
channel=bytes(32),
inscription=json.dumps(CRYPTARCHIA_PARAMS).encode("utf-8"),
parent=bytes(32),
signer=Ed25519PublicKey_ZERO,
)
Epoch Nonce Ceremony
The initial epoch nonce value governs the Cryptarchia lottery randomness for the first epoch. It must be revealed AFTER the initial stake distribution has been frozen. This is done to prevent any stakeholders from gaining an unfair advantage from prior knowledge of the lottery randomness.
The protocol for generating the initial randomness nonce can be found below.
Schedule Epoch Nonce Ceremony Event
The time of the epoch nonce ceremony must be fixed well in advance;
let t denote the time of the Epoch Nonce Ceremony, broadcast t widely.
The STAKE_DISTRIBUTION_TX must be finalized before t
to ensure a fair Cryptarchia slot lottery.
Randomness Collection
The entropy is collected from multiple randomness sources:
-
Bitcoin block hash immediately after time
t, denoted as r₁. Block hash can be found onblockchain.com's bitcoin block explorer, e.g. blockchain.com/explorer/blocks/btc/905030. -
Ethereum block hash immediately after time
t, denoted as r₂. Block hash can be found in themore detailssection when viewing a block on etherscan, e.g. etherscan.io/block/22894116. -
DRAND beacon value for the round immediately after
t, denoted as r₃. Use thedefaultbeacon, and find the round number corresponding tot. api.drand.sh/v2/beacons/default/rounds/1234.
Randomness Derivation
Once all above entropy contributions, i.e., r₁, r₂, r₃ are collected, the initial epoch randomness η_GENESIS is computed as:
η_GENESIS = H(r₁, r₂, r₃)
where H is a collision-resistant zkhash function.
Genesis Mantle Transaction
The initial stake distribution, service declarations and Cryptarchia inscription are components of the Genesis Mantle Transaction. This is the single transaction that forms the body of the Genesis block.
GENESIS_MANTLE_TX = MantleTx(
ops=[CRYPTARCHIA_INSCRIPTION] + SERVICE_DECLARATIONS,
ledger_tx=STAKE_DISTRIBUTION_TX,
permanent_storage_gas_price=0,
execution_gas_price=0
)
Block Header Fields
The Genesis Block header fields are set to the following values:
bedrock_version: Protocol version (e.g., 1).parent_block: 0 (as this is the first block).slot: 0 (the Genesis slot).block_root: Block Merkle root over the (single) initial transaction.proof_of_leadership: Stubbed leadership proof.leader_voucher: 0 (as there is no leader block reward for the initial block).entropy_contribution: 0 (no entropy is provided through the initial PoL).proof: Null Groth16Proof, all values are set to zero.leader_key: Null PublicKey.
Block Header Fields Example
GENESIS_HEADER = Header(
bedrock_version=1,
parent_block=0,
slot=0,
block_root=block_merkle_root([GENESIS_MANTLE_TX]),
proof_of_leadership=ProofOfLeadership(
leader_voucher=bytes(32),
entropy_contribution=bytes(32),
proof=Groth16Proof(G1_ZERO, G2_ZERO, G1_ZERO),
leader_key=Ed25519PublicKey_ZERO,
)
)
Sample Genesis Block
# distribute NMO to all stakeholders
STAKE_DISTRIBUTION_TX = LedgerTx(
inputs=[],
outputs=[
Note(value=1000, public_key=STAKE_HOLDER_0_PK),
Note(value=2000, public_key=STAKE_HOLDER_1_PK),
Note(value=1500, public_key=STAKE_HOLDER_2_PK),
# ...
]
)
# set Cryptarchia parameters
CRYPTARCHIA_PARAMS = {
"chain_id": "nomos-mainnet",
"genesis_time": "2026-01-05T19:20:35Z",
"genesis_epoch_nonce": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
}
CRYPTARCHIA_INSCRIPTION = Inscribe(
channel=bytes(32),
inscription=json.dumps(CRYPTARCHIA_PARAMS).encode("utf-8"),
parent=bytes(32),
signer=Ed25519PublicKey_ZERO,
)
# service declarations
DA_DECLARATIONS = [
Declaration(
msg=DeclarationMessage(ServiceType.DA, ["ip://1.1.1.1:3000"], PROVIDER_ID_0, ZK_ID_0),
locked_note_id=STAKE_DISTRIBUTION_TX.output_note_id(0)
),
# ... more declarations
]
BLEND_DECLARATIONS = [
Declaration(
msg=DeclarationMessage(ServiceType.BLEND, ["ip://1.1.1.1:3000"], PROVIDER_ID_0, ZK_ID_0),
locked_note_id=STAKE_DISTRIBUTION_TX.output_note_id(0)
),
# ... more declarations
]
SERVICE_DECLARATIONS = DA_DECLARATIONS + BLEND_DECLARATIONS
# build the genesis Mantle Transaction
GENESIS_MANTLE_TX = MantleTx(
ops=[CRYPTARCHIA_INSCRIPTION] + SERVICE_DECLARATIONS,
ledger_tx=STAKE_DISTRIBUTION_TX,
gas_price=0,
)
GENESIS_HEADER = Header(
bedrock_version=1,
parent_block=bytes(32),
slot=0,
block_root=block_merkle_root([GENESIS_MANTLE_TX]),
proof_of_leadership=ProofOfLeadership(
leader_voucher=bytes(32),
entropy_contribution=bytes(32),
proof=Groth16Proof(G1.ZERO, G2.ZERO, G1.ZERO),
leader_key=Ed25519PublicKey_ZERO,
)
)
GENESIS_BLOCK = (GENESIS_HEADER, [GENESIS_MANTLE_TX])
Initializing Bedrock
Bedrock is initialized by executing the Mantle Transaction
without validating the Ledger Transaction and Mantle Operations.
No validation or execution is done for the Genesis block header;
in particular, processing of proof_of_leadership is skipped.
Mantle Ledger Initialization
The Ledger Transaction should be executed without checking that the transaction is balanced. However, other validations are checked, e.g. that output note values are positive and smaller than the maximum allowed value. The result of normal transaction execution adds all transaction outputs to the Ledger.
Cryptarchia Initialization
The Mantle Transaction contains an inscription sent to the null channel containing the parameters for initializing Cryptarchia.
The Cryptarchia slot clock is initialized to genesis_time,
LIB is set to the Genesis block and the epoch state is then initialized:
Initial Epoch State
Cryptarchia progresses in epochs where the variables governing the lottery are fixed for the duration of an epoch and the activity during that epoch is used to derive the values of those variables for the next epoch. These variables taken together are called the Epoch State. (see Cryptarchia v1 Protocol Specification - Epoch State).
To initialize the Epoch State, the epoch variables are derived from the genesis block.
-
η: the epoch nonce is taken directly from the
genesis_epoch_nonce. -
C_LEAD: Eligible leader commitment is set to the Ledger Root over all notes from the initial token distribution. The derivation of this root is specified in Proof of Leadership Specification - Ledger Root.
-
D: The initial estimate of total stake will be the total tokens distributed at genesis.
Bedrock Services Initialization
DA and Blend network are initialized through normal Mantle Transaction execution.
The SDP_DECLARE Operations in the Genesis Mantle Transaction
will create the initial set of providers in each service.
During normal operations, DA/Blend services would wait until a block is deep enough to be finalized, but for the Genesis block, it is considered finalized by definition and so DA/Blend will immediately use the provider set without the usual finalization delay.
References
Normative
- Proof of Leadership Specification - Protocol for generating note keys
- NomosDA Specification - Minimum Network Size requirements
- Blend Protocol - Minimal Network Size requirements
- Cryptarchia v1 Protocol Specification - Epoch State specification
Informative
- Bedrock Genesis Block - Original specification document
- Ouroboros Praos - Ouroboros Praos protocol
- Ouroboros Genesis - Ouroboros Genesis protocol
- Ouroboros Crypsinous - Ouroboros Crypsinous protocol
- Cardano Shelley Genesis File Format - Cardano genesis file format
- Cardano CIP-16 Key Serialisation - Cardano key serialisation
Copyright
Copyright and related rights waived via CC0.
BEDROCK-SERVICE-DECLARATION-PROTOCOL
| Field | Value |
|---|---|
| Name | Bedrock Service Declaration Protocol |
| Slug | 87 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski [email protected] |
| Contributors | Mehmet Gonen [email protected], Daniel Sanchez Quiros [email protected], Álvaro Castro-Castilla [email protected], Thomas Lavaur [email protected], Gusto Bacvinka [email protected], David Rusu [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Service Declaration Protocol (SDP), a mechanism enabling validators to declare their participation in specific protocols that require a known and agreed-upon list of participants. Examples include Data Availability (DA) and the Blend Network. SDP creates a single repository of identifiers used to establish secure communication between validators and provide services. Before being admitted to the repository, a validator proves that it has locked at least a minimum stake.
Keywords: service declaration, validator, stake, declaration, withdrawal, session, minimum stake, provider, locator, Blend Network, Data Availability
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| SDP | Service Declaration Protocol for node participation in Nomos Services. |
| Declaration | A message confirming a validator's willingness to provide a specific service. |
| Service Type | The type of service being declared (e.g., BN for Blend Network, DA for Data Availability). |
| Minimum Stake | The minimum amount of stake a node MUST lock to declare for a service. |
| Session | A fixed-length window defined per service via session_length. |
| Lock Period | The minimum time during which a declaration cannot be withdrawn. |
| Inactivity Period | The maximum time during which an activation message MUST be sent. |
| Retention Period | The time after which a declaration can be safely deleted. |
| Provider ID | An Ed25519 public key used to sign SDP messages and establish secure links. |
| ZK ID | A public key used for zero-knowledge operations including rewarding. |
| Locator | The network address of a validator following the multiaddr scheme. |
| Declaration ID | A unique identifier for a declaration, computed as a hash. |
Background
In many protocols, a known and agreed-upon list of participants is required. Examples include Data Availability and the Blend Network. SDP enables nodes to declare their eligibility to serve a specific service and withdraw their declarations.
Requirements
The protocol requirements are:
- A declaration MUST be backed by confirmation that the sender owns a certain value of stake.
- A declaration is valid until it is withdrawn or is not used for a service-specific amount of time.
Actions Overview
The protocol defines the following actions:
- Declare: A node sends a declaration confirming its willingness to provide a specific service, backed by locking a threshold of stake.
- Active: A node marks that its participation in the protocol is active according to the service-specific activity logic. This action enables the protocol to monitor the node's activity. It is crucial to exclude inactive nodes from the set of active nodes, as it enhances the stability of services.
- Withdraw: A node withdraws its declaration and stops providing a service.
Protocol Flow
-
A node sends a declaration message for a specific service and proves it has a minimum stake.
-
The declaration is registered on the blockchain ledger, and the node can commence its service according to the service-specific logic.
-
After a service-specific service-providing time, the node confirms its activity.
-
The node MUST confirm its activity with a service-specific minimum frequency; otherwise, its declaration is inactive.
-
After the service-specific locking period, the node can send a withdrawal message, and its declaration is removed from the blockchain ledger (after the necessary retention period), meaning the node will no longer provide the service.
Note: Protocol messages are subject to finality, meaning messages become part of the immutable ledger after a delay. The delay is defined by the consensus.
Protocol Specification
Service Types
The following services are defined for service declaration:
BN: Blend Network service.DA: Data Availability service.
class ServiceType(Enum):
BN = "BN" # Blend Network
DA = "DA" # Data Availability
A declaration can be generated for any of the services above. Any declaration that is not one of the above MUST be rejected. The number of services MAY grow in the future.
Minimum Stake
The minimum stake is a global value defining the minimum stake a node MUST have to perform any service.
The MinStake structure holds the value of the stake stake_threshold
and the block number at which it was set (timestamp).
class MinStake:
stake_threshold: StakeThreshold
timestamp: BlockNumber
The stake_thresholds structure aggregates all defined MinStake values:
stake_thresholds: list[MinStake]
Service Parameters
The service parameters structure defines the parameters necessary for handling interaction between the protocol and services. Each service type MUST be mapped to the following parameters:
session_length: The session length expressed as the number of blocks. Sessions are counted from blocktimestamp.lock_period: The minimum time (as a number of sessions) during which the declaration cannot be withdrawn. This time MUST include the period necessary for finalizing the declaration and provision of a service for at least a single session. It can be expressed as blocks by multiplying bysession_length.inactivity_period: The maximum time (as a number of sessions) during which an activation message MUST be sent; otherwise, the declaration is considered inactive. It can be expressed as blocks by multiplying bysession_length.retention_period: The time (as a number of sessions) after which the declaration can be safely deleted by the Garbage Collection mechanism. It can be expressed as blocks by multiplying bysession_length.timestamp: The block number at which the parameter was set.
class ServiceParameters:
session_length: NumberOfBlocks
lock_period: NumberOfSessions
inactivity_period: NumberOfSessions
retention_period: NumberOfSessions
timestamp: BlockNumber
The parameters structure aggregates all defined ServiceParameters values:
parameters: list[ServiceParameters]
Session Tracking
A session is a fixed-length window defined per service
via ServiceParameters.session_length.
The session length MUST be at least k, the consensus finality parameter.
Session numbers start at 0 and are computed as follows:
def get_session_number(current_block_number, service_parameters):
return current_block_number // service_parameters.session_length
At the start of session n,
each node takes a snapshot (get_snapshot_at_block) of the SDP registry
at a specified block height from the finalized part of the chain:
def get_session_snapshot(session_number, service_parameters):
if session_number < 2:
# Take the genesis block for the first two sessions
return get_snapshot_at_block(0)
# Take the last block of the previous session for the rest
return get_snapshot_at_block(
(session_number - 1) * service_parameters.session_length - 1
)
The function get_snapshot_at_block(block_number) returns the state
of the SDP registry at block_number,
including state changes made by that block.
This snapshot defines the declaration state for the session—
each snapshot updates the common view of the registry.
Changes to the declaration registry take effect with a one-session delay:
messages sent during session n are included
in the next snapshot (for session n+1).
Sessions 0 and 1 read the snapshot at block 0, because the chain has not yet progressed far enough to provide a later finalized block.
Identifiers
The following identifiers are used for service-specific cryptographic operations:
provider_id: Used to sign SDP messages and establish secure links between validators. It is anEd25519PublicKey.zk_id: Used for zero-knowledge operations by the validator, including rewarding (Zero Knowledge Signature Scheme).
Locators
A Locator is the address of a validator
used to establish secure communication between validators.
It follows the multiaddr addressing scheme from libp2p,
but it MUST contain only the location part
and MUST NOT contain the node identity (peer_id).
The provider_id MUST be used as the node identity.
Therefore, the Locator MUST be completed
by adding the provider_id at the end of it,
making the Locator usable in the context of libp2p.
The length of the Locator is restricted to 329 characters.
The syntax of every Locator entry MUST be validated.
Common formatting of every Locator MUST be applied
to maintain its unambiguity and make deterministic ID generation work consistently.
The Locator MUST at least contain only lowercase letters
and every part of the address MUST be explicit (no implicit defaults).
Declaration Message
The construction of the declaration message is as follows:
class DeclarationMessage:
service_type: ServiceType
locators: list[Locator]
provider_id: Ed25519PublicKey
locked_note_id: NoteId
zk_id: ZkPublicKey
The locators list length MUST be limited to reduce the potential for abuse.
The length of the list MUST NOT be longer than 8.
The message MUST be signed by the provider_id key
to prove ownership of the key used for network-level authentication.
The locked_note_id points to a locked note
used for minimum stake threshold verification purposes.
The message MUST also be signed by the zk_id key.
Declaration Storage
Only valid declaration messages can be stored on the blockchain ledger.
The DeclarationInfo structure is defined as follows:
class DeclarationInfo:
service: ServiceType
provider_id: Ed25519PublicKey
locked_note_id: NoteId
zk_id: ZkPublicKey
locators: list[Locator]
created: BlockNumber
active: BlockNumber
withdrawn: BlockNumber
nonce: Nonce
Where:
service: The service type of the declaration.provider_id: AnEd25519PublicKeyused to sign the message by the validator.locked_note_id: ANoteIdused for minimum stake threshold verification.zk_id: Used for zero-knowledge operations including rewarding.locators: A copy of the locators from theDeclarationMessage.created: The block number of the block that contained the declaration.active: The latest block number for which the active message was sent (set tocreatedby default).withdrawn: The block number for which the service declaration was withdrawn (set to 0 by default).nonce: MUST be set to 0 for the declaration message and MUST increase monotonically by every message sent for thedeclaration_id.
The declaration_id (of type DeclarationId)
is the unique identifier of DeclarationInfo,
calculated as a hash of the concatenation of
service, provider_id, zk_id, and locators.
The hash function implementation is blake2b using 256 bits of output:
declaration_id = Hash(service || provider_id || zk_id || locators)
The declaration_id is not stored as part of DeclarationInfo
but is used to index it.
All DeclarationInfo references are stored in declarations
and are indexed by declaration_id:
declarations: list[declaration_id]
Active Message
The construction of the active message is as follows:
class ActiveMessage:
declaration_id: DeclarationId
nonce: Nonce
metadata: Metadata
Where metadata is service-specific node activeness metadata.
The message MUST be signed by the zk_id key
associated with the declaration_id.
The nonce MUST increase monotonically
by every message sent for the declaration_id.
Withdraw Message
The construction of the withdraw message is as follows:
class WithdrawMessage:
declaration_id: DeclarationId
locked_note_id: NoteId
nonce: Nonce
The message MUST be signed by the zk_id key from the declaration_id.
The locked_note_id is a NoteId
that was used for minimum stake threshold verification purposes
and will be unlocked after withdrawal.
The nonce MUST increase monotonically
by every message sent for the declaration_id.
Indexing
Every event MUST be correctly indexed
to enable lighter synchronization of the changes.
Events are indexed by EventType, ServiceType, and Timestamp,
where EventType = { "created", "active", "withdrawn" }
corresponds to the type of message:
events = {
event_type: {
service_type: {
timestamp: {
declarations: list[declaration_id]
}
}
}
}
Protocol Actions
Declare
The Declare action associates a validator with a service it wants to provide.
It requires sending a valid DeclarationMessage,
which is then processed and stored.
The declaration message is considered valid when all of the following are met:
- The sender meets the stake requirements and its
locked_note_idis valid. - The
declaration_idis unique. - The sender knows the secret behind the
provider_ididentifier. - The length of the
locatorslist MUST NOT be longer than 8. - The
nonceincreases monotonically.
If all conditions are fulfilled, the message is stored on the blockchain ledger; otherwise, the message is discarded.
Active
The Active action enables marking the provider as actively providing a service.
It requires sending a valid ActiveMessage,
which is relayed to the service-specific node activity logic.
The Active action updates the active value of the DeclarationInfo,
which also activates inactive (but not expired) providers.
The SDP active action logic is:
-
A node sends an
ActiveMessagetransaction. -
The
ActiveMessageis verified by the SDP logic:- The
declaration_idreturns an existingDeclarationInfo. - The transaction containing
ActiveMessageis signed by thezk_id. - The
withdrawnfrom theDeclarationInfois set to zero. - The
nonceincreases monotonically.
- The
-
If any of these conditions fail, discard the message and stop processing.
-
The message is processed by the service-specific activity logic alongside the
activevalue indicating the period since the last active message was sent. Theactivevalue comes from theDeclarationInfo. -
If the service-specific activity logic approves the node active message, then the
activefield of theDeclarationInfois set to the current block height.
Withdraw
The Withdraw action enables withdrawal of a service declaration.
It requires sending a valid WithdrawMessage.
The withdrawal cannot happen before the end of the locking period,
defined as the number of blocks counted since created.
This lock period is stored as lock_period in the Service Parameters.
The logic of the withdraw action is:
-
A node sends a
WithdrawMessagetransaction. -
The
WithdrawMessageis verified by the SDP logic:- The
declaration_idreturns an existingDeclarationInfo. - The transaction containing
WithdrawMessageis signed by thezk_id. - The
withdrawnfromDeclarationInfois set to zero. - The
nonceincreases monotonically.
- The
-
If any of the above is not correct, discard the message and stop.
-
Set the
withdrawnfrom theDeclarationInfoto the current block height. -
Unlock the stake (release the
locked_note_id).
Garbage Collection
The protocol requires a garbage collection mechanism
that periodically removes unused DeclarationInfo entries.
The logic of garbage collection is:
For every DeclarationInfo in the declarations set,
remove the entry if either:
-
The entry is past the retention period:
withdrawn + (retention_period * session_length) < current_block_height. -
The entry is inactive beyond the inactivity and retention periods:
active + (inactivity_period + retention_period) * session_length < current_block_height.
Query Interface
The protocol MUST enable querying the blockchain ledger with at least the following queries:
GetAllProviderId(timestamp): Returns allprovider_ids associated with the timestamp.GetAllProviderIdSince(timestamp): Returns allprovider_ids since the timestamp.GetAllDeclarationInfo(timestamp): Returns allDeclarationInfoentries associated with the timestamp.GetAllDeclarationInfoSince(timestamp): Returns allDeclarationInfoentries since the timestamp.GetDeclarationInfo(provider_id): Returns theDeclarationInfoentry identified by theprovider_id.GetDeclarationInfo(declaration_id): Returns theDeclarationInfoentry identified by thedeclaration_id.GetAllServiceParameters(timestamp): Returns all entries of theServiceParametersstore for the timestamp.GetAllServiceParametersSince(timestamp): Returns all entries of theServiceParametersstore since the timestamp.GetServiceParameters(service_type, timestamp): Returns the service parameter entry for aservice_typeat a timestamp.GetMinStake(timestamp): Returns theMinStakestructure at the requested timestamp.GetMinStakeSince(timestamp): Returns a set ofMinStakestructures since the requested timestamp.
The query MUST return an error if the retention period for the declaration has passed and the requested information is not available.
The list of queries MAY be extended.
Every query MUST return information for a finalized state only.
Security Considerations
Stake Requirements
Validators MUST lock a minimum stake before declaring for a service. This prevents Sybil attacks by ensuring economic commitment to the network.
Message Authentication
All SDP messages MUST be cryptographically signed:
DeclarationMessageMUST be signed by bothprovider_idandzk_id.ActiveMessageMUST be signed byzk_id.WithdrawMessageMUST be signed byzk_id.
Nonce Monotonicity
The nonce MUST increase monotonically for each declaration_id
to prevent replay attacks.
Locator Validation
The syntax of every Locator entry MUST be validated
to prevent malformed addresses from being registered.
The length restriction of 329 characters
and the limit of 8 locators per declaration
prevent resource exhaustion attacks.
References
Normative
- BEDROCK-MANTLE-SPECIFICATION - Mantle Transaction and Operation specification
Informative
- Service Declaration Protocol - Original specification document
- libp2p multiaddr - Multiaddr addressing scheme
Copyright
Copyright and related rights waived via CC0.
BEDROCK-SERVICE-REWARD-DISTRIBUTION
| Field | Value |
|---|---|
| Name | Bedrock v1.2 Service Reward Distribution Protocol |
| Slug | 86 |
| Status | raw |
| Category | Standards Track |
| Editor | Thomas Lavaur [email protected] |
| Contributors | David Rusu [email protected], Mehmet Gonen [email protected], Marcin Pawlowski [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Service Reward Distribution Protocol for distributing rewards to validators based on their participation in Nomos services such as Data Availability and Blend Network. The protocol enables deterministic, efficient, and verifiable reward distribution to validators based on their activity within each service.
Keywords: Bedrock, rewards, services, validators, Data Availability, Blend Network, session, activity
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Session | A fixed number of blocks during which the validator set remains unchanged. |
| Service Validator | A node participating in a service (DA or Blend Network). |
| Activity Message | A signed message attesting to a validator's participation. |
| zk_id | The zero-knowledge identity of a validator from SDP declarations. |
| SDP_ACTIVE | A Mantle Operation used to submit activity attestations. |
Background
Nomos relies on multiple services, including the Data Availability and Blend Network - each operated by independent validator sets. For sustainability and fairness, these services must compensate service validators based on their participation. Validators first declare their participation through Service Declaration Protocol.
Each service defines:
- The session length, a fixed number of blocks during which its validator set remains unchanged.
- The validator activity rule that distinguishes between active and inactive validators.
- The reward formula for distributing the session's rewards at the end of the session.
The protocol unfolds over three key phases, aligned with validator sessions:
-
Service Activity Tracking (Session N+1): Service validators submit signed activity messages to attest to their participation of session N through a Mantle Transaction, including an activity message (see Mantle Specification - SDP_ACTIVE).
-
Service Reward Derivation (End of Session N+1): Nodes compute each validator's reward based on validated activity messages and the different service reward policies.
-
Service Reward Distribution (First block of session N+2): Rewards are distributed to validators marked as active for the service. This is done by inserting new notes in the ledger corresponding to the reward amount for each active validator.
Core Properties:
- Service rewards are distributed to the
zk_idfrom validator SDP declarations. - Minimal Block Overhead: rewards are directly added to the ledger without involving Mantle Transactions.
Protocol
Sessions
Each service defines its own session length (e.g., 10000 blocks), during which:
- The service validator set remains static.
- Activity criteria and reward policy are fixed.
Activity Tracking
Throughout session N+1, the block proposers integrate Mantle Transactions
containing SDP_ACTIVE Operations.
These transactions originate from service validators
and are used to derive their activity
according to the service provided policy.
The protocol does not prescribe a unique activity rule:
each service defines what qualifies as valid participation,
enabling flexibility across different services.
Service validators are economically incentivized to participate actively since only active validators will be rewarded. Moreover, by decoupling activity submission from reward calculation, the system remains robust to network latency.
This generalized mechanism accommodates a wide range of services without requiring specialized infrastructure. It enables services to evolve their own activity rules independently while preserving a shared framework for reward distribution.
Service Reward Calculation
At the end of session N+1,
service rewards for the validator n for the session N
are computed by the different services
taking as input the rewards of the session:
Rewards^n := serviceReward(n, Rewards_Session)
Where Rewards_Session are the total rewards of session N.
The Rewards_Session is determined by the service,
which calculates how much each service receives
based on fees burnt during session N and the blockchain's state.
Rewards^n is stored as an array that maps each validator's zk_id
to their allocated reward.
Service Reward Distribution
Starting immediately after session N+1, service rewards are distributed in the first block of session N+2. The rewards are inserted directly in the ledger without triggering any Mantle validation.
The note ID is computed using the result of
zkhash(FiniteField(ServiceType, byte_order="little", modulus=p) || session_number)
as the transaction hash.
The output number corresponds to the position of the zk_id
when sorted in ascending order.
The reward MUST:
- Transfer the correct reward amount according to Service Reward Calculation.
- Be sent to the public key
zk_idof the validator registered during declaration of the service. - Be distributed into a single note if several rewards share the same
zk_id. - Be executed identically by every node processing the first block of session N+2.
This happens by inserting notes in the ledger in ascending order of
zk_id.
Nodes indirectly verify the correct inclusion of rewards because all consensus-validating nodes must maintain the same ledger view to derive the latest ledger root, which serves as input for verifying the Proof of Leadership.
References
Normative
- Service Declaration Protocol - Protocol for declaring service participation
- Mantle Specification - SDP_ACTIVE operation specification
- Proof of Leadership - Proof of Leadership specification
Informative
- v1.2 Service Reward Distribution Protocol - Original specification document
Copyright
Copyright and related rights waived via CC0.
BEDROCK-V1-1-BLOCK-CONSTRUCTION
| Field | Value |
|---|---|
| Name | Bedrock v1.1 Block Construction, Validation and Execution Specification |
| Slug | 93 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski [email protected] |
| Contributors | Thomas Lavaur [email protected], Daniel Sanchez Quiros [email protected], David Rusu [email protected], Álvaro Castro-Castilla [email protected], Mehmet Gonen [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the construction, validation, and execution of block proposals in the Nomos blockchain. It describes how block proposals contain references to transactions rather than complete transactions, compressing the proposal size from up to 1 MB down to 33 kB to save bandwidth necessary to broadcast new blocks.
Keywords: Bedrock, block construction, validation, execution, leader, transaction, Proof of Leadership
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Leader | A node elected through the leader lottery to construct a new block. |
| Block Builder | The leader node that constructs a new block proposal. |
| Block Proposer | The leader node that shares the constructed block with other network members. |
| Block Proposal | A message structure containing a header and references to transactions. |
| Proof of Leadership (PoL) | A proof confirming that a node is indeed the elected leader. |
| Transaction Maturity | The assumption that transactions have had enough time to spread across the network. |
| Validator | A node that validates and executes block proposals. |
High-level Flow
This section presents a high-level description of the block lifecycle. The main focus of this section is to build an intuition on the block construction, validation, and execution.
-
A leader is selected. The leader becomes a block builder.
-
The block builder constructs a block proposal.
-
The block builder selects the latest block (parent) as the reference point for the chain state update.
-
The block builder constructs references to the deterministically generated Mantle Transactions that execute the Service Reward Distribution Protocol, if such transactions can be constructed. For example, there is no need to distribute rewards when all rewards have already been distributed.
-
The block builder selects valid Mantle Transactions (as defined in Mantle Specification) from its mempool and includes references to them in the proposal.
-
The block builder populates the block header of the block proposal.
-
-
The block proposer sends the block proposal to the Blend network.
-
The validators receive the block proposal.
-
The validators validate the block proposal.
-
They validate the block header.
-
They verify distribution of service rewards through Mantle Transactions as specified in Service Reward Distribution Protocol. This is done by independently deriving the distribution transaction and confirming that it matches the first reference, if there are rewards to be distributed.
-
They retrieve complete transactions from their mempool that are referred in the block.
-
They validate each transaction included in the block.
-
-
The validators execute the block proposal.
-
They derive the new blockchain state from the previous one by executing transactions as defined in Mantle Specification.
-
They update the different variables that need to be maintained over time.
-
Constructions
Hash
This specification uses two hashing algorithms that have the same output length of 256 bits (32 bytes) that are Poseidon2 and Blake2b.
Block Proposal
A block proposal, instead of containing complete Mantle Transactions of an unlimited size, contains references of fixed size to the transactions. Therefore, the size of the proposal is constant and it is 33129 bytes.
The following message structure is defined:
class Proposal: # 33129 bytes
header: Header # 297 bytes
references: References # 32768 bytes
signature: Ed25519Signature # 64 bytes
Where:
headeris the header of the proposal; defined below: Header.referencesis a set of 1024 references to transactions of ahashtype; the size of thehashtype is 32 bytes and is the transaction hash as defined in Mantle Specification - Mantle Transaction.signatureis the signature of the completeheaderusing theleader_keyfrom theProofOfLeadership; the size of theEd25519Signaturetype is 64 bytes.
Note: The length of the
referenceslist must be preserved to maintain the message's indistinguishability in the Blend protocol. Therefore, the list must be padded with zeros when necessary.
Header
class Header: # 297 bytes
bedrock_version: byte # 1 bytes
parent_block: hash # 32 bytes
slot: SlotNumber # 8 bytes
block_root: hash # 32 bytes
proof_of_leadership: ProofOfLeadership # 224 bytes
Where:
bedrock_versionis the version of the proposal message structure that supports other protocols defined in Bedrock Specification; the size of it is 1 byte and is fixed to0x01.parent_blockis the block ID (Cryptarchia v1 Protocol Specification) of the parent block, validated and accepted by the block builder. It is used for the derivation of theAgedLedgerandLatestLedgervalues necessary for validating the PoL; the size of thehashis 32 bytes.slotis the consensus slot number; the size of theSlotNumbertype is 8 bytes.block_rootis the root of the Merkle tree constructed from transaction hashes (defined in Mantle Specification - Mantle Transaction) used for constructing thereferenceslist in thetransactions; the size of thehashis 32 bytes.proof_of_leadershipis the proof confirming that the sender is the leader; defined below: Proof of Leadership.
Block References
class References: # 32768 bytes
service_reward: list[zkhash] # 1*32 bytes
mempool_transactions: list[zkhash] # 1024-len(service_reward)*32 bytes
Where:
service_rewardis a set of up to 1 reference to a reward transaction of azkhashtype; the size of thezkhashtype is 32 bytes and is the transaction hash as defined in Mantle Specification - Mantle Transaction.mempool_transactionsis a set of up to 1024 references to transactions of azkhashtype; the size of thezkhashtype is 32 bytes and is the transaction hash as defined in Mantle Specification - Mantle Transaction.
The service_reward transaction is created deterministically by the leader
and is not obtained from the mempool.
If this transaction were obtained from the mempool,
it could expose the leader's identity as the transaction creator.
To protect the leader's identity,
only the service_reward reference is included in the proposal,
and it is derived again by the nodes verifying the block.
The service_reward transaction is a Service Rewards Distribution Transaction
that distributes service rewards.
It is a Mantle Transaction with no input
and up to service_count x 4 outputs,
service_count being the number of services (global parameter).
The outputs represent the validators rewarded (up to 4 per service).
If the service_reward transaction cannot be created,
then nothing is added to the list.
Therefore, the service_reward list is allowed to have a length of 0.
Proof of Leadership
class ProofOfLeadership: # 224 bytes
leader_voucher: RewardVoucher # 32 bytes
entropy_contribution: zkhash # 32 bytes
proof: ProofOfLeadership # 128 bytes
leader_key: Ed25519PublicKey # 32 bytes
Where:
leader_voucheris the voucher value used for retrieving the reward by the leader for proposal; the size of theRewardVoucheris 32 bytes.entropy_contributionis the output of the PoL contribution for Cryptarchia entropy; the size of thezkhashtype is 32 bytes.proofis the proof confirming that the proposal is constructed by the leader; the size of theProofOfLeadershiptype is 128 bytes (2 compressed G1 and 1 compressed G2 BN256 elements).leader_keyis the one-timeEd25519PublicKeyused for signing theProposal. This binds the content of the proposal with theProofOfLeadership; the size of theEd25519PublicKeytype is 32 bytes.
Proposal Construction
This section explains how the block proposal structure presented above is populated by the consensus leader.
The block proposal is constructed by the leader of the current slot.
The node becomes a leader only after successfully generating a valid PoL
for a given (Epoch, Slot).
Prerequisites
Before constructing the proposal, the block builder must:
-
Select a valid parent block referenced by
ParentBlockon which they will extend the chain. -
Derive the required Ledger state snapshots
AgedLedgerandLatestLedgerfrom the state of the chain including the last block. -
Select a valid unspent note winning the PoL.
-
Generate a valid PoL proving leadership eligibility for
(Epoch, Slot)based on the selected note. Attach the PoL to a one-time Ed25519 public key used to sign the block proposal.
Only after the PoL is generated can the block proposal be constructed (see Proof of Leadership Specification).
Construction Procedure
-
Initialize proposal metadata with the last known state of the blockchain. Set the:
header:bedrock_versionparent_blockslotblock_rootproof_of_leadership:leader_voucherentropy_contributionproofleader_key
-
Construct the
service_rewardobject:- If there are service rewards to be distributed,
construct the transaction that distributes
the service rewards from previous session
and add its reference to the
service_rewardlist. This transaction must be computed locally, do not disseminate this transaction.
- If there are service rewards to be distributed,
construct the transaction that distributes
the service rewards from previous session
and add its reference to the
-
Construct the
mempool_transactionsobject:-
Select Mantle transactions:
-
Choose up to
1024-len(service_reward)validSignedMantleTxfrom the local mempool. -
Ensure each transaction:
-
Is valid according to Mantle Specification.
-
Has no conflicts with others (e.g., two transactions trying to spend the same note).
-
-
-
-
Derive references values:
references: list[zkhash] = [mantle_txhash(tx) for tx in service_reward + mempool_transactions] -
Compute the
header.block_rootas the root of the Merkle tree constructed from thelist(service_reward) + mempool_transactionstransactions used to buildreferences. -
Sign the block proposal header.
signature = Ed25519.sign(leader_secret_key, header) -
Assemble the block proposal.
proposal = Proposal( header, references, signature )
The PoL must have been generated beforehand and bound to the same Ledger view as mentioned in the Prerequisites.
The constructed proposal can now be broadcast to the network for validation.
Block Proposal Reconstruction
Given a block proposal, this specification assumes transaction maturity. This means that the block proposal must include transactions from the mempool that have had enough time to spread across the network to reach all nodes. This ensures that transactions are widely known and recognized before block reconstruction.
This transaction maturity assumption holds true because the block proposal must be sent through the Blend Network before it reaches validators and can be reconstructed. The Blend Network introduces significant delay, ensuring that transactions referenced in the proposal have reached all network participants.
This approach is crucial for maintaining smooth network operation and reducing the risk that proposals get rejected due to transactions being unavailable to some validators. Moreover, by increasing the number of nodes that have seen the transaction, anonymity is also enhanced as the set of nodes with the same view is larger. This may result in increased difficulty—or even practical prevention—of executing deanonymization attacks such as tagging attacks.
Upon receipt of a block proposal, validators must confirm the presence of all referenced transactions within their local mempool. This verification is an absolute requirement—if even a single referenced transaction is missing from the validator's mempool, the entire proposal must be rejected. This stringent validation protocol ensures only widely-distributed transactions are included in the blockchain, safeguarding against potential network state fragmentation.
The process works as follows:
-
Transaction is added to the node mempool.
-
Node sends the transaction to all its neighbors.
-
Neighbors add the transaction to their own mempools and propagate it to their neighbors—transaction is gossiped throughout the network.
-
Block builder selects a transaction from its local mempool, which is guaranteed to be propagated through the network due to steps 1-3.
-
Block builder constructs a block proposal with references to selected transactions.
-
Block proposal is sent through the Blend Network, which requires multiple rounds of gossiping. This introduces a delay that ensures the transaction has reached most of the network participants' mempools.
-
Block proposal is received by validators.
-
Validators check their local mempools for all referenced transactions from the proposal.
-
If any transaction is missing, the entire proposal is rejected.
-
If all transactions are present, the block proposal is reconstructed and proceeds to further validation steps.
Block Proposal Validation
This section defines the procedure followed by a Nomos node to validate a received block proposal.
Given a Proposal, a proposed block consisting of a header and references.
This block proposal is considered valid if the following conditions are met:
Block Validation
The Proposal must satisfy the rules defined in
Cryptarchia v1 Protocol Specification - Block Header Validation.
Block Proposal Reconstruction Validation
The references must refer to either a service_reward transaction
that is locally derivable
or to existing mempool_transaction entries
that are retrievable from the node's local mempool.
Mempool Transactions Validation
mempool_transactions must refer to a valid sequence
of Mantle Transactions from the mempool.
Each transaction must be valid according to the rules
defined in the Mantle Specification.
In order to verify ZK proofs,
they are batched for verification as explained in
Batch verification of ZK proofs
to get better performances.
Rewards Validation
-
Check if the first reference matches a deterministically derived Service Rewards Distribution Transaction that distributes previous session service fees as defined in Service Reward Distribution Protocol. It should take no input and output up to
service_count * 4reward notes distributed to the correct validators. -
If the above rewarding transactions cannot be derived, then the first reference must refer to a
mempool_transaction.
If any of the above checks fail, the block proposal must be rejected.
Block Execution
This section specifies how a Nomos node executes a valid block proposal to update its local state.
Given a ValidBlock that has successfully passed proposal validation,
the node must:
-
Append the
leader_vouchercontained in the block to the set of reward vouchers when the following epoch starts. -
Execute the Mantle Transactions included in the block sequentially, using the execution rules defined in the Mantle Specification.
Annex
Batch verification of ZK proofs
Blob Samples
-
For each sample the verifier follows the classic cryptographic verification procedure as described in NomosDA Cryptographic Protocol - Verification except the last step, once the verifier has a single commitment C^(i), an aggregated element v^(i) at position u^(i) and one proof π^(i) for each sample.
-
The verifier draws a random value for each sample r_i ← $F_p.
-
The verifier computes:
-
C' := Σ(i=1 to k) r_i · C^(i)
-
v' := Σ(i=1 to k) r_i · v^(i)
-
π' := Σ(i=1 to k) r_i · π^(i)
-
u' := Σ(i=1 to k) r_i · u^(i) · π^(i)
-
-
They test if e(C' - v' · G1 + u', G2) = e(π', τ · G2).
Proofs of Claim
-
For each proof of Claim the verifier collects the classic Groth16 elements required for verification. It includes the proof π^(i), and the public values x_j^(i) for each proof of claim.
-
The verifier draws one random value for each proof r_i ← $F_p.
-
The verifier computes:
-
π'_j := Σ(i=1 to k) r_i · π_j^(i) for j ∈ {A, B, C}.
-
r' := Σ(i=1 to k) r_i
-
IC := r' · Ψ_0 + Σ(j=1 to l) (Σ(i=1 to k) r_i · x_j^(i)) · Ψ_j
-
-
They test if Σ(i=1 to k) e(r_1 · π'_A, π'_B) = e(r' · [α]_1, [β]_2) + e(IC, [γ]_2) + e(π'_C, [δ]_2).
Note: This batch verification of Groth16 proofs is the same as what is described in the Zcash paper, Appendix B.2.
ZkSignatures
The verifier follows the same procedure as in Proofs of Claim but with the Groth16 proofs of ZkSignatures.
References
Normative
- Mantle Specification - Mantle transaction specification
- Cryptarchia v1 Protocol Specification - Cryptarchia consensus protocol
- Bedrock Specification - Bedrock protocol specification
- Proof of Leadership Specification - Proof of Leadership specification
- Service Reward Distribution Protocol - Service reward distribution protocol
- NomosDA Cryptographic Protocol - NomosDA cryptographic verification
Informative
- v1.1 Block Construction, Validation and Execution Specification - Original specification document
- Poseidon2 - Hash function
- Blake2b - Hash function
- Zcash paper, Appendix B.2 - Batch verification of Groth16 proofs
Copyright
Copyright and related rights waived via CC0.
BEDROCK-V1.1-MANTLE-SPECIFICATION
| Field | Value |
|---|---|
| Name | Bedrock v1.1 Mantle Specification |
| Slug | 98 |
| Status | raw |
| Category | Informational |
| Editor | Thomas Lavaur [email protected] |
| Contributors | David Rusu [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document specifies the Mantle layer of Bedrock, the foundational execution layer that connects Nomos Services to provide functionality for Sovereign Rollups. Mantle serves as the system call interface of Bedrock, exposing a safe and constrained set of Operations to interact with lower-level Bedrock services, similar to syscalls in an operating system.
Keywords: Bedrock, Mantle, transactions, operations, ledger, UTXO, Note, NMO, fees, gas, Sovereign Rollups
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Mantle | The foundational execution layer of Bedrock that connects Nomos Services for Sovereign Rollups. |
| Mantle Transaction | A transaction containing zero or more Operations and one Ledger Transaction. |
| Operation | An action within a Mantle Transaction that interacts with Nomos Services. |
| Ledger Transaction | A transaction component that manages asset transfers using a transparent UTXO model. |
| Note | A UTXO-like asset unit composed of a value and a public key (owner). |
| Locked Note | A Note serving as collateral for Service Declarations, locked until withdrawal. |
| Channel | A virtual chain overlaying the Cryptarchia blockchain for Rollup updates. |
| SDP | Service Declaration Protocol for node participation in Nomos Services. |
| ZkSignature | Zero Knowledge Signature proving ownership without revealing the private key. |
| NMO | The native token of the Nomos network. |
| DA | Data Availability, ephemeral storage for blob data. |
Background
Transactions
The features of Nomos are exposed through Mantle Transactions. Each transaction can contain zero or more Operations and one Ledger Transaction. Mantle Transactions enable users to execute multiple Operations atomically. The Ledger Transaction serves two purposes, it can pay the transaction fee and allows users to issue transfers.
Operations
Nomos features are exposed through Mantle Operations, which can be combined and executed together in a single Mantle Transaction. These Operations enable functions such as on-chain data posting, SDP interaction, and leader reward claims.
Ledger
The Mantle Ledger enables asset transfers using a transparent UTXO model. While a Ledger Transaction can consume more NMO than it creates, the Mantle Transaction excess balance must exactly pay for the fees.
Fees
Mantle Transaction fees are derived from a gas model. Nomos has three different Gas markets, accounting for permanent data storage, ephemeral data storage through DA, and execution costs. Permanent data storage is paid at the Mantle Transaction level, while ephemeral data storage is paid at the Blob Operation level. Each Operation and Ledger Transaction has an associated Execution Gas cost. Users can specify their Gas prices in their Mantle Transactions or in the Blob Operation to incentivize the network to include their transaction.
| Gas Market | Charged On | Pricing Basis |
|---|---|---|
| Execution Gas | Ledger Transaction and Operations | Fixed per Operation |
| Permanent Storage Gas | Signed Mantle Transaction | Proportional to encoded size |
| DA Storage Gas | Blob Operation | Proportional to blob size |
Mantle Transaction
Mantle Transactions form the core of Mantle, enabling users to combine multiple Operations to access different Nomos functions. Each transaction contains zero or more Operations plus a Ledger Transaction. The system executes all Operations atomically, while using the Mantle Transaction's excess balance (calculated as the difference between consumed and created value) as the fee payment.
class MantleTx:
ops: list[Op]
ledger_tx: LedgerTx # excess balance is used for fee payment
permanent_storage_gas_price: int # 8 bytes
execution_gas_price: int # 8 bytes
class Op:
opcode: byte
payload: bytes
def mantle_txhash(tx: MantleTx) -> ZkHash:
tx_bytes = encode(tx)
h = Hasher()
h.update(FiniteField(b"NOMOS_MANTLE_TXHASH_V1", byte_order="little", modulus=p))
for i in range((len(tx_bytes)+30)//31):
chunk = tx_bytes[i*31:(i+1)*31]
fr = FiniteField(chunk, byte_order="little", modulus=p)
h.update(fr)
return h.digest()
The hash function used, as well as other cryptographic primitives like ZK proofs and signature schemes, are described in NOMOS-COMMON-CRYPTOGRAPHIC-COMPONENTS.
A Mantle Transaction MUST include all relevant signatures and proofs for each Operation, as well as for the Ledger Transaction.
class SignedMantleTx:
tx: MantleTx
op_proofs: list[OpProof | None] # each Op has at most 1 associated proof
ledger_tx_proof: ZkSignature # ZK proof of ownership of the spent notes
Each proof (op_proofs and ledger_tx_proof) must be cryptographically bound
to the MantleTx through the mantle_txhash to prevent replay attacks.
This binding is achieved by including the MantleTx hash
as a public input in every ZK proof.
The transaction fee is a sum of two components:
the multiplication of the total Execution Gas by the execution_gas_price,
and the total size of the encoded signed Mantle Transaction
multiplied by the permanent_storage_gas_price.
If the Mantle Transaction contains Blob Operations,
the fee also accounts for ephemeral data storage.
In this case,
the blob_size of each blob is multiplied by the DA_storage_gas_price
stored in the Blob Operation
and added to the previous amounts to determine the final fee.
def gas_fees(signed_tx: SignedMantleTx) -> int:
mantle_tx = signed_tx.tx
permanent_storage_fees = len(encode(signed_mantle_tx)) * mantle_tx.permanent_storage_gas_price
execution_fees = execution_gas(mantle_tx.ledger_tx) * mantle_tx.execution_gas_price
da_storage_fees = 0
for op in mantle_tx.ops:
if op.opcode == CHANNEL_BLOB:
blob = decode_blob(op.payload)
da_storage_fees += blob.da_storage_gas_price * blob.blob_size
# Compute the execution gas of this operation as defined
# in the gas cost determination specification.
execution_fees += execution_gas(op) * mantle_tx.execution_gas_price
return execution_fees + da_storage_fees + permanent_storage_fees
MantleTx Validation
Given:
signed_tx = SignedMantleTx(
tx=MantleTx(ops, permanent_storage_gas_price, execution_gas_price, ledger_tx),
op_proofs,
ledger_tx_proof
)
Mantle validators will ensure the following:
- The ledger transaction is valid according to Ledger Validation.
validate_ledger_tx(ledger_tx, ledger_tx_proof, mantle_txhash(tx))
- There is a proof or a
Nonevalue for each operation.
assert len(op_proofs) == len(ops)
- Each Operation is valid.
for op, op_proof in zip(ops, op_proofs):
assert op.opcode in MANTLE_OPCODES
validate_mantle_op(mantle_txhash(tx), op.opcode, op.payload, op_proof)
def validate_mantle_op(txhash, opcode, payload, op_proof):
if opcode == INSCRIBE:
validate_inscribe(txhash, payload, op_proof)
# elif opcode == ...
# ...
- The Mantle Transaction excess balance pays for the transaction fees.
tx_fee = get_fees(signed_tx)
assert tx_fee == get_transaction_balance(signed_tx)
def get_transaction_balance(signed_tx):
balance = 0
for op in signed_tx.tx.ops:
if op.opcode == LEADER_CLAIM:
balance += get_leader_reward()
for inp in signed_tx.tx.ledger_tx.inputs:
balance += get_value_from_note_id(inp)
for out in signed_tx.tx.ledger_tx.outputs:
balance -= out.value
MantleTx Execution
Given:
SignedMantleTx(
tx=MantleTx(ops, permanent_storage_gas_price, execution_gas_price, ledger_tx),
op_proofs,
ledger_tx_proof
)
Mantle Validators execute the following:
-
Execute the Ledger Transaction as described in Ledger Validation.
-
Execute sequentially each Operation in
opsaccording to its opcode.
Mantle Operations
Opcodes
| Operation | Opcode | Description |
|---|---|---|
| CHANNEL_INSCRIBE | 0x00 | Write a message permanently onto Mantle. |
| CHANNEL_BLOB | 0x01 | Store a blob in DA. |
| CHANNEL_SET_KEYS | 0x02 | Manage the list of keys accredited to post to a channel. |
| RESERVED | 0x03 - 0x1F | |
| SDP_DECLARE | 0x20 | Declare intention to participate as a node in a Nomos Service, locking funds as collateral. |
| SDP_WITHDRAW | 0x21 | Withdraw participation from a Nomos Service, unlocking your funds in the process. |
| SDP_ACTIVE | 0x22 | Signal that you are still an active participant of a Nomos Service. |
| RESERVED | 0x23 - 0x2F | |
| LEADER_CLAIM | 0x30 | Claim leader reward anonymously. |
| RESERVED | 0x31 - 0xFF |
Full nodes will track and process every Operation. In contrast, nodes focused on a specific rollup will also track all Operations but will only fully process blobs that target their own rollup referenced by a channel ID.
Channel Operations
Channels allow Rollups to post their updates on chain. Channels form virtual chains that overlay on top of the Cryptarchia blockchain. Clients and dependents of Rollups can watch the Rollups channels to learn the state of that Rollup.
Channel Sequencing
These channels form virtual chains by having each message reference its parent message. The order of messages in these channels is enforced by the sequencer by building a hash chain of messages, i.e. new messages reference the previous messages through a parent hash. Given that Cryptarchia has long finality times, these message parent references allow the Rollup sequencers to continue to post new updates to channels without having to wait for finality. No matter how Cryptarchia forks and reorgs, the channel messages will eventually be re-included in a way that satisfies the virtual chain order.
The first time a message is sent to an unclaimed channel, the message signing key that signs the initial message becomes both the administrator and an accredited key. The administrator can update the list of accredited keys who are authorized to write messages to that channel.
Validators must keep the following state for processing channel Operations:
channels: dict[ChannelId, ChannelState]
class ChannelState:
tip: hash
accredited_keys: list[Ed25519PublicKey]
CHANNEL_INSCRIBE
Write a message to a channel with the message data being permanently stored on the Nomos blockchain.
CHANNEL_INSCRIBE Payload
class Inscribe:
channel: ChannelID # Channel being written to
inscription: bytes # Message to be written on the blockchain
parent: hash # Previous message in the channel
signer: Ed25519PublicKey # Identity of message sender
CHANNEL_INSCRIBE Proof
Ed25519Signature
A signature from signer over the Mantle txhash containing this inscription.
CHANNEL_INSCRIBE Execution Gas
Channel Inscribe Operations have a fixed Execution Gas cost of
EXECUTION_CHANNEL_INSCRIBE_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
CHANNEL_INSCRIBE Validation
Given:
txhash: zkhash
msg: Inscribe
sig: Ed25519Signature
channels: dict[ChannelID, ChannelState]
Validate:
# Ensure the msg signer signature
assert Ed25519_verify(msg.signer, txhash, sig)
if msg.channel in channels:
chan = channels[msg.channel]
# Ensure signer is authorized to write to the channel
assert msg.signer is in chan.accredited_keys
# Ensure message is continuing the channel sequence
assert msg.parent == chan.tip
else:
# Channel will be created automatically upon execution
# Ensure that this message is the genesis message (parent==ZERO)
assert msg.parent == ZERO
CHANNEL_INSCRIBE Execution
Given:
msg: Inscribe
sig: Ed25519Signature
channels: dict[ChannelId, ChannelState]
Execute:
- If the channel does not exist, create it just-in-time.
if msg.channel is not in channels:
channels[msg.channel] = ChannelState(
tip=ZERO,
accredited_keys=[msg.signer]
)
- Update the channel tip.
chan = channels[msg.channel]
chan.tip = hash(encode(msg))
CHANNEL_INSCRIBE Example
Sending a greeting to all followers of Sovereign Rollup Earth.
# Build the inscription
greeting = Inscription(
channel=CHANNEL_EARTH,
inscription=b"Live long and prosper",
parent=ZERO,
signer=spock_pk
)
# Wrap it in a transaction
tx = MantleTx(
ops=[Op(opcode=INSCRIBE, payload=encode(greeting))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[<spocks_note_id>], outputs=[<change_note>]),
)
# Sign the transaction
signed_tx = SignedMantleTx(
tx=tx,
op_proofs=[Ed25519_sign(mantle_txhash(tx), spock_sk)],
ledger_tx_proof=tx.ledger_tx.prove(spock_sk)
)
# Send the transaction to the mempool
mempool.push(signed_tx)
CHANNEL_BLOB
Write a message to a channel where the message data is stored temporarily in NomosDA. Data stored in NomosDA will eventually expire but its commitment (BlobID) remains permanently on chain. Anyone with access to the original data can confirm that it matches this commitment.
CHANNEL_BLOB Payload
class Blob:
channel: ChannelID # Channel this message is written to
session: SessionNumber # Session during which dispersal happened
blob: BlobID # Blob commitment
blob_size: int # Size of blob before encoding in bytes
da_storage_gas_price: int # 8 bytes
parent: hash # Previous message written to the channel
signer: Ed25519PublicKey # Identity of the message sender
blob_size is reported here to ensure the DA Storage fee
of this transaction can still be calculated after the Blob is expired from NomosDA.
CHANNEL_BLOB Proof
Ed25519Signature
A signature from signer over the Mantle txhash containing this blob.
CHANNEL_BLOB Execution Gas
The Execution Gas consumed by a Blob Operation is proportional to the size of the sample verified by the nodes. The bigger the sample, the harder it is to verify it. The size of this sample is:
NUMBER_OF_DA_COLUMNS = 1024 # before RS encoding
ELEMENT_SIZE = 31 # in bytes
SAMPLE_SIZE = blob_size / (NUMBER_OF_DA_COLUMNS * ELEMENT_SIZE)
Channel Blob Operations have an Execution Gas cost proportional to the blob size:
EXECUTION_CHANNEL_BLOB_BASE_GAS + EXECUTION_CHANNEL_BLOB_SIZED_GAS * SAMPLE_SIZE
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
CHANNEL_BLOB DA Storage Gas
Channel Blob Operations have a DA Storage Gas consumption proportional to Blob size:
CHANNEL_BLOB_DA_STORAGE_GAS = blob_size * da_storage_gas_price
CHANNEL_BLOB Validation
Validators will perform DA sampling to ensure availability. From these samples, the Blob size can be determined and checked against what is written in the Blob payload.
Given:
txhash: zkhash
msg: Blob
sig: Ed25519Signature
block_slot: int
channels: dict[ChannelID, ChannelState]
Validate:
# Verify the msg signature
assert Ed25519_verify(msg.signer, txhash, sig)
if msg.channel in channels:
chan = channels[msg.channel]
# Ensure signer is authorized to write to the channel
assert msg.signer is in chan.accredited_keys
# Ensure message is continuing the channel sequence
assert msg.parent == chan.tip
else:
# Channel will be created automatically upon execution
# Ensure that this message is the Genesis message
assert msg.parent == ZERO
if NomosDA.should_validate_block_availability(block_slot):
# Validate Blobs that are still held in DA
assert NomosDA.validate_availability(msg.session, msg.blob)
# Derive Blob size from DA sample
actual_blob_size = NomosDA.derive_blob_size(msg.blob)
assert msg.blob_size == actual_blob_size
CHANNEL_BLOB Execution
Given:
msg: Blob
sig: Ed25519Signature
channels: dict[ChannelId, ChannelState]
Execute:
# If the channel does not exist, create it JIT
if msg.channel is not in channels:
channels[msg.channel] = ChannelState(
tip=ZERO,
accredited_keys=[msg.signer]
)
chan = channels[msg.channel]
chan.tip = hash(encode(msg))
CHANNEL_BLOB Example
Suppose a sequencer for Rollup A wants to post a Rollup update.
They would first build the Blob payload:
# Given a rollup update and the previous txhash
rollup_update: bytes = encode([tx1, tx2, tx3])
last_channel_msg_hash: hash
# The sequencer encodes the rollup update and builds the blob payload
blob_id, blob_size = NomosDA.upload_blob(rollup_update)
msg = Blob(
channel=ROLLUP_A,
current_session=current_session,
blob=blob_id,
blob_size=blob_size,
da_storage_gas_price=10,
parent=last_channel_msg_hash,
signer=sequencer_pk,
)
tx = MantleTx(
ops=[Op(opcode=BLOB, payload=encode(msg))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[sequencer_funds], outputs=[<change_note>])
)
signed_tx = SignedMantleTx(
tx=tx,
op_proofs=[sequencer_sk.sign(mantle_txhash(tx))],
ledger_tx_proof=[tx.ledger_tx.prove(sequencer_sk)]
)
The Signed Mantle Transaction is then sent to DA nodes for dispersal and added to the mempool for inclusion in a block (see NOMOS-DA-DISPERSAL).
CHANNEL_SET_KEYS
Overwrite the list of accredited keys to post Blobs to a channel.
CHANNEL_SET_KEYS Payload
class ChannelSetKeys:
channel: ChannelID
keys: list[Ed25519PublicKey]
CHANNEL_SET_KEYS Proof
Ed25519Signature # signature from `administrator` over the Mantle tx hash.
CHANNEL_SET_KEYS Execution Gas
Channel Set Keys Operations have a fixed Execution Gas cost of
EXECUTION_CHANNEL_SET_KEYS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
CHANNEL_SET_KEYS Validation
Given:
txhash: zkhash
setkeys: ChannelSetKeys
sig: Ed25519Signature
channels: dict[ChannelID, ChannelState]
Validate:
# Ensure at least one key
assert len(setkeys.keys) > 0
if setkeys.channel in channels:
chan = channels[setkeys.channel]
admin_pk = chan.accredited_keys[0]
assert Ed25519_verify(txhash, admin_pk, sig)
The first key of the list is the administration key.
CHANNEL_SET_KEYS Execution
Given:
setkeys: ChannelSetKeys
channels: dict[ChannelID, ChannelState]
Execute:
# Create the channel if it does not exist
if setkeys.channel not in channels:
channels[setkeys.channel] = ChannelState(
tip=CHANNEL_GENESIS,
accredited_keys=[],
)
# Update the set of accredited keys
channels[setkeys.channel].accredited_keys = setkeys.keys
CHANNEL_SET_KEYS Example
Suppose the administrator of Rollup A wants to add a key to the list of accredited keys:
# Given a key to add
sequencer_pk: Ed25519PublicKey
# The administrator encodes the update and builds the payload
setkeys = ChannelSetKeys(
channel=ROLLUP_A,
keys=[admin_pk, sequencer_pk],
)
tx = MantleTx(
ops=[Op(opcode=CHANNEL_SET_KEYS, payload=encode(setkeys))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[admin_funds], outputs=[<change note>])
)
signed_tx = SignedMantleTx(
tx=tx,
op_proofs=[Ed25519_sign(mantle_txhash(tx), admin_sk)],
ledger_tx_proof=tx.ledger_tx.prove(admin_sk),
)
Service Declaration Protocol (SDP) Operations
These Operations implement the NOMOS-SERVICE-DECLARATION-PROTOCOL.
Validators must keep the following state when implementing SDP Operations:
locked_notes: dict[NoteID, LockedNote]
declarations: dict[DeclarationID, DeclarationInfo]
class LockedNote:
declarations: set[DeclarationID]
locked_until: BlockNumber
Common SDP Structures
class ServiceType(Enum):
BN="BN" # Blend Network
DA="DA" # Data Availability
class Locator(str):
def validate(self):
assert len(self) <= 329
assert validate_multiaddr(self)
class MinStake:
stake_threshold: int # stake value
timestamp: int # block number
class ServiceParameters:
lock_period: int # number of blocks
inactivity_period: int # number of blocks
retention_period: int # number of blocks
timestamp: int # block number
class DeclarationInfo:
service: ServiceType
locators: list[Locator]
provider_id: Ed25519PublicKey
zk_id: ZkPublicKey
locked_note_id: NoteId
created: BlockNumber
active: BlockNumber
withdrawn: BlockNumber
# SDP ops updating a declaration must use monotonically increasing nonces
nonce: int
SDP_DECLARE
The service registration follows the definition given in NOMOS-SERVICE-DECLARATION-PROTOCOL - Declaration Message.
SDP_DECLARE Payload
class DeclarationMessage:
service_type: ServiceType
locators: list[Locator]
provider_id: Ed25519PublicKey
zk_id: ZkPublicKey
locked_note_id: NoteId
Locked notes are introduced in Locked notes and serve as Service collaterals. They cannot be spent before the owner withdraws its participation from the declared service(s).
SDP_DECLARE Proof
class DeclarationProof:
zk_sig: ZkSignature # signature proving ownership over locked note and zk_id
provider_sig: Ed25519Signature # signature proving ownership of provider key
See NOMOS-COMMON-CRYPTOGRAPHIC-COMPONENTS for the Zero Knowledge Signature Scheme (ZkSignature).
SDP_DECLARE Execution Gas
SDP Declare Operations have a fixed Execution Gas cost of
EXECUTION_SDP_DECLARE_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
SDP_DECLARE Validation
Given:
txhash: zkhash # the txhash of the transaction being validated
declaration: DeclarationMessage # the declaration being validated
proof: DeclarationProof
min_stake: MinStake # the (global) minimum stake setting
ledger: Ledger # the set of unspent notes
locked_notes: dict[NoteId, LockedNote]
declarations: dict[NoteId, DeclarationInfo]
Validate:
The declaration is verified according to NOMOS-SERVICE-DECLARATION-PROTOCOL - Declare.
- Ensure ownership over the locked note,
zk_idandprovider_id.
assert ZkSignature_verify(
txhash,
proof.zk_sig,
[note.public_key, declaration.zk_id]
)
assert Ed25519_verify(txhash, proof.provider_sig, provider_id)
- Ensure declaration does not already exist.
assert declaration_id(declaration) not in declarations
- Ensure it has no more than 8 locators.
assert len(declaration.locators) <= 8
- Ensure locked note exists and value of locked note is sufficient for joining the service.
assert ledger.is_unspent(declaration.locked_note_id)
note = ledger.get_note(declaration.locked_note_id)
assert note.value >= min_stake.stake_threshold
- Ensure the note has not already been locked for this service.
if declaration.locked_note in locked_notes:
locked_note = locked_notes[declaration.locked_note]
services = [declarations[declare_id] for declare_id in locked_note.declarations]
assert declaration.service_type not in services
SDP_DECLARE Execution
Given:
declaration: DeclarationMessage # the declaration being executed
service_parameters: dict[ServiceType, ServiceParameters]
current_block_height: int
locked_notes: dict[NoteId, LockedNote]
Execute:
- Create the locked note state if it doesn't already exist.
if declaration.locked_note not in locked_notes:
locked_notes[declaration.locked_note_id] = \
LockedNote(declarations=set(), locked_until=0)
locked_note = locked_notes[declaration.locked_note_id]
- Update the locked notes timeout using this services lock period.
lock_period = service_parameters[declaration.service_type].lock_period
service_lock = current_block_height + lock_period
locked_note.locked_until = max(service_lock, locked_note.locked_until)
- Add this declaration to the locked note.
declare_id = declaration_id(declaration)
locked_note.declarations.add(declare_id)
- Store the declaration as explained in NOMOS-SERVICE-DECLARATION-PROTOCOL - Declaration Storage.
declarations[declare_id] = DeclarationInfo(
service: declaration.service,
locators: declaration.locators,
provider_id: declaration.provider_id,
zk_id: declaration.zk_id,
locked_note_id: declaration.locked_note_id,
created=current_block_height,
active=current_block_height,
withdrawn=0,
nonce=0
)
Notice that locked notes cannot refresh their keys to update their slot secrets required for Proof of Leadership participation (see NOMOS-PROOF-OF-LEADERSHIP - Protection Against Adaptive Adversaries). It's RECOMMENDED to refresh the note before locking it, which guarantees a key life of more than a year. After this period, the note cannot be used in PoL until its private key is refreshed (see leader key setup).
SDP_DECLARE Example
# Assume `alice_note` is in the ledger:
alice_note = Utxo(
txhash=0x2948904F2F0F479B8F8197694B30184B0D2ED1C1CD2A1EC0FB85D299A,
output_number=3,
note=Note(value=500, public_key=alice_pk_1),
)
# Alice wishes to lock it to join the DA network
declaration = DeclarationMessage(
service_type=ServiceType.DA,
locators=["/ip4/203.0.113.10/tcp/4001"],
provider_id=alice_provider_pk,
zk_id=alice_pk_2,
locked_note_id=alice_note.id()
)
tx = MantleTx(
ops=[Op(opcode=SDP_DECLARE, payload=encode(declaration))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[fee_note_id], outputs=[]),
)
txhash = mantle_txhash(tx)
declaration_proof = DeclarationProof(
# proof of ownership of the staked note and zk_id
zk_sig=ZkSignature([alice_sk_1, alice_sk_2], txhash),
# proof of ownership of the provider id
provider_sig=Ed25519Signature(alice_provider_sk, txhash),
)
SignedMantleTx(
tx=tx,
ledger_tx_proof=LedgerTxProof,
op_proofs=[declaration_proof],
ledger_proof=prove_ledger_tx(tx.ledger_tx, [alice_sk_1]),
)
SDP_WITHDRAW
The service withdrawal follows the definition given in NOMOS-SERVICE-DECLARATION-PROTOCOL - Withdraw Message.
SDP_WITHDRAW Payload
class WithdrawMessage:
declaration: DeclarationID
locked_note_id: NoteId
nonce: int
SDP_WITHDRAW Proof
A signature from the zk_id and the locked note pk attached to the declaration
is required for withdrawing from a service,
(see NOMOS-COMMON-CRYPTOGRAPHIC-COMPONENTS).
ZkSignature
SDP_WITHDRAW Execution Gas
SDP Withdraw Operations have a fixed Execution Gas cost of
EXECUTION_SDP_WITHDRAW_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
SDP_WITHDRAW Validation
Given:
txhash: zkhash # Mantle transaction hash of the tx containing this operation
withdraw: WithdrawMessage
signature: ZkSignature
block_height: int # block height of the current block
ledger: Ledger
locked_notes: dict[NoteId, LockedNote]
declarations: dict[DeclarationID, DeclarationInfo]
Validate:
- Ensure that the locked note exists, is locked and bound to this declaration.
assert ledger.is_unspent(withdraw.locked_note_id)
assert withdraw.locked_note_id in locked_notes
locked_note = locked_notes[withdraw.locked_note_id]
assert withdraw.declaration in locked_note.declarations
- Ensure that the locked note has expired.
assert locked_note.locked_until <= block_height
- Validate SDP withdrawal according to NOMOS-SERVICE-DECLARATION-PROTOCOL - Withdraw.
a. Ensure declaration exists.
assert withdraw.declaration in declarations
declare_info = declarations[withdraw.declaration]
b. Ensure locked note pk and zk_id attached to this declaration authorized this Operation.
locked_note = ledger[withdraw.locked_note_id]
assert ZkSignature_verify(txhash, signature, [locked_note.pk, declare_info.zk_id])
c. Ensure the declaration has not already been withdrawn.
assert declare_info.withdrawn == 0
d. Ensure that the nonce is greater than the previous one.
assert withdraw.nonce > declare_info.nonce
SDP_WITHDRAW Execution
Given:
withdraw: WithdrawMessage
signature: ZkSignature
block_height: int # block height of the current block
ledger: Ledger
locked_notes: dict[NoteId, LockedNote]
declarations: dict[DeclarationID, DeclarationInfo]
Execute:
Executes the withdrawal protocol NOMOS-SERVICE-DECLARATION-PROTOCOL - Withdraw.
- Update declaration info with nonce and withdrawn timestamp.
declare_info = declarations[withdraw.declaration]
declare_info.nonce = withdraw.nonce
declare_info.withdrawn = block_height
- Remove this declaration from the locked note.
locked_note = locked_notes[withdraw.locked_note_id]
locked_note.declarations.remove(withdraw.declaration)
- Remove the locked note if it is no longer bound to any declarations.
if len(locked_note.declarations) == 0:
del locked_notes[withdraw.locked_note_id]
SDP_WITHDRAW Example
withdraw = Withdraw(
declaration=alice_declaration_id,
locked_note_id=alices_locked_note_id,
nonce=1579532
)
tx = MantleTx(
ops=[Op(opcode=SDP_WITHDRAW, payload=encode(withdraw))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(
inputs=[alices_locked_note_id],
outputs=[Note(100, alice_note_pk)]
),
)
SignedMantleTx(
tx=tx,
ledger_tx_proof=tx.ledger_tx.prove(alice_sk),
# proof ownership of the withdrawn note and zk id
op_proofs=[ZkSignature_sign([alice_note_sk, alice_sk], mantle_txhash(tx))]
)
SDP_ACTIVE
The service active action follows the definition given in NOMOS-SERVICE-DECLARATION-PROTOCOL - Active Message.
SDP_ACTIVE Payload
class Active:
declaration: DeclarationID
nonce: int
metadata: bytes # a service-specific node activeness metadata
SDP_ACTIVE Proof
ZkSignature
Signature from the zk_id attached to the declaration over the transaction hash.
SDP_ACTIVE Execution Gas
SDP Active Operations have a fixed Execution Gas cost of
EXECUTION_SDP_ACTIVE_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
SDP_ACTIVE Validation
Given:
txhash: zkhash # Mantle transaction hash of the tx containing this operation
active: Active
signature: ZkSignature
declarations: dict[DeclarationId, DeclarationInfo]
Validate:
assert active.declaration in declarations
declaration_info = declarations[active.declaration]
assert active.nonce > declaration_info.nonce
assert ZkSignature_verify(txhash, signature, declaration_info.zk_id)
SDP_ACTIVE Execution
Executes the active protocol
NOMOS-SERVICE-DECLARATION-PROTOCOL - Active.
The activation,
i.e. setting the declaration.active,
is handled by the service-specific logic.
SDP_ACTIVE Example
active = Active(
declaration=alice_declaration_id,
nonce=1579532,
metadata=b"Look, I am still doing my job"
)
tx = MantleTx(
ops=[Op(opcode=SDP_ACTIVE, payload=encode(active))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[fee_note_id], outputs=[]),
)
txhash = mantle_txhash(tx)
SignedMantleTx(
tx=tx,
ledger_tx_proof=tx.ledger_tx.prove(fee_note_sk),
op_proofs=[Ed25519_sign(txhash, validator_sk)]
)
Leader Operations
LEADER_CLAIM
This Operation claims the leader's block reward anonymously.
LEADER_CLAIM Payload
class ClaimRequest:
rewards_root: zkhash # Merkle root used in the proof for voucher membership
voucher_nf: zkhash
LEADER_CLAIM Proof
The provider proves that they have won a proof of Leadership before the start of the current epoch, i.e., their reward voucher is indeed in the voucher set.
LEADER_CLAIM Execution Gas
Leader Claim Operations have a fixed Execution Gas cost of
EXECUTION_LEADER_CLAIM_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
LEADER_CLAIM Validation
# Given
mantle_txhash: zkhash
claim: ClaimRequest
last_voucher_root: zkhash # The last root of the voucher Merkle tree at the start of the epoch
voucher_nullifier_set: set[zkhash]
proof: ProofOfClaim
# Validate
assert claim.voucher_nf not in voucher_nullifier_set
assert claim.rewards_root == last_voucher_root
validate_proof(claim, proof, mantle_txhash)
LEADER_CLAIM Execution
-
Add
claim.voucher_nfto thevoucher_nullifier_set. -
Increase the balance of the Mantle Transaction by the leader reward amount according to NOMOS-ANONYMOUS-LEADERS-REWARD-PROTOCOL - Leaders Reward.
-
Reduce the leader's reward
leaders_rewardsvalue by the same amount (without ZK proof).
LEADER_CLAIM Example
secret_voucher = 0xDEADBEAF
reward_voucher = leader_claim_voucher(secret_voucher)
voucher_nullifier = leader_claim_nullifier(secret_voucher)
claim = ClaimRequest(
rewards_root=REWARDS_MERKLE_TREE.root(),
voucher_nf=voucher_nullifier,
)
tx = MantleTx(
ops=[Op(opcode=LEADER_CLAIM, payload=encode(claim))],
permanent_storage_gas_price=150,
execution_gas_price=70,
ledger_tx=LedgerTx(inputs=[<fee_note>], outputs=[<change_note>]),
)
claim_proof = claim.prove(
secret_voucher,
REWARDS_MERKLE_TREE.path(leaf=reward_voucher),
mantle_txhash(tx)
)
SignedMantleTx(
tx=tx,
ledger_tx_proof=tx.ledger_tx.prove(fee_note_sk),
op_proofs=[claim_proof]
)
Mantle Ledger
Notes
Notes are composed of two fields representing their value and their owner:
class Note:
value: int # 8 bytes
public_key: ZkPublicKey # 32 bytes
Note Id
Any note can be uniquely identified
by the Ledger Transaction that created it and its output number:
(txhash, output_number).
However,
it is often useful to have a commitment to the note fields
for use in ZK proofs (e.g., for PoL),
so the note is included in the note identifier derivation.
def derive_note_id(txhash: zkhash, output_number: int, note: Note) -> NoteId:
return zkhash(
FiniteField(b"NOMOS_NOTE_ID_V1", byte_order="little", modulus=p),
txhash,
FiniteField(output_number, byte_order="little", modulus=p),
FiniteField(note.value, byte_order="little", modulus=p),
note.public_key
)
These note identifiers uniquely define notes in the system and cannot be chosen by the user. Nodes maintain the set of notes through a dictionary mapping the NoteId to the note.
Locked notes
Locked notes are special notes in Mantle that serve as collateral for Service Declarations. A note can become locked after executing a Declare Operation, preventing it from being spent until explicitly released through a Withdraw Operation. The system maintains a mapping of locked note IDs to their supporting declarations. Though locked, these notes remain in the Ledger and can still participate in Proof of Stake. When service providers withdraw all their declarations, the associated note(s) become unlocked and available for spending again.
Ledger Transactions
Transactions must prove the ownership of spent notes. In classical blockchains, this is done through a signature. To stay compatible with this architecture, the signature is done by a ZK proof (see NOMOS-COMMON-CRYPTOGRAPHIC-COMPONENTS), proving the knowledge of the secret key associated with the public key.
Transactions allow complete transaction linkability and the public key spending the note is not hidden.
LedgerTx Structure
class LedgerTx:
inputs: list[NoteId] # the list of consumed note identifiers
outputs: list[Note]
LedgerTx Proof
A transaction proves the ownership of the consumed notes using a Zero Knowledge Signature Scheme (ZkSignature).
ZkSignature
ZkSignature proving ownership over the input notes and signing the Mantle Transaction containing this Ledger Transaction.
LedgerTx Execution Gas
Ledger Transactions have a fixed Execution Gas cost of
EXECUTION_LEDGER_TX_GAS.
See NOMOS-GAS-COST-DETERMINATION for the Execution Gas values.
LedgerTx Hash
def ledger_txhash(tx: LedgerTx) -> ZkHash:
tx_bytes = encode(tx)
h = Hasher()
h.update(FiniteField(b"NOMOS_LEDGER_TXHASH_V1", byte_order="little", modulus=p))
for i in range((len(tx_bytes)+30)//31):
chunk = tx_bytes[i*31:(i+1)*31]
fr = FiniteField(chunk, byte_order="little", modulus=p)
h.update(fr)
return h.digest()
Ledger Validation
Given:
mantle_txhash: ZkHash # ZkHash of mantle tx containing this ledger tx
ledger_tx: LedgerTx
ledger_tx_proof: ZkSignature
ledger: Ledger
locked_notes: dict[NoteId, LockedNote]
Validate:
- Ensure all inputs are unspent.
assert all(ledger.is_unspent(note_id) for note_id in ledger_tx.inputs)
- Validate ledger proof to show ownership over input notes.
input_notes = [ledger[input_note_id] for input_note_id in ledger_tx.inputs]
input_pks = [note.public_key for note in input_notes]
assert ZkSignature_verify(mantle_txhash, ledger_tx_proof, input_pks)
- Ensure inputs are not locked.
# Ensure inputs are not locked
for note_id in ledger_tx.inputs:
assert note_id not in locked_notes
- Ensure outputs are valid.
for output in ledger_tx.outputs:
assert output.value > 0
assert output.value < 2**64
Ledger Execution
Given:
ledger_tx: LedgerTx
ledger_tx_proof: ZkSignature
ledger: Ledger
Execute:
- Remove inputs from the ledger.
for note_id in ledger_tx.inputs:
# updates the merkle tree to zero out the leaf for this entry
# and adds that leaf index to the list of unused leaves
ledger.remove(note_id)
- Add outputs to the ledger.
txhash = ledger_txhash(ledger_tx)
for (output_number, output_note) in enumerate(tx.outputs):
output_note_id = derive_note_id(txhash, output_number, output_note)
ledger.add(output_note_id)
Ledger Example
alice_note_id = ... # assume Alice holds a note worth 501 NMO
bob_note = Note(
value=500,
public_key=bob_pk,
)
ledger_tx = LedgerTx(
inputs=[alice_note_id],
outputs=[bob_note],
)
Appendix
Gas Determination
From NOMOS-GAS-COST-DETERMINATION, the following gas values are used:
| Variable | Value |
|---|---|
| EXECUTION_LEDGER_TX_GAS | 590 |
| EXECUTION_CHANNEL_INSCRIBE_GAS | 56 |
| EXECUTION_CHANNEL_BLOB_BASE_GAS | 6356 |
| EXECUTION_CHANNEL_BLOB_SIZED_GAS | 1600 |
| EXECUTION_CHANNEL_SET_KEYS | 56 |
| EXECUTION_SDP_DECLARE_GAS | 646 |
| EXECUTION_SDP_WITHDRAW_GAS | 590 |
| EXECUTION_SDP_ACTIVE_GAS | 590 |
| EXECUTION_LEADER_CLAIM_GAS | 580 |
Zero Knowledge Signature Scheme (ZkSignature)
A proof attesting that for the following public values:
class ZkSignaturePublic:
public_keys: list[ZkPublicKey] # public keys signing the message (len = 32)
msg: zkhash # zkhash of the message
The prover knows a witness:
class ZkSignatureWitness:
# The list of secret keys used to sign the message
secret_keys: list[ZkSecretKey] # (len = 32)
Such that the following constraints hold:
- The number of secret keys is equal to the number of public keys.
assert len(secret_keys) == len(public_keys)
- Each public key is derived from the corresponding secret key.
assert all(
notes[i].public_key == zkhash(
FiniteField(b"NOMOS_KDF", byte_order="little", modulus=p),
secret_keys[i])
for i in range(len(public_keys))
)
- The proof is bound to
msg(themantle_tx_hashin case of transactions).
For implementation, the ZkSignature circuit will take a maximum of 32 public keys as inputs. To prove ownership of fewer keys, the remaining inputs will be padded with the public key corresponding to the secret key 0 and ignored during execution. The outputs have no size limit since they are included in the hashed message.
Proof of Claim
A proof attesting that given these public values:
class ProofOfClaimPublic:
voucher_root: zkhash # Merkle root of the reward_voucher maintained by everyone
voucher_nullifier: zkhash
mantle_tx_hash: zkhash # attached hash
The prover knows the following witness:
class ProofOfClaimWitness:
secret_voucher: zkhash
voucher_merkle_path: list[zkhash]
voucher_merkle_path_selectors: list[bool]
Such that the following constraints hold:
- The reward voucher is derived from the secret voucher.
assert reward_voucher == zkhash(
FiniteField(b"REWARD_VOUCHER", byte_order="little", modulus=p),
secret_voucher)
- There exists a valid Merkle path from the reward voucher as a leaf to the Merkle root.
assert voucher_root == path_root(
leaf=reward_voucher,
path=voucher_merkle_path,
selectors=voucher_merkle_path_selectors)
- The voucher nullifier is derived from the secret voucher correctly.
assert voucher_nullifier == zkhash(
FiniteField(b"VOUCHER_NF", byte_order="little", modulus=p),
secret_voucher)
- The proof is bound to the
mantle_tx_hash.
References
Normative
- RFC 2119: Key words for use in LIPs to Indicate Requirement Levels
- NOMOS-COMMON-CRYPTOGRAPHIC-COMPONENTS: Common Cryptographic Components
- NOMOS-GAS-COST-DETERMINATION: Gas Cost Determination
- NOMOS-SERVICE-DECLARATION-PROTOCOL: Service Declaration Protocol
- NOMOS-DA-DISPERSAL: NomosDA Dispersal
- NOMOS-PROOF-OF-LEADERSHIP: Proof of Leadership Specification
- NOMOS-ANONYMOUS-LEADERS-REWARD-PROTOCOL: Anonymous Leaders Reward Protocol
Informative
- v1.1 Mantle Specification: Original specification document
Copyright
Copyright and related rights waived via CC0.
CRYPTARCHIA-FORK-CHOICE
| Field | Value |
|---|---|
| Name | Cryptarchia Fork Choice Rule |
| Slug | 147 |
| Status | raw |
| Category | Standards Track |
| Editor | David Rusu [email protected] |
| Contributors | Jimmy Debe [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
0ef87b1— New RFC: CODEX-MANIFEST (#191) - 2026-01-29 —
a428c03— New RFC: NOMOS-FORK-CHOICE (#247)
Abstract
This document describes the consensus mechanism of the fork choice rule, followed by nodes in the Cryptarchia protocol. Cryptarchia implements two fork choice rules, one during node bootstrapping and the second fork choice once a node is connected to the network.
Keywords: fork choice, Cryptarchia, Ouroboros Genesis, Ouroboros Praos, bootstrapping, long-range attack, consensus
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Term | Description |
|---|---|
| $k$ | Safety parameter, the depth at which a block is considered immutable. |
| $s_{gen}$ | Sufficient time measured in slots to measure the density of block production with enough statistical significance. In practice, $s_{gen} = \frac{k}{4f}$, where $f$ is the active slot coefficient from the leader lottery. See Theorem 2 of Badertscher et al., 2018 for more information. |
| CommonPrefixDepth | A function $\textbf{CommonPrefixDepth}(b_1, b_2) \rightarrow (\mathbb{N}, \mathbb{N})$ that returns the minimum block depth at which the two branches converge to a common chain. |
| density | A function $\textbf{density}(b_i, d, s_{gen})$ that returns the number of blocks produced in the $s_{gen}$ slots following block $b_{i-d}$. |
Background
In blockchain networks, the consensus process may encounter multiple competing branches (forks) of the blockchain state. A Nomos node maintains a local copy of the blockchain state and connects to a set of peers to download new blocks.
During bootstrapping, Cryptarchia v1 implements Ouroboros Genesis and Ouroboros Praos for the fork choice mechanism. These translate to two fork choice rules, the bootstrap rule and the online rule. This approach is meant to help nodes defend against malicious peers feeding false chains to download. This calls for a more expensive fork choice rule that can differentiate between malicious long-range attacks and the honest chain.
The Long Range Attack
The protocol has a time window for a node, which is the lottery leader winner, to complete a new block. Nodes with more stake have a higher probability of being selected through the lottery. The lottery difficulty is determined by protocol parameters and the node's stake. The leadership lottery difficulty will adjust dynamically based on the total stake that is participating in the consensus at the time. The scenario, which this fork choice rule solves, is when an adversary forks the chain and generates a very sparse branch where he is the only winner for an epoch. This fork would be very sparse since the attacker does not control a large amount of stake initially.
Each epoch, the lottery difficulty is adjusted based on participation in the previous epoch to maintain a target block rate. When this happens on the adversary's chain, the lottery difficulty will plummet and he will be able to produce a chain that has a similar growth rate to the main chain. The advantage is that his chain is more efficient. Unlike the honest chain, which needs to deal with unintentional forks caused by network delays, the adversary's branch has no wasted blocks.
With this advantage, the adversary can eventually make up for that sparse initial period and extend his fork until it's longer than the honest chain. He can then convince bootstrapping nodes to join his fork, where he has had a monopoly on block rewards.
Genesis Fork Choice Rule Mitigation
When the honest branch and the adversary branch are in the period immediately following the fork, the honest chain is dense and the adversary's fork will be quite sparse. If an honest node had seen the adversary's fork in that period, it would not have followed this fork since the honest chain would be longer, so selecting the fork using the longest chain rule is fine for a short-range fork.
If an honest node sees the adversary's fork after he's completed the attack, the longest chain rule is no longer enough to protect them. Instead, the node can look at the density of both chains in that short period after they diverge and select the chain with the higher density of blocks.
Praos Fork Choice Rule Mitigation
Under two assumptions:
- A node has successfully bootstrapped and found the honest chain.
- Nodes see honest blocks reasonably quickly.
Nodes will remain on the honest chain if they reject forks that diverge further back than $k$ blocks, without further inspection. In order for an adversary to succeed, they would need to build a $k$-deep chain faster than the time it takes the honest nodes to grow the honest chain by $k$ blocks. The adversary must build this chain live, alongside the honest chain. They cannot build this chain after-the-fact, since online nodes will be rejecting any fork that diverges before their $k$-deep block.
Protocol Specification
CommonPrefixDepth Examples
- $\textbf{CommonPrefixDepth}(b_1, b_2) = (0, 4)$ implies that $b_2$ is ahead of $b_1$ by 4 blocks.

- $\textbf{CommonPrefixDepth}(b_2, b_5) = (2, 3)$ would represent a forking tree like the one illustrated below:

- $\textbf{density}(b_i, d, s_{gen})$ returns the number of blocks produced in the $s_{gen}$ slots following block $b_{i-d}$. For example, in the following diagram, count the number of blocks produced in the $s_{gen}$ slots of the highlighted area.

Bootstrap Fork Choice Rule
During bootstrapping, the Ouroboros Genesis fork choice rule (maxvalid-bg) is used.
def bootstrap_fork_choice(c_local, forks, k, s_gen):
c_max = c_local
for c_fork in forks:
depth_max, depth_fork = common_prefix_depth(c_max, c_fork)
if depth_max <= k:
# the fork depth is less than our safety parameter `k`. It's safe
# to use longest chain to decide the fork choice.
if depth_max < depth_fork:
# strict inequality to ensure to choose first-seen chain as the tie break
c_max = c_fork
else:
# here the fork depth is larger than our safety parameter `k`.
# It's unsafe to use the longest chain here, instead check the density
# of blocks immediately after the divergence.
if density(c_max, depth_max, s_gen) < density(c_fork, depth_fork, s_gen):
# The denser chain immediately after the divergence wins.
c_max = c_fork
return c_max
Online Fork Choice Rule
When bootstrap-rule is complete,
a node SHOULD switch to the online-rule.
See CRYPTARCHIA-V1-BOOTSTRAPPING-SYNCHRONIZATION for more information on bootstrapping.
With the online-rule flag,
the node SHOULD now reject any forks that diverge further back than $k$ blocks.
def online_fork_choice(c_local, forks, k):
c_max = c_local
for c_fork in forks:
depth_max, depth_fork = common_prefix_depth(c_max, c_fork)
if depth_max <= k:
# the fork depth is less than our safety parameter `k`. It's safe
# to use the longest chain to decide the fork choice.
if depth_max < depth_fork:
# strict inequality to ensure to choose the first-seen chain as our tie break
c_max = c_fork
else:
# The fork depth is larger than our safety parameter `k`.
# Ignore this fork.
continue
return c_max
Security Considerations
Long-Range Attack Resistance
The bootstrap fork choice rule provides resistance against long-range attacks by comparing chain density in the period immediately following divergence. Implementations MUST use the Genesis fork choice rule during bootstrapping to protect against adversaries who have built alternative chains over extended periods.
Safety Parameter Selection
The safety parameter $k$ determines the depth at which blocks are considered immutable. Implementations SHOULD choose $k$ based on the expected network conditions and the desired security guarantees. A larger $k$ provides stronger security but requires longer confirmation times.
Online Rule Assumptions
The online fork choice rule assumes that nodes have successfully bootstrapped and are receiving honest blocks in a timely manner. If these assumptions are violated, nodes MAY be vulnerable to attacks and SHOULD fall back to the bootstrap rule.
References
Normative
- CRYPTARCHIA-V1-BOOTSTRAPPING-SYNCHRONIZATION - Bootstrapping and synchronization protocol
Informative
- Ouroboros Genesis - Composable Proof-of-Stake Blockchains with Dynamic Availability
- Ouroboros Praos - An adaptively-secure, semi-synchronous proof-of-stake blockchain
- Cryptarchia Fork Choice Rule - Original specification
Copyright
Copyright and related rights waived via CC0.
CRYPTARCHIA-PROOF-OF-LEADERSHIP
| Field | Value |
|---|---|
| Name | Cryptarchia Proof of Leadership Specification |
| Slug | 83 |
| Status | raw |
| Category | Standards Track |
| Editor | Thomas Lavaur [email protected] |
| Contributors | Mehmet [email protected], Giacomo Pasini [email protected], Daniel Sanchez Quiros [email protected], Álvaro Castro-Castilla [email protected], David Rusu [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
The Proof of Leadership (PoL) enables a leader to produce a zero-knowledge proof attesting to the fact that they have an eligible note (a representation of stake) that has won the leadership lottery. This proof is designed to be as lightweight as possible to generate and verify, to impose minimal restrictions on access to the role of leader and maximize the decentralization of that role. This document specifies the PoL mechanism for Cryptarchia, extending the work presented in the Ouroboros Crypsinous paper with recent cryptographic developments.
Keywords: Cryptarchia, proof-of-leadership, zero-knowledge, consensus, note, stake, lottery, Merkle tree
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Background
Protocol Overview
The PoL mechanism ensures that a note has legitimately won the leadership election while protecting the leader's privacy. The protocol is comprised of two parts: setup and PoL generation.
Setup:
- Draw uniformly a random seed.
- Construct a Merkle tree composed of slot secrets derived from the seed.
- Use the root of the tree and the starting slot to get the leader's secret key. The starting slot is when the note can start to be used for PoL.
- The leader receives their stake in a note that uses this generated secret key. The leader either transfers this stake to themselves or obtains it from a different user.
- The note becomes eligible for Proof of Stake (PoS) when it has aged sufficiently, and the actual slot number is greater than or equal to the starting slot of the note.
PoL generation:
- First, check if the note is winning by simulating the lottery.
- Prove the membership of the note identifier in an old snapshot of the Mantle Ledger, proving its age and its existence.
- Prove the membership of the note identifier in the most recent Mantle ledger, proving it's unspent.
- Prove that the note won the PoS lottery.
- Prove the knowledge of the slot secret for the winning slot.
- The proof is bound to a cryptographic public key used for signing the leader's proposed blocks.
Comparison with Original Crypsinous PoL
This description differs from the original paper proposition, proving that a note is unspent directly instead of delegating the verification to validators. This design choice brings the following tradeoffs:
Advantages:
-
The ledger isn't required to be private using shielded notes.
- Validators don't need to maintain a nullifier list.
- Leaders keep their privacy unlinking their stake, block and PoL.
-
There is no leader note evolution mechanism anymore (see the paper for details).
- There are no orphan proofs anymore, removing the need to include valid PoL proofs from abandoned forks.
- Crypsinous required maintaining a parallel note commitment set integrating evolving notes over time. This requirement is removed now.
- The derivation of the slot secret and its Merkle proof can be done locally without connection to the Nomos chain.
Disadvantages:
- The PoL cannot be computed far in advance because the leader MUST know the latest ledger state of Mantle.
Protocol
Protection Against Adaptive Adversaries
The Ouroboros Crypsinous paper integrates protection against adaptive adversaries:
The design has several subtleties since a critical consideration in the PoS setting is tolerating adaptive corruptions: this ensures that even if the adversary can corrupt parties in the course of the protocol execution in an adaptive manner, it does not gain any non-negligible advantage by e.g., re-issuing past PoS blocks. (p. 2)
To avoid a leaked note being reused to maliciously regenerate past PoLs, this specification adopts the solution proposed in the paper using slightly different parameters.
The solution proposed in the paper is as follows:
We solve the former issue, by adding a cheap key-erasure scheme into the NIZK for leadership proofs. Specifically, parties have a Merkle tree of secret keys, the root of which is hashed to create the corresponding public key. The Merkle tree roots acts like a Zerocash coin secret key, and can be used to spend coins. For leadership however, parties also must prove knowledge of a path in the Merkle tree to a leaf at the index of the slot they are claiming to lead. After a slot passes, honest parties erase their preimages of this part of that path in the tree. As the size of this tree is linear with the number of slots, we allow parties to keep it small, by restricting its size. (p. 5)
The paper proposed a tree of depth 24.
- This implies that the note is usable for PoS for only 194 days approximately (because 1 slot is 1 second).
- After this period, the note MUST be refreshed to include new randomness. For simplicity, the refresh mechanism is designed as a classical transaction modifying the nullifier secret key.
- This solution has good performance:
For a reasonable value of $R = 2^{24}$, this is of little practical concern. Public keys are valid for $2^{24}$ slots and employing standard space/time trade-offs, key updates take under 10,000 hashes, with less than 500kB storage requirement. The most expensive part of the process, key generation, still takes less than a minute on a modern CPU. (p. 29)
The disadvantages of this solution are that:
- The public key of the note will change periodically (each time all slot secrets are consumed) for the ones participating in PoL.
- The note will not be reusable directly after refresh as only old enough notes are usable for PoS.
This specification proposes a tree with a depth of 25, extending the note's eligibility to around 388 days, with a maximum of two epochs remaining ineligible not counted in these days. Note that this requirement applies specifically to proving leadership in PoS and is not needed for every note. While any note can be used for PoL, the knowledge of the secret slots behind the public key is only necessary to demonstrate that you are a leader.
Setup: When refreshing their notes, potential leaders will:
-
Uniformly randomly draw a seed $r_1 \xleftarrow{$} \mathbb{F}_p$.
-
Construct a Merkle Tree of root $R$ containing $2^{25}$ slot secrets (that are random numbers). One way to efficiently construct the tree is to:
-
Derive the slot secrets using a zkhash chain: $\forall i \in [2, 2^{25}], r_i := \text{hash}(r_{i-1})$.
- More concretely, each leaf is the zkhash of the previous leaf (slot secret).
-
This reduces storage requirements compared to directly randomly drawn independently $2^{25}$ slot secrets.
- The first generation of the Merkle tree should be fast enough as it only requires hashing data. A correct implementation that erases data over time could maintain an upper bound in memory usage during the generation of the tree to only $\log_2(2^{25}) = 25$ zk hashes which is 800 bytes.
- Leaders are only required to maintain the MMR up to the current slot. This means at minimum, leaders hold only 25 hashes in memory at any point in time.
- After the first generation, the wallets optimize their storage by holding only the necessary information to maintain a correct Merkle path, deriving the next one over time using the fact that slot secrets were derived from the previous ones.
-
It guarantees protection against adaptive adversaries.
- Thanks to the pseudo-random properties of the hash function, slot secrets are indistinguishable from true randomness.
- The one-way property of the hash function guarantees that an adaptive adversary cannot retrieve past slot secrets using a fresher one.
-
-
The user chooses a starting slot $sl_{start}$ from which their note will be eligible for PoS.
The note MUST be on-chain by the start of epoch $ep - 1$ to be eligible for PoL in epoch $ep$ because of the age requirement. Based on this, we suggest $sl_{start}$ to not be earlier than the start of the epoch following the one after the transaction is emitted. This prevents the inclusion of unusable slot secrets in the tree (because the note would not be aged enough), optimizing the PoL lifetime of the note.
-
Finally, they derive their secret key $sk := \text{hash}(\text{NOMOS_POL_SK_V1}||sl_{start}||R)$, binding the starting slot and the Merkle tree of slot secret to the note secret key. This is verified in Circuit Constraints.
These four steps are summarized in the following pseudo-code:
def pol_sk_gen(sl_start, seed):
frontier_nodes = MMR()
path = MerkleProof()
# Generate 2^25 slot secrets using a hash chain initialized with `seed`.
r = zkhash(seed)
for i in range(2**25):
frontier_nodes.append(r) # Append the slot secret to the MMR
path.update(frontier_nodes) # Update Merkle path of this slot secret
r = zkhash(r) # Derive the next slot secret
# Derive the root of the MMR
root = frontier_nodes.get_root()
# Finally, derive the final PoL secret key.
# Return the secret key and the Merkle proof of seed.
return (zkhash(b"NOMOS_POL_SK_V1" + sl_start + root), path)
def update_secret_and_path(r, path):
r = zkhash(r) # Derive next slot secret
path.update(r) # Update the path for the Merkle proof of the new slot secret
return (r, path)
Note that the generation of the slot secret tree is not constrained by proofs or at consensus level and can be adapted by the node as long as they are able to derive the merkle proof of their slot secret.
PoL: When proving the leadership election, note owners will prove knowledge of the slot secret corresponding to the slot $sl$.
- To do that, they will give a Merkle path from the leaf at index $sl - sl_{start}$.
- The root of the tree hashed with $sl_{start}$ MUST be the secret key $sk$, which will be used for public key derivation.
Protection against adaptive adversaries: Since each slot has its own slot secret, requiring wallets to delete slot secrets used for previous slots avoids the risk of corruption that leads to the creation of PoL for previous blocks.
- The slot secret is derived from the previous one but the opposite is impossible.
- An adaptive adversary corrupting the node would not have access to previous slot secrets if correctly deleted. Therefore, an adversary would not be able to generate the PoL for previous slots.
Ledger Root
In order to prove that the winning note exists in the ledger and existed at the start of the previous epoch, every node MUST compute two ledger commitments. These commitments $ledger_{AGED}$ and $ledger_{LATEST}$ are Merkle roots constructed over the Note IDs. The trees have a depth of 32 and are populated with note IDs. The value 0 represents an empty leaf. When the set is updated, during insertion, the first empty leaf is replaced with the new note ID, and during deletion, the leaf containing the deleted note ID is replaced with 0.
The following pseudo-code shows how the tree is managed:
def insert_new_note(note_set: list[NoteId], new_note: NoteId):
i = 0
while i < len(note_set) and note_set[i] != 0:
i += 1
if i < len(note_set):
note_set[i] = new_note
else:
note_set.append(new_note)
return note_set
def delete_note(note_set: list[NoteId], note: NoteId):
i = 0
while i < len(note_set) and note_set[i] != note:
i += 1
if i == len(note_set):
# note not in the set
return note_set
note_set[i] = 0
return note_set
def empty_tree_root(depth: int):
root = 0
for i in range(depth):
h = hasher() # zk hash
h.update(root)
h.update(root)
root = h.digest()
return root
def get_ledger_root(note_set: list[NoteId]):
assert(len(note_set) < 2**32)
ledger_root = get_merkle_root(note_set)
# return the Merkle root of the set padded with 0 to next power of 2
ledger_root_height = len(note_set).bit_length()
for height in range(ledger_root_height, 32):
h = Hasher() # zk hash
h.update(ledger_root)
h.update(empty_tree_root(height))
ledger_root = h.digest()
return ledger_root
The ledger root may not be unique because the note IDs set can cycle. Indeed, even if it's not possible to insert the same note ID twice, it's possible to cycle on a previous set state by removing notes. However, note IDs uniqueness guarantees protection against attacks on note aging.
Zero-knowledge Proof Statement

Circuit Public Inputs
The prover (the leader) and the verifiers (nodes of the chain) MUST agree on these values:
-
The slot number: $sl$.
-
The epoch nonce: $\eta$.
- For details see Cryptarchia v1 Protocol Specification - Epoch Nonce.
-
The lottery function constants: $t_0 = -\frac{\text{VRF_order} \cdot \ln(1-f)}{\text{inferred_total_stake}}$ and $t_1 = -\frac{\text{VRF_order} \cdot \ln^2(1-f)}{2 \cdot \text{inferred_total_stake}^2}$.
- For details see Lottery Approximation.
- These numbers MUST be computed with high precision outside the proof.
-
The root of the note Merkle tree when the stake distribution was frozen: $ledger_{AGED}$.
- For details see Cryptarchia v1 Protocol Specification - Epoch State Pseudocode.
-
The latest root of the note Merkle tree: $ledger_{LATEST}$.
- Used to ensure the leadership note has not been spent.
-
The leader's one-time public key $P_{LEAD}$ represented by 2 public inputs, each of 16 bytes in little endian. This key is needed to sign the proposed block.
- For details see Linking the Proof of Leadership to a Block.
-
The entropy contribution $\rho_{LEAD}$ verified to be correctly derived.
- This is the epoch nonce entropy contribution. See Cryptarchia v1 Protocol Specification - Epoch Nonce.
Circuit Private Inputs
The prover has to provide these values, but they remain secret:
-
The slot secret and the related information used for the slot $sl$ as described in Protection Against Adaptive Adversaries:
- The slot secret $r_{sl}$.
- The Merkle path $slot_secret_path$ of $r_{sl}$ leading to the root $R$.
- The starting secret slot $sl_{start}$.
-
The eligible note and its related information used to derive the $noteID$ (the secret key is derived for the previous step):
- The note value: $v$.
- The note transaction zk hash: $note_tx_hash$.
- The note outputs number: $note_output_number$.
-
The proof of membership of the note identifier in the zone ledgers $ledger_{AGED}$ and $ledger_{LATEST}$. This is done by providing the complementary Merkle nodes and indicating whether they are left (0) or right (1) through boolean selectors:
- The aged ledger complementary nodes: $noteid_aged_path$.
- The aged ledger complementary node selectors: $note_id_aged_selectors$.
- The latest ledger complementary nodes: $noteid_latest_path$.
- The latest ledger complementary node selectors: $note_id_latest_selectors$.
Circuit Constraints
The proof confirms the following relations:
-
The derivation of the Merkle tree root $R$ using the slot secret $r_{sl}$ as the $sl - sl_{start}$'s leaf of the Merkle tree using the Merkle path.
This is a proof of knowledge of the secret slot $r_{sl}$ guaranteeing protection against adaptive adversaries.
-
The derivation of $sk = \text{hash}(\text{NOMOS_POL_SK_V1}||sl_{start}||R)$, as documented in Protection Against Adaptive Adversaries.
-
The computation of the note identifier.
-
The note identifier is in $ledger_{AGED}$ and $ledger_{LATEST}$.
-
The computation of the lottery ticket: $ticket := \text{hash}(\text{LEAD_V1}||\eta||sl||noteID||sk)$ using Poseidon2.
-
The computation of the threshold: $t := v(t_0 + t_1 \cdot v)$. The ticket MUST be lower than this threshold to win the lottery.
-
The check that indeed $ticket < t$.
-
Compute and output the entropy contribution $\rho_{LEAD} := \text{hash}(\text{NOMOS_NONCE_CONTRIB_V1}||sl||noteID||sk)$.
Linking the Proof of Leadership to a Block
The PoL is bound to a public key from an asymmetric signature scheme. This public key $P_{LEAD}$ is given as two public inputs during the PoL proof generation, binding the proof to the key.
- The public key is represented by two public inputs of 16 bytes to guarantee the support of every possible EdDSA25519 public key.
- This public key is later used to verify the signature $\sigma$ of a block when it is dispersed. This ensures that the PoL is tied to a specific block, and only the entity creating the proof can perform this binding.
- The key is single-use, as reusing the same one could allow multiple PoLs to be linked to the same identity. An observer could then infer the stake of that identity by observing the frequency at which it emits a PoL.
Appendix
Lottery Approximation
- The $\phi_f(\alpha) = 1 - (1-f)^\alpha$ function of Ouroboros Crypsinous cannot be computed in a hand-written circuit as it can only operate on elements of $\mathbb{F}_p$ for a certain prime number $p$.
- Managing floating point numbers and mathematical functions involving floating points like exponentiations or logarithms in circuits is very inefficient.
- Comparing the Taylor expansion of order 1 and 2,
the Taylor expansion of order 2 method is used
to approximate the Ouroboros Genesis (and Crypsinous) function
by the following linear function:
- $\stackrel{0}{\sim}$ means nearly equal in the neighborhood of 0
- $f$ is the probability that at least one leader wins the lottery on each slot
- $x$ is the stake of the proven note
$$1 - (1-f)^x = 1 - e^{x \ln(1-f)}$$
$$1 - e^{x \ln(1-f)} \stackrel{0}{\sim} x(-\ln(1-f) - 0.5 \ln^2(1-f)x)$$
Then the threshold is $stake(t_0 + t_1 \cdot stake)$ with $t_0 := -\frac{\text{VRF_order} \cdot \ln(1-f)}{\text{inferred_total_stake}}$ and $t_1 := -\frac{\text{VRF_order} \cdot \ln^2(1-f)}{2 \cdot \text{inferred_total_stake}^2}$.
Since everything is known by every node except the value of the staked note, we pre-compute $t_0$ and $t_1$ outside of the circuit.
- The Hash functions used to derive the lottery ticket is Poseidon2 so the VRF_order is $p$ the order of the scalar field of the BN254 elliptic curve.
- To compute $t_0$ and $t_1$, we precomputed the constant parts using sagemath and real number of 512 bits precision. In the implementation, $t_0$ and $t_1$ should then be derived using 256-bit precision integers following:
| Variable | Formula |
|---|---|
| $p$ | 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001 |
| $t_0_constant$ | 0x1a3fb997fd58374772808c13d1c2ddacb5ab3ea77413f86fd6e0d3d978e5438 |
| $t_1_constant$ | 0x71e790b41991052e30c93934b5612412e7958837bac8b1c524c24d84cc7d0 |
| $t_0$ | $\frac{t_0_constant}{\text{inferred_total_stake}}$ |
| $t_1$ | $p - \lfloor\frac{t_1_constant}{\text{inferred_total_stake}^2}\rfloor$ |
Error Analysis
- For $f = 0.05$. The error percentage is computed with $100 \cdot \frac{estimation - real_value}{real_value}$.
- This analysis considers inferred_total_stake to be 23.5B as in Cardano.
- Original function: $1 - (1-f)^{\frac{stake}{\text{inferred_total_stake}}}$
- Taylor expansion of order 1: $-\frac{stake}{\text{inferred_total_stake}} \ln(1-f) := stake \cdot t_0$
- Taylor expansion of order 2: $\frac{stake}{\text{inferred_total_stake}}(-\ln(1-f) - 0.5 \ln^2(1-f)(\frac{stake}{\text{inferred_total_stake}})) := stake(t_0 + stake \cdot t_1)$
| stake (%) | order 1 error | order 2 error |
|---|---|---|
| 5% | 0.13% | -0.0001% |
| 10% | 0.26% | -0.0004% |
| 15% | 0.39% | -0.0010% |
| 20% | 0.51% | -0.0018% |
| 25% | 0.64% | -0.0027% |
| 30% | 0.77% | -0.0040% |
| 35% | 0.90% | -0.0054% |
| 40% | 1.03% | -0.0071% |
| 45% | 1.16% | -0.0089% |
| 50% | 1.29% | -0.0110% |
| 55% | 1.42% | -0.0134% |
| 60% | 1.55% | -0.0159% |
| 65% | 1.68% | -0.0187% |
| 70% | 1.81% | -0.0217% |
| 75% | 1.94% | -0.0249% |
| 80% | 2.07% | -0.0284% |
| 85% | 2.20% | -0.0320% |
| 90% | 2.33% | -0.0359% |
| 95% | 2.46% | -0.0406% |
| 100% | 2.59% | -0.0444% |
Benchmarks
The material used for the benchmarks is the following:
- CPU: 13th Gen Intel(R) Core(TM) i9-13980HX (24 cores / 32 threads)
- RAM: 32GB - Speed: 5600 MT/s
- Motherboard: Micro-Star International Co., Ltd. MS-17S1
- OS: Ubuntu 22.04.5 LTS
- Kernel: 6.8.0-59-generic

References
Normative
- Cryptarchia v1 Protocol Specification - Parent protocol specification
Informative
- Proof of Leadership Specification - Original Proof of Leadership documentation
- Ouroboros Crypsinous: Privacy-Preserving Proof-of-Stake - Foundation for the PoL design
Copyright
Copyright and related rights waived via CC0.
CRYPTARCHIA-TOTAL-STAKE-INFERENCE
| Field | Value |
|---|---|
| Name | Cryptarchia Total Stake Inference |
| Slug | 94 |
| Status | raw |
| Category | Standards Track |
| Editor | David Rusu [email protected] |
| Contributors | Alexander Mozeika [email protected], Daniel Kashepava [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document defines the total stake inference algorithm for Cryptarchia. In proof-of-stake consensus protocols, the probability that an eligible participant wins the right to propose a block depends on that participant's stake relative to the total active stake. Because leader selection in Cryptarchia is private, the total active stake is not directly observable. Instead, nodes must infer it from observable chain growth.
Keywords: Cryptarchia, stake inference, proof-of-stake, epoch, slot occupancy, leadership lottery
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Background
The total active stake can be inferred by observing the slot occupancy rate: a higher fraction of occupied slots implies more stake participating in consensus. By observing the rate of occupied slots from the previous epoch and knowing the total stake estimate used during that period, nodes can infer a correction to the total stake estimate to compensate for any changes in consensus participation.
This inference process is done by each node following the chain. Leaders will use this total stake estimate to calculate their relative stake as part of the leadership lottery without revealing their stake to others.
The stake inference algorithm adjusts the previous total stake estimate based on the difference between the empirical slot activation rate (measured as the growth rate of the honest chain) and the expected slot activation rate. A large difference serves as an indicator that the total stake estimate is not accurate and must be adjusted.
This algorithm has been analyzed and shown to have good accuracy, precision, and convergence speed. A caveat to note is that accuracy decreases with increased network delays. The analysis can be found in Total Stake Inference Analysis.
Construction
Parameters and Variables
beta (learning rate)
- Value: 1.0
- Description: Controls how quickly the algorithm adjusts to new participation levels.
Lower values for
betagive a more stable/gradual adjustment, while higher values give faster convergence but at the cost of less stability.
PERIOD (observation period)
- Value: ⌊6k/f⌋
- Description: The length of the observation period in slots.
f (slot activation coefficient)
- Value: inherited from Cryptarchia v1 Protocol
- Description: The target rate of occupied slots. Not all slots contain blocks, many are empty.
k (security parameter)
- Value: inherited from Cryptarchia v1 Protocol
- Description: Block depth finality.
Blocks deeper than
kon any given chain are considered immutable.
Functions
density_over_slots
def density_over_slots(s, p):
"""
Returns the number of blocks produced in the p slots
following slot s in the honest chain.
"""
Algorithm
For a current epoch's estimate total_stake_estimate
and the epoch's first slot epoch_slot,
the next epoch's estimate is calculated as shown below:
def total_stake_inference(total_stake_estimate, epoch_slot):
period_block_density = density_over_slots(epoch_slot, PERIOD)
slot_activation_error = 1 - period_block_density / (PERIOD * f)
coefficient = total_stake_estimate * beta
return total_stake_estimate - coefficient * slot_activation_error
Security Considerations
Stake Estimation Accuracy
The accuracy of the total stake inference depends on the observed slot occupancy rate. Implementations SHOULD be aware of the following security considerations:
- Network delays: Accuracy decreases with increased network delays, as delayed blocks may not be included in density calculations.
- Adversarial manipulation: An adversary with significant stake could potentially influence the slot occupancy rate by withholding blocks.
- Convergence period: During periods of rapid stake changes, the estimate may lag behind the actual total stake.
Privacy Considerations
The stake inference algorithm is designed to maintain leader privacy:
- Leaders calculate their relative stake locally using the shared total stake estimate.
- Individual stake amounts are never revealed to the network.
- The algorithm relies only on publicly observable chain growth, not on private stake information.
Copyright
Copyright and related rights waived via CC0.
References
Normative
- Cryptarchia v1 Protocol
- Protocol specification defining
fandkconstants
Informative
- Total Stake Inference - Original Total Stake Inference documentation
- Total Stake Inference Analysis - Analysis of algorithm accuracy, precision, and convergence speed
CRYPTARCHIA-V1-BOOTSTRAPPING-SYNCHRONIZATION
| Field | Value |
|---|---|
| Name | Cryptarchia v1 Bootstrapping & Synchronization |
| Slug | 96 |
| Status | raw |
| Category | Standards Track |
| Editor | Youngjoon Lee [email protected] |
| Contributors | David Rusu [email protected], Giacomo Pasini [email protected], Álvaro Castro-Castilla [email protected], Daniel Sanchez Quiros [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document specifies the bootstrapping and synchronization protocol for Cryptarchia v1 consensus. When a new node joins the network or a previously-bootstrapped node has been offline, it must catch up with the most recent honest chain by fetching missing blocks from peers before listening for new blocks. The protocol defines mechanisms for setting fork choice rules, downloading blocks, and handling orphan blocks while mitigating long range attacks.
Keywords: bootstrapping, synchronization, fork choice, initial block download, orphan blocks, long range attacks, checkpoint
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Background
This protocol defines the bootstrapping mechanism that covers all of the following cases:
- From the Genesis block
- From the checkpoint block obtained from a trusted checkpoint provider
- From the local block tree (with $B_\text{imm}$ newer than the Genesis and the checkpoint)
Additionally, the protocol defines the synchronization mechanism that handles orphan blocks while listening for new blocks after the bootstrapping is completed.
The protocol consists of the following key components:
- Determining the fork choice rule (Bootstrap or Online) at startup
- Switching the fork choice rule from Bootstrap to Online
- Downloading blocks from peers
Upon startup, a node determines the fork choice rule, as defined in Setting the Fork Choice Rule. If the Bootstrap rule is selected, it is maintained for the Prolonged Bootstrap Period, after which the node switches to the Online rule.
Using the chosen fork choice rule, a node will download blocks to catch up with the head (also known as the tip) of each peer's local chain $c_{loc}$.
After downloading is done, the node starts listening for new blocks. Upon receiving a new block, the node validates and adds it to its local block tree. If the ancestors of the block are missing from the local block tree, the node downloads missing ancestors using the same mechanism as above.
Protocol
Constants
| Constant | Name | Description | Value |
|---|---|---|---|
| $T_\text{offline}$ | Offline Grace Period | A period during which a node can be restarted without switching to the Bootstrap rule. | 20 minutes |
| $T_\text{boot}$ | Prolonged Bootstrap Period | A period during which Bootstrap fork choice rule must be continuously used after Initial Block Download is completed. This gives nodes additional time to compare their synced chain with a broader set of peers. | 24 hours |
| $s_\text{gen}$ | Density Check Slot Window | A number of slots used by density check of Bootstrap rule. This constant is defined in Cryptarchia Fork Choice Rule - Definitions. | $\lfloor\frac{k}{4f}\rfloor$ (=4h30m) |
Setting the Fork Choice Rule
Upon startup, a node sets the fork choice rule to the Bootstrap rule in one of the following cases. Otherwise, the node uses the Online fork choice rule.
-
A node is starting with $B_\text{imm}$ set to the Genesis block or from a checkpoint block.
The node is setting its latest immutable block $B_\text{imm}$ to the Genesis or a checkpoint, which clearly indicates that the node intends to catch up with the subsequent blocks. Regardless of how many subsequent blocks remain, the node SHOULD use the Bootstrap rule to mitigate long range attacks.
-
A node is restarting after being offline longer than $T_\text{offline}$ (20 minutes).
Unlike starting from Genesis or checkpoint, in the case where a node is restarted while preserving its existing block tree, the node MUST choose a fork choice rule depending on how long it has been offline.
If it is certain that a node has been offline longer than the offline grace period $T_\text{offline}$ since it last used the Online rule, the node uses the Bootstrap rule upon startup. Otherwise, it starts with the Online rule.
Details of $T_\text{offline}$ are described in Offline Grace Period. A recommended way how to measure the offline duration is introduced in Offline Duration Measurement.
-
A node operator set the Bootstrap rule explicitly (e.g., by
--bootstrapflag).In any case where the node operator is clearly aware that the node has fallen behind by more than $k$ blocks, they SHOULD be able to start the node with the Bootstrap rule. For example, the operator may obtain the latest block height from another trusted operator and realize that their node has fallen significantly behind due to some issue.
Initial Block Download
If peers for Initial Block Download (IBD) are configured, a node performs IBD by downloading blocks to catch up with the tip of the local chain $c_{loc}$ of each peer using the fork choice rule chosen in Setting the Fork Choice Rule.
Blocks are downloaded in parent-to-child order, as defined in the Downloading Blocks mechanism. This mechanism applies not only when a node starts from the Genesis block, but also when it already has the local block tree (or a checkpoint block).
def initial_block_download(peers, local_tree):
# In real implementation, these downloadings can be run in parallel.
# Also, any optimization can be applied to minimize downloadings,
# such as grouping peers by tip.
for peer in peers:
download_blocks(local_tree, peer, target_block=None)
The downloaded blocks are validated and added to the local block tree using the fork choice rule determined above.
According to Cryptarchia v1 Protocol Specification - Block Header Validation, the downloaded blocks are validated and added to the local block tree using the fork choice rule determined above.
If all IBD peers become unavailable before the node catches up with at least one of the IBD peers, the node is terminated with an error, allowing the operator to restart the node with other IBD peers.
If downloading is done successfully, the node starts listening for new blocks as described in Listening for New Blocks.
Prolonged Bootstrap Period
After Initial Block Download is completed, a node MUST maintain the Bootstrap fork choice rule during the Bootstrap Period $T_\text{boot}$, if the node chose the Bootstrap rule at Setting the Fork Choice Rule.
The purpose of the Prolonged Bootstrap Period is giving a syncing node additional time to compare its synced chain with a broader set of peers. In other words, it provides the node with an opportunity to connect to different peers and verify whether they are on the same chain. If the syncing node has downloaded blocks only from peers within an isolated network, the result of Initial Block Download may not reflect the honest chain followed by the majority of the entire network. To resolve such situations, the node SHOULD continue using the Bootstrap rule while discovering additional peers, allowing it to switch to a better chain if one is found.
Theoretically, the Bootstrap rule should be prolonged until the node has seen a sufficient number of blocks beyond the $s_\text{gen}$ slot window, which is required for the density check of the Bootstrap rule to be meaningful. However, if the node has seen a fork longer than $k$ blocks from its divergence block during Initial Block Download, it means that the node has already seen more slots than $s_\text{gen}$ with very high probability, considering the small size of $s_\text{gen} = k/(4f)$. If the node has never seen any fork longer than $k$ blocks, it means that all forks could have been handled by the longest chain rule, which is part of the Bootstrap rule. Therefore, this protocol does not explicitly wait $s_\text{gen}$ slots after Initial Block Download. In other words, the protocol does not use $s_\text{gen}$ to configure the Prolonged Bootstrap Period.
This protocol configures the Bootstrap Period to 24 hours.
A timer MUST be started when Listening for New Blocks is started after Initial Block Download is completed. Once the timer is completed, the fork choice rule is switched to the Online rule.
Listening for New Blocks
Once Initial Block Download is complete and Prolonged Bootstrap Period is started, a node starts listening for new blocks relayed by its peers.
Upon receiving a new block, the node tries to validate and add it to its local block tree, as defined in Cryptarchia v1 Protocol Specification - Chain Maintenance.
If the parent of the block is missing from the local block tree, the block cannot be fully validated and added. These blocks are called orphan blocks. To handle an orphan block, the node downloads missing blocks from a randomly selected peer, as described in Downloading Blocks. If the request fails, the node MAY retry with different peers before abandoning the orphan block. The retry policy can be configured by implementers.
Note that downloading missing blocks does not need to be triggered if it is clear that the orphan block is in a fork diverged before the latest immutable (committed) block, as the node MUST never revert immutable blocks.
def listen_and_process_new_blocks(fork_choice: ForkChoice,
local_tree: Tree,
peers: List[Node]):
for block in listen_for_new_blocks():
try:
# Run the chain maintenance defined in the Cryptarchia spec.
local_tree.on_block(block, fork_choice)
except InvalidBlock:
continue
except ParentNotFound:
# Ignore the orphan block proactively,
# if it's clear that the orphan block is in a fork
# behind the latest immutable block
# because immutable blocks should never be reverted.
# This check doesn't cover all cases, but the uncovered cases
# will be handled by the Cryptarchia block validation
# during the `download_blocks` below.
if block.height <= local_tree.latest_immutable_block().height:
continue
# In real implementation, downloading can be run in background
# with the retry policy.
download_blocks(local_tree, random.choice(peers),
target_block=block.id)
Downloading Blocks
For performing Initial Block Download and handling orphan blocks
while Listening for New Blocks,
a node sends a DownloadBlocksRequest to a peer,
which MUST respond with blocks in parent-to-child order.
This communication should be implemented based on Libp2p streaming.
Libp2p Protocol ID
- Mainnet:
/nomos/cryptarchia/sync/1.0.0 - Testnet:
/nomos-testnet/cryptarchia/sync/1.0.0
class DownloadBlocksRequest:
# Ask blocks up to the target block.
# The response may not contain the target block
# if the responder limits the number of blocks returned.
# In that case, the requester must repeat the request.
target_block: BlockId
# To allow the peer to determine the starting block to return.
known_blocks: KnownBlocks
class KnownBlocks:
local_tip: BlockId
latest_immutable_block: BlockId
# Additional known blocks.
# A responder will reject a request if this list contains more than 5.
additional_blocks: list[BlockId]
class DownloadBlocksResponse:
# A stream of blocks in parent-to-child order.
# The max number of blocks to be returned can be limited by implementers.
# A requester can read the stream until the stream returns "NoMoreBlock".
blocks: Stream[Block | "NoMoreBlock"]
The responding peer uses KnownBlocks to determine the optimal starting block
for the response stream, aiming to minimize the number of blocks to be returned.
The requesting node can include any block it believes could assist in this process
to the KnownBlocks.additional_blocks.
To avoid spamming responders,
the size of KnownBlocks.additional_blocks is limited to 5.
The responding peer finds the latest common ancestor (i.e. LCA)
between the target_block and each of the known blocks.
Then, it returns a stream of blocks, starting from the highest LCA.
To mitigate malicious downloading requests,
the peer limits the number of blocks to be returned.
The detailed implementation is up to implementers,
depending on their internal architecture (e.g. storage design).
The requesting node SHOULD repeat DownloadBlocksRequests
by updating the KnownBlocks in order to download the next batches of blocks.
The following code shows how the requesting node can be implemented.
def download_blocks(local_tree: Tree, peer: Node,
target_block: Optional[BlockId]):
latest_downloaded: Optional[Block] = None
while True:
# Fetch the peer's tip if target is not specified.
target_block = target_block if target_block is not None else peer.tip()
# Don't start downloading if target is already in local.
if local_tree.has(target_block):
return
req = DownloadBlocksRequest(
# If target_block is None, specify the current peer's tip
# each time when building DownloadBlocksRequest,
# so that the node can catch up with the most recent peer's tip.
target_block=target_block,
known_blocks=KnownBlocks(
local_tip=local_tree.tip().id,
latest_immutable_block=local_tree.latest_immutable_block().id,
# Provide the latest downloaded block as well
# to avoid downloading duplicate blocks
additional_blocks=[latest_downloaded.id]
if latest_downloaded is not None else [],
)
)
resp = send_request(peer, req)
for block in resp.blocks():
latest_downloaded = block
try:
# Run the chain maintenance defined in the Cryptarchia spec.
local_tree.on_block(block)
# Early stop if the target has been reached.
if block == req.target_block:
break
except:
return
If the node is continuing from a previous DownloadBlocksRequest,
it is important to include the latest downloaded block
to the KnownBlocks.additional_blocks to avoid downloading duplicate blocks.
If the requesting node is downloading blocks up to the peer's tip $c_{loc}$
(e.g. Initial Block Download) by repeating DownloadBlocksRequests,
the $c_{loc}$ may switch between requests.
The algorithm described above also handles this case
by specifying the most recent peer's tip each time
when a DownloadBlocksRequest is constructed.
Bootstrapping from Checkpoint
Instead of bootstrapping from the Genesis block or from the local block tree, a node can choose to bootstrap the honest chain starting from a checkpoint block obtained from a trusted checkpoint provider. A checkpoint provider is a trusted service (which MAY be a Nomos node or a dedicated server) that provides recent blockchain snapshots. In this case, the node fully trusts the checkpoint provider and considers blocks deeper than the checkpoint block as immutable (including the checkpoint block itself).
A trusted checkpoint provider exposes a HTTP endpoint, allowing nodes to download the checkpoint block and the corresponding ledger state. The details are defined in Checkpoint Provider HTTP API.
The bootstrapping node imports the downloaded checkpoint block and ledger state before starting bootstrapping. The imported checkpoint block is used as the latest immutable block $B_{imm}$ and the local chain tip $c_{loc}$. Starting from the checkpoint block, the same Initial Block Download is used to download blocks up to the tip of the local chain of each peer. As defined in Setting the Fork Choice Rule, the Bootstrap fork choice rule MUST be used upon startup.
If it turns out that none of the peers' local chains are connected to the checkpoint block, the node is terminated with an error, allowing the node operator to select a new checkpoint.
Details
Offline Grace Period
The offline grace period $T_\text{offline}$ is a period during which a node can be restarted without switching to the Bootstrap rule.
This protocol configures $T_\text{offline}$ to 20 minutes. Here are the advantages and disadvantages of a short period:
Advantages:
- Limits chances for malicious peers to build long alternative chains beyond the scope of the Online rule.
- Conservatively enables the Bootstrap rule to handle long forks.
Disadvantages:
- Even a short offline duration can too sensitively trigger the Bootstrap rule, which then lasts for the long Prolonged Bootstrap Period.
The following example explains why $T_\text{offline}$ SHOULD NOT be set too long:
- A local node stopped in the following situation. A malicious peer is building a fork which is now a little shorter ($k - d$) than the honest chain.
- The local node has been offline shorter than $T_\text{offline}$ and just restarted. As defined in this protocol, the Online fork choice rule is used because the offline duration is short.
- During the offline duration, the malicious peer made its fork longer by adding $k - d$ blocks. Now the fork is in the same length as the honest chain.
- If the malicious peer sends the fork to the restarted node faster than the honest peer, the restarted node will commit to the fork because it has $k$ new blocks. Even if the node later receives the honest chain from the honest peer, it cannot revert blocks that are already immutable.
Offline Duration Measurement
As defined in Setting the Fork Choice Rule, when a node is restarted, it should be able to choose a proper fork choice rule depending on how long it has been offline since it last used the Online rule.
It is considered unsafe to rely on any external information (e.g. the slot or height of peer's tip) to check how long the node has been offline, since such information could be manipulated as an attack vector. Instead, it is recommended to employ a local method to measure the offline duration.
While the specific implementation is left to the discretion of implementers, one approach is for the node to periodically record the current time to a local file while it is running with the Online fork choice rule. Upon restart, it can use this timestamp to calculate how long it has been offline.
Checkpoint Provider HTTP API
A trusted checkpoint provider serves the GET /checkpoint API,
allowing users (which are not connected via p2p)
to download the latest checkpoint block and its corresponding ledger state.
openapi: 3.0
paths:
/checkpoint:
get:
responses:
'200':
description: OK
content:
multipart/mixed:
schema:
type: object
properties:
checkpoint_block:
type: string
format: binary
checkpoint_ledger_state:
type: string
format: binary
References
Normative
- Cryptarchia v1 Protocol Specification - Parent protocol specification
- Cryptarchia Fork Choice Rule - Fork choice rule specification
Informative
- Cryptarchia v1 Bootstrapping & Synchronization - Original bootstrapping and synchronization documentation
- Libp2p Streaming - Peer-to-peer networking library
Copyright
Copyright and related rights waived via CC0.
NOMOS-BLEND-PROTOCOL
| Field | Value |
|---|---|
| Name | Nomos Blend Protocol |
| Slug | 95 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski |
| Contributors | Alexander Mozeika [email protected], Youngjoon Lee [email protected], Frederico Teixeira [email protected], Mehmet Gonen [email protected], Daniel Sanchez Quiros [email protected], Álvaro Castro-Castilla [email protected], Daniel Kashepava [email protected], Thomas Lavaur [email protected], Antonio Antonino [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
The Blend Protocol is an anonymous broadcasting protocol for the Nomos network that provides network-level privacy for block proposers. It addresses network-based de-anonymization by making it difficult and costly to link block proposals to their proposers through network analysis. The protocol increases the time to link a sender to a proposal by at least 300 times, making stake inference highly impractical.
The protocol achieves probabilistic unlinkability in a highly decentralized environment with low bandwidth cost but high latency. It hides the sender of a block proposal through cryptographic obfuscation and timing delays, routing encrypted messages through multiple blend nodes before revelation.
Keywords: Blend, anonymous broadcasting, privacy, mix network, unlinkability, stake privacy, encryption
Motivation
All Proof of Stake (PoS) systems have an inherent privacy problem where stake determines node behavior. By observing node behavior, one can infer the node's stake. The Blend Protocol addresses network-based de-anonymization where an adversary observes network activity to link nodes to their proposals and estimate stake.
The protocol achieves:
- Unlinkability: Block proposers cannot be linked to their proposals through network analysis
- Stake privacy: Inferring relative stake takes more than 10 years for adversaries controlling 10% stake (targeting 0.1% stake node)
The Blend Protocol is one of the Nomos Bedrock Services, providing censorship resistance and network-level privacy for block producers. It must be used alongside mempool protections (like NomosDA) to achieve truly privacy-preserving system.
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Term | Description |
|---|---|
| Data message | A message generated by a consensus leader containing a block proposal. Indistinguishable from other messages until fully processed. |
| Cover message | A message with meaningless content that creates noise for data messages to hide in. Indistinguishable from data messages. |
| Core node | A Nomos node that declared willingness to participate in Blend Network through SDP. Responsible for message generation, relaying, processing, and broadcasting. |
| Edge node | A Nomos node that is not a core node. Connects to core nodes to send messages. |
| Block proposer node | A core or edge node generating a new data message. |
| Blend node | A core node that processes a data or cover message. |
| Blending | Cryptographically transforming and randomly delaying messages to shuffle temporal order. |
| Broadcasting | Sending a data message payload (block proposal) to all Nomos nodes. |
| Disseminating | Relaying messages by core nodes through the network. |
| Epoch | 648,000 slots (each 1 second), with average 21,600 blocks per epoch. |
| Session | Time period with same set of core nodes executing the protocol. Length follows epoch length (21,600 blocks average). |
| Round | Primitive time measure (1 second) during which a node can emit a new message. |
| Interval | 30 rounds, approximating time between two consecutive block production events. |
| Blending token | Information extracted from processed messages, used as proof of processing for rewards. |
Node Types
| Type | Description |
|---|---|
| Honest node | Follows the protocol fully. |
| Lazy node | Does not follow protocol due to lack of incentives; only participates when directly beneficial. |
| Spammy node | Emits more messages than protocol expects. |
| Unhealthy node | Emits fewer messages than expected (may be under attack). |
| Malicious node | Does not follow protocol regardless of incentives. |
| Unresponsive node | Does not follow protocol due to technical reasons. |
Adversary Types
| Type | Description |
|---|---|
| Passive adversary | Can only observe, cannot modify node behavior. |
| Active adversary | Can modify node behavior and observe network. |
| Local observer | Passive adversary with limited network view and ability to observe internals of limited nodes. |
Document Structure
This specification is organized into two distinct parts to serve different audiences and use cases:
Part I: Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations MUST follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.
Part II: Implementation Details provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.
This separation provides several benefits:
- Clarity of Requirements: Implementers can clearly distinguish between mandatory requirements for interoperability (Part I) and optional optimizations (Part II)
- Protocol Evolution: The core protocol specification (Part I) can remain stable while implementation guidance (Part II) evolves with new techniques and optimizations
- Multiple Implementations: Different implementations can make different trade-offs in Part II while maintaining full compatibility through adherence to Part I
- Audit Focus: Security auditors can concentrate on the normative requirements in Part I that are critical for the protocol's privacy guarantees
- Accessibility: Protocol researchers can understand the essential mechanisms without being overwhelmed by implementation details, while developers get the practical guidance they need
Part I: Protocol Specification
Protocol Overview
The Blend Protocol works as follows:
- Core nodes form a network by establishing encrypted connections with other core nodes at random
- A block proposer node selects several core nodes and creates a data message containing a block proposal that can only be processed by selected nodes in specified order
- The block proposer sends the data message to its neighbors (or connects to random core nodes if edge node)
- Core nodes disseminate (relay) the message to the rest of the network
- Core nodes generate new cover messages every round, blended with other messages
- When a data message reaches a designated blend node:
- Message is cryptographically transformed (incoming/outgoing messages unlinkable by content)
- Message is randomly delayed (unlinkable by timing observation)
- The blend node disseminates the processed message so next blend node can process it
- When message reaches the last blend node:
- Node processes (decrypts and delays) the message
- Extracts the block proposal payload
- Broadcasts block proposal to Nomos Network
Note: Current protocol version is optimized for privacy of core nodes. Edge nodes gain lower privacy level, which is acceptable as they are assumed mobile without static long-term network identifiers and have lower stake.
Network Protocol
Network Formation
Core nodes form a peer-to-peer network at the beginning of each session:
- All core nodes retrieve the set of participating core nodes from SDP protocol
- Each core node establishes encrypted connections with randomly selected core nodes
- Network is considered formed when nodes reach minimum connectivity requirements
Edge nodes connect to core nodes on-demand when they need to send messages.
Minimal Network Size
The protocol requires a minimum number of core nodes to operate safely. If this minimum is not met, nodes MUST NOT use the Blend protocol and MUST broadcast data messages directly.
Network Maintenance
Nodes monitor connection quality and adjust their connections based on:
- Message frequency and correctness
- Network health indicators
- Protocol compliance of peers
Nodes may close connections with misbehaving peers and establish new connections to maintain network quality.
Session Transitions
When a new session or epoch begins, the network implements a transition period to allow messages generated with old credentials to safely complete their journey through the network.
Quota Protocol
The protocol limits the number of messages that can be generated during a session through a quota system. Two types of quota exist:
- Core Quota: Limits cover message generation and blending operations for core nodes during a session
- Leadership Quota: Limits blending operations a block proposer can perform per proof of leadership
Nodes generate session-specific key pools, where each key is associated with a proof of quota. This ensures messages are properly rate-limited and nodes cannot exceed their allowed message generation capacity.
Message Protocol
Message Structure
Messages consist of three components:
- Public Header (H): Contains public key, proof of quota, and signature
- Encrypted Private Header (h): Contains blending headers for each hop, with proofs of selection
- Payload (P): The actual content (block proposal or cover message data)
Message Lifecycle
Messages follow a defined lifecycle through the network:
- Generation: Triggered by consensus lottery (data) or schedule (cover)
- Relaying: Nodes validate and forward messages to neighbors
- Processing: Designated nodes decrypt and extract next-hop information
- Delaying: Random delays hide timing correlations
- Releasing: Messages released according to delay schedule
- Broadcasting: Final nodes extract and broadcast block proposals
Proof Mechanisms
Proof of Quota (PoQ)
Guarantees that honestly generated messages use valid quota allocation. Two types exist:
- Core Quota Proof: Validated message is within core node's session quota
- Leadership Quota Proof: Validated message is within leader's quota per won slot
Combined proof uses logical OR of both proof types.
Proof of Selection (PoSel)
Makes node selection for message processing random and verifiable. Prevents:
- Targeting specific nodes
- Selfish behavior (sending all messages to self)
- Predictable routing patterns
Rewarding Protocol
Nodes are rewarded for participating in the protocol:
- Message Processing: Nodes collect blending tokens as proof of work
- Activity Proof: Probabilistic attestation using Hamming distance
- Two-Tier Rewards: Base reward for all active nodes, premium reward for nodes with minimal Hamming distance
Security Considerations
DoS Protection
Multiple mechanisms prevent denial-of-service attacks:
- Quota system limits message generation
- Connection monitoring detects spammy/malicious nodes
- Minimal network size requirement
- Message uniqueness verification
Privacy Properties
The protocol provides probabilistic unlinkability with quantifiable privacy guarantees. Time to link sender to proposal and time to infer stake increase significantly with each additional hop in the blending path.
Attack Resistance
Protection against various attack vectors:
- Grinding attacks: Prevented by unpredictable session randomness
- Tagging attacks: Addressed by mempool protections (NomosDA)
- Timing attacks: Mitigated by random delays
- Content inspection: Prevented by layered encryption
- Replay attacks: Prevented by TLS and key uniqueness verification
Rationale
Design Decisions
Blending vs Mixing: Protocol uses blending (spatial anonymity through multiple nodes) rather than mixing (temporal anonymity through single node) for higher decentralization and censorship resistance.
Two-tier reward system: Base reward ensures fairness; premium reward continues motivating nodes through lottery mechanism.
Edge node privacy trade-off: Lower privacy acceptable as edge nodes are assumed mobile, without static identifiers, with lower stake.
Cover traffic motivation: Nodes must generate cover messages for own privacy; protocol enforces statistical indistinguishability.
Statistical bias: Modulo operation for node selection introduces negligible bias (< 2^{-128}) for expected network sizes.
Part II: Implementation Details
Network Implementation
Core Network Bootstrapping
At the beginning of a session:
- All core nodes retrieve fresh set of core nodes' connectivity information from SDP protocol
- Each core node selects at random a set of other core nodes and connects through fully encrypted connections
- After all core nodes connect, a new network is formed
Detailed Bootstrapping Procedure
- Core node retrieves set of core nodes' information from SDP protocol at session start
- If number of core nodes is below minimum (32), stop and use regular broadcasting
- Start opening new connections:
- Select at random (without replacement) a node from set of core nodes
- Establish secure TLS 1.3 connection using ephemeral Ed25519 keys
- Identify neighbor using Neighbor Distinction Process (NDP)
- Stop connecting after reaching maximum retries (3 by default)
- Repeat until connected to minimal core peering degree (4 by default, both incoming and outgoing count)
- Start accepting incoming connections and maintaining all connections:
- Can maintain up to maximum connections with core nodes (8 by default)
- Can receive up to maximum connections with edge nodes (300 by default)
- If two nodes open connections to each other:
- Node with lower public key value (provider_id from SDP, compared via Base58 encoding) closes outgoing connection
- Node with higher public key value closes incoming connection
Connection Details
- Protocol: libp2p with TLS 1.3 (not older)
- Cryptographic scheme: Ed25519 with ephemeral keys
- libp2p protocol name:
- Mainnet:
/nomos/blend/1.0.0 - Testnet:
/nomos-testnet/blend/1.0.0
- Mainnet:
Connectivity Maintenance Implementation
Core nodes monitor connection quality by verifying message correctness and frequency:
- Count messages after successful connection-level decryption during observation window (30 rounds)
- If frequency exceeds maximum: mark neighbor as spammy, close connection, establish new one
- If frequency below minimum: mark connection as unhealthy, establish additional connection
- Unhealthy connections are monitored continuously and may recover
- If maximum connections exceeded: log situation, pause new connections until below maximum
- Edge nodes MUST send message immediately after connection then close; otherwise core node closes connection
- Messages with invalid proof of quota or signature from core node: mark as malicious, close connection
- Messages with duplicate identifier: close connection with neighbor (with grace period for network delay)
Edge Network Bootstrapping Implementation
Edge nodes connect to core nodes when needing to send messages:
- Retrieve set of core nodes from SDP at session start
- If below minimum size (32), stop and use regular broadcasting
- When needing to send message, select random core node
- Establish secure TLS connection
- Identify and authenticate using NDP
- Send message and close connection
- Repeat for communication redundancy number (4 by default)
Transition Period Implementation
When new session or epoch begins, protocol implements Transition Period (30 rounds) to allow messages generated with old keys to safely exit the network:
New session:
- Validate message proofs against both new and past session-related public input for TP duration
- Open new connections for new session
- Maintain old connections and process messages for TP duration
New epoch:
- Validate message proofs against both new and past epoch-related public info for TP duration
Quota Implementation
Quota limits the number of messages that can be generated during a session for network health and fair reward calculation.
Core Quota
Core quota (Q_C) defines messaging allowance for a core node during single session:
Q_C = ⌈(C · (β_C + R_C · β_C)) / N⌉
Where:
- C = S · F_C = expected number of cover messages per session by all core nodes
- β_C = 3 = expected blending operations per cover message
- R_C = redundancy parameter for cover messages
- N = number of core nodes from SDP
Total core quota (all nodes): Q^Total_C = N · Q_C = C · (β_C + R_C · β_C)
Leadership Quota
Leadership quota (Q_L) defines blending operations a block proposer can perform. Single quota used per proof of leadership:
Q_L = β_D + β_D · R_D
Where:
- β_D = 3 = expected blending operations per data message
- R_D = redundancy parameter for data messages
Average data messages per session: D_Avg = L_Avg · Q_L, where L_Avg = 21,600 (average leaders per session)
Quota Application Details
Nodes create session-specific key pools:
K^{n,s}_q = {(K^n_0, k^n_0, π_Q^{K^n_0}), ..., (K^n_{q-1}, k^n_{q-1}, π_Q^{K^n_{q-1}})}
Where:
- q = Q_C + Q^n_L = sum of core quota and leadership quota for node n
- K^n_i = i-th public key
- k^n_i = corresponding private key
- π_Q^{K^n_i} = proof of quota (confirms i < h without disclosing node identity)
Message Structure Implementation
A node n constructs message M = (H, h, P):
Public Header (H)
- K^n_i: public key from set K^n_h
- π^{K^n_i}_Q: proof of quota for key (contains key nullifier)
- σ_{K^n_i}(P_i): signature of i-th encapsulation, verifiable by K^n_i
Encrypted Private Header (h)
Contains β_max blending headers (b_1, ..., b_{β_max}), each with:
- K^n_l: public key from set K^n_h
- π^{K^n_l}_Q: proof of quota for key
- σ_{K^n_l}(P_l): signature of l-th encapsulation
- π^{K^n_{l+1}, m_{l+1}}S: proof of selection of node index m{l+1}
- Ω: flag indicating last blending header
Payload (P)
Message content (block proposal or random data for cover messages)
Encapsulation Overhead: Using Groth16 SNARKs, total overhead is ~1123 bytes for 3 hops (~3% increase for typical block proposal of 33,129 bytes).
Message Lifecycle Implementation
Generation Details
Message generation is triggered by:
- Data message: Core/edge node won consensus lottery and has proof of leadership
- Cover message: Released at random by core node per Cover Message Schedule
Generation process:
- Generate keys according to Key Types and Generation Specification
- Each key uses message-type-specific allowance (quota)
- Correct usage proven by Proof of Quota
- Format payload according to Payload Formatting Specification
- Encapsulate payload using Message Encapsulation Mechanism
- Each key for single encapsulation, processable by single node
- Node selection is random and deterministic, provable by Proof of Selection
- Format message according to Message Formatting Specification
- Release message according to Releasing logic
Relaying Details
When node receives message from neighbor:
- Check public header:
- Version MUST equal 0x01
- Proof of quota MUST be valid
- Signature MUST be valid
- Public key MUST be unique
- Release message to network (Releasing section)
- Concurrently, add message to processing queue (Processing section)
Duplicate Detection: Node MUST cache public key for every relayed message for duration of session plus safety buffer and transition period (~65 MB).
Processing Details
When message M is received with correct public header:
- Decapsulate message per Message Encapsulation Mechanism
- If decapsulation succeeds:
- Validate proof of selection (points to node index in SDP list)
- Store blending token: τ = (π^{K^n_l}_Q, π^{K^n_l,l}_S)
- If last flag set (Ω == 1):
- If payload is block proposal: verify structure and broadcast
- If payload is cover message: discard
- Else:
- Validate decapsulated public header (key uniqueness, signature, proof of quota)
- Format message per Message Formatting Specification
- Attempt subsequent decapsulation recursively
- If decapsulation fails: randomly delay and release to neighbors
- If decapsulation fails: return failure message
Delaying Details
Purpose: Hide timing correlations between incoming/outgoing messages.
Maximum delay between release attempts: Δ_max = 3 rounds
Delaying logic:
- Select random delay: δ ∈ (1, Δ_max)
- Start counting rounds from r_s
- Every round check if r_c == r_s + δ:
- Release messages from queue (Releasing logic)
- Select new random delay
- Restart round counting
Release round selection works independently of queue state.
Releasing Details
Release process:
- Received messages: Immediately released to all neighbors (except sender) after header validation
- Processed messages: Queued and released at next release round (per Delaying logic)
- Generated messages: Released at beginning of next round after generation
- Statistical indistinguishability: When data message generated, one random unreleased cover message MUST be removed from schedule
- Multiple messages: If multiple messages scheduled for same round, randomly shuffle before release
Expected messages per release round for single node:
μ = ⌈(Δ_max · β_C · α) / N⌉
Where:
- Δ_max = 3 (maximal delay)
- β_C = 3 (blending operations per cover message)
- α ≈ 1.03 (normalization constant for data messages)
- N = number of core nodes
Results:
- N=16: μ=1 message per round
- N=8: μ=2 messages per round
- N=4: μ=3 messages per round
Broadcasting Implementation
When payload added to broadcasting queue:
- Verify payload contains valid block proposal structure (proposal not validated yet)
- Extract block proposal
- Broadcast to Nomos broadcasting channel after random delay
Cover Message Schedule Implementation
Core nodes generate cover messages in fully random manner to maintain privacy. Messages evenly distributed across session duration.
Safety Buffer Implementation
Problem: Session length in rounds is non-deterministic due to random block production. Safety buffer (100 intervals) reserves cover messages for when session lasts longer than expected.
Cover Message Generation Algorithm
Given:
- Core quota Q_C
- Expected blending operations β_C = 3
- Last interval I_end = 21,600
- Last interval of safety buffer I_max > I_end
For every session:
- Calculate maximum cover messages: c = ⌈Q_C / β_C⌉
- For i ∈ {1, ..., c}:
- Select random interval I ∈ {1, ..., I_max}
- Select random round r ∈ {1, ..., |I|}
- If (I, r) already selected, repeat; else add to winning slots W
- During each interval I and round r: if (I, r) ∈ W, generate cover message
Important: Number of generated cover messages MUST be reduced by number of data messages node generates during session (for statistical indistinguishability).
Proof Mechanisms Implementation
Proof of Quota Implementation (PoQ)
Guarantees honestly generated messages are relayed and disseminated. Two parts:
Core Quota Proof (π^{K^n_a}_{Q_C}) is true when:
- n ∈ N = SDP(s): node is in registered set (identity hidden)
- K^n_a ∈ K^{n,s}_h: key generated by node for session
- a < Q_C: index limits proof nullifiers per session
Public input: s, K^n_a, Q_C Private input: n, a Public output: ν_s (key nullifier uniquely identifying PoQ)
Leadership Quota Proof (π^{K^n_b}_{Q_L}) is true when:
- ∃ π^{n,e}_L: valid proof of leadership for node n in epoch e
- K^n_b ∈ K^{n,s}_h: key generated by node for session
- b < Q^n_L: index limits proof nullifiers per won slot
Public input: e, s, K^n_b, Q^n_L Private input: π^{n,e}_L, n, b Public output: ν_s (key nullifier)
Combined Proof: π^{K^n_i}Q = π^{K^n_i}{Q_C} ∨ π^{K^n_i}_{Q_L} (logical OR)
Proof of Selection Implementation (PoSel)
Makes node selection for message processing random and verifiable. Prevents targeting specific nodes and selfish behavior.
PoSel (π^{K^n_i, m_i}_S) is true when:
- m_i = CSPRBG(H_N(ρ))_8 mod N, where:
- ρ = secret selection randomness (little-endian)
- m_i = recipient node index (little-endian)
- CSPRBG()_8 = cryptographically secure pseudo-random bytes generator (8 bytes, little-endian)
- H_N() = domain separated blake2b hash
- N = number of core nodes
- v == v', where:
- v = key nullifier of π^{K^n_i}_Q
- v' = H_Ψ(b"KEY_NULLIFIER\V1", ρ)
- H_Ψ() = Poseidon2 hash function
PoSel MUST be used alongside PoQ as they are tightly coupled.
Rewarding Implementation
Rewarding Motivation Details
Nodes must be rewarded for protocol actions:
- Message generation: Especially for cover messages (data messages rewarded through consensus)
- Message relaying: Motivated by connection quality monitoring (fear of losing reward)
- Message processing: Motivated by collecting blending tokens (activity-based reward)
- Message broadcasting: Motivated by increasing service income pool
Blending Tokens Implementation
When node processes message, it stores blending token:
τ = (π^{K^n_l}_Q, π^{K^n_l,l}_S)
Tokens stored with context (session number) in set Τ^{l,s}.
Session Randomness Implementation
Rewarding requires common unbiased randomness provided by consensus:
R_s = H('BLEND_SESSION_RANDOMNESS\V1' || R_e(s) || s)_512
Where:
- H()_512 = blake2b hash (512 bits output)
- R_e(s) = epoch nonce from consensus for epoch corresponding to session s
- s = session number
Activity Proof Implementation
Node activity proof (π^{l,τ,s}_A) attests in probabilistic manner that node l was active during session s by presenting blending token τ.
Activity proof is true when:
- Node l has blending token τ ∈ Τ^{l,s} collected during session s where:
- Proof of Quota π^{K^n_l}_Q ∈ τ is true for session s
- Proof of Selection π^{K^n_l,l}_S ∈ τ is true for session s
- Hamming distance between token and next session randomness is below activity threshold:
Δ_H(H(τ)_ε, H(R_{s+1})_ε) < A_ε
Where:
- H() = blake2b hash
- ε = ⌈log_2(Q^Total_C + 1) / 8⌉ · 8 (bits, rounded to full bytes)
Activity Threshold:
A_ε = χ - ν - θ
Where:
- ν = ⌈log_2(N + 1)⌉ (bits needed for number of nodes)
- χ = ⌈log_2(Q^Total_C + 1)⌉ (bits needed for all blending tokens)
- θ = 1 (sensitivity parameter)
Active Message Implementation
Node l constructs active message M_A = {l, τ, s, π^{l,τ,s}_A} for every session following Active Message format.
Active message metadata field MUST start with one byte version field (fixed to 0x01), followed by Activity Proof.
Node l selects activity proof minimizing Hamming distance to new randomness:
π^{l,τ,s}_A = min_{Δ_H}(true(π^{i,τ,s}_A))
Active message for session s MUST only be sent during session s+1; otherwise rejected.
Ledger MUST only accept single active message per-node per-session. Duplicates rejected.
Reward Calculation Details
Rewards for session s calculated as:
- No calculation if number of nodes from SDP below Minimal Network Size (32)
- Count base proofs: B = number of true activity proofs
- Count premium proofs: P = number of true activity proofs with minimal Hamming distance
- Calculate base reward: R = I / (B + P), where I = service income for session s
- Calculate node reward:
R(n) = R · [true(π^{i,τ,s}_A) + min_{Δ_H}(true(π^{i,τ,s}_A))]
Base reward (R) paid to all nodes with true activity proof; reward doubled for nodes with minimal Hamming distance proof.
Rewarding Distribution Logic Details
- Node sends Active Message with activity proof in metadata field
- Must point to single declaration (declaration_id) and single provider identity (provider_id)
- Any reuse of provider_id makes Active Message invalid
- Active Message sent after end of session s (during s+1), after transition
period
- Delay allows including tokens from transition period
- When session s+2 begins, Mantle distributes rewards per Service Reward
Distribution Protocol
- Delay required to calculate reward partition
- No Active Message on time = no reward
Security Considerations Implementation
DoS Protection Details
The protocol includes multiple DoS mitigation mechanisms:
- Quota system limits message generation
- Connectivity maintenance monitors and drops spammy/malicious nodes
- Minimal network size requirement (32 nodes)
- Connection limits prevent resource exhaustion
- Message uniqueness verification prevents replay attacks
Privacy Properties Details
Unlinkability: For adversary controlling 10% stake targeting 0.1% stake node with 3-hop blending:
- Time to Link (TTL): > 9 epochs
- Time to Infer (TTI): > 10 years (487 epochs)
Trade-offs:
- Each additional hop increases TTL/TTI by ~10x
- Latency penalty: ~1.5s per hop
- Optimal configuration: 3-hop blending (4.5s average latency increase)
Attack Resistance Details
- Grinding attacks: Prevented by unpredictable session randomness
- Tagging attacks: Addressed by NomosDA (separate mempool protection)
- Timing attacks: Mitigated by random delays (Δ_max = 3 rounds)
- Content inspection: Prevented by layered encryption
- Replay attacks: Prevented by TLS and public key uniqueness verification
Rationale Implementation
Design Decisions Details
Blending vs Mixing: Anonymity in blending comes from processing same message by multiple nodes (spatial anonymity), while mixing processes multiple messages by same node (temporal anonymity). Blending chosen for higher decentralization and censorship resistance.
Two-tier reward system: Base reward ensures fairness (all active nodes receive it); premium reward continues motivating lazy nodes through lottery mechanism.
Edge node privacy trade-off: Lower privacy acceptable as edge nodes assumed mobile, without static identifiers, with lower stake, and sporadic connections.
Cover traffic motivation: Nodes must generate cover messages for own privacy protection; protocol enforces indistinguishability by requiring cover message removal when data message generated.
Statistical bias: Modulo operation for node selection introduces negligible bias (< 2^{-128} for N < 2^{128}), acceptable for expected network sizes (< 10 million nodes).
Parameters Summary
Global Parameters
- Session length (S): 648,000 rounds (average 21,600 blocks)
- Interval length: 30 rounds
- Maximum delay (Δ_max): 3 rounds
- Maximum blending operations (β_max): 3
- Expected blending operations (β_C, β_D): 3
- Observation window (W): 30 rounds
- Safety buffer: 100 intervals
- Transition period: 30 rounds
- Minimal network size: 32 nodes
Core Node Parameters
- Minimal core peering degree (Φ_{CC}^{Min}): 4
- Maximum core peering degree (Φ_{CC}^{Max}): 8
- Maximum edge connections (Φ_{CE}^{Max}): 300
- Maximum connection retries (Ω_C): 3
Edge Node Parameters
- Connection redundancy (Φ_{EC}): 4
- Maximum connection retries (Ω_E): 3
References
Normative
- RFC 2119 - Key words for use in LIPs to Indicate Requirement Levels
- Service Declaration Protocol - Nomos SDP specification
- Nomos Bedrock - Nomos foundational layer specification
- Data Availability Network Specification - NomosDA specification
- Service Reward Distribution Protocol - Reward distribution specification
Informative
- Blend Protocol - Original Blend Protocol documentation
- Nomos Services - Overview of Nomos services architecture
- Anonymous Leaders Reward Protocol - Consensus reward mechanism
- Cryptarchia v1 Protocol Specification - Consensus protocol details
- Block Construction, Validation and Execution Specification - Block structure details
Copyright
Copyright and related rights waived via CC0.
NOMOS-CRYPTARCHIA-V1-PROTOCOL
| Field | Value |
|---|---|
| Name | Nomos Cryptarchia v1 Protocol Specification |
| Slug | 92 |
| Status | raw |
| Category | Standards Track |
| Editor | David Rusu [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Giacomo Pasini [email protected], Thomas Lavaur [email protected], Mehmet [email protected], Marcin Pawlowski [email protected], Daniel Sanchez Quiros [email protected], Youngjoon Lee [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
Cryptarchia is the consensus protocol of Nomos Bedrock. This document specifies how Bedrock comes to agreement on a single history of blocks. The values that Cryptarchia optimizes for are resilience and privacy, which come at the cost of block times and finality. Cryptarchia is a probabilistic consensus protocol with properties similar to Bitcoin's Nakamoto Consensus, dividing time into slots with a leadership lottery run at each slot.
Keywords: consensus, proof-of-stake, leadership lottery, fork choice, block validation, epoch, slot, immutability
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Background
Resilience
In consensus, there is a trade-off between prioritizing either safety or liveness in the presence of catastrophic failure (this is a re-formalization of the CAP theorem). Choosing safety means the chain never forks, instead the chain halts until the network heals. On the other hand, choosing liveness (a la Bitcoin/Ethereum) means that block production continues but finality will stall, leading to confusion around which blocks are on the honest chain.
On the surface both options seem to provide similar guarantees. If finality is delayed indefinitely, is this not equivalent to a halted chain? The differences come down to how safety or liveness is implemented.
Prioritizing Safety
Chains that provide a safety guarantee do so using quorum-based consensus. This requires a known set of participants (i.e. a permissioned network) and extensive communication between them to reach agreement. This restricts the number of participants in the network. Furthermore, quorum based consensus can only tolerate up to 1/3rd of the participants becoming faulty.
A small participant set and low threshold for faults generally pushes these networks to put large barriers to entry, either through large staking requirements or politics.
Prioritizing Liveness
Chains that prioritize liveness generally do so by relying on fork choice rules such as the longest chain rule from Nakamoto consensus. These protocols allow each participant to make a local choice about which fork to follow, and therefore do not require quorums and thus can be permissionless.
Additionally, due to a lack of quorums, these protocols can be quite message efficient. Thus, participation does not need to be artificially reduced to remain within bandwidth restrictions.
These protocols tolerate up to 1/2 of participants becoming faulty. The large fault tolerance threshold and the large number of participants provides for much higher resilience to corruption.
Privacy
The motivation behind the design of Cryptarchia can be boiled down to this statement:
A block proposer should not feel the need to self-censor when proposing a block.
Working to give leaders confidence in this statement has had ripple effects throughout the protocol, including that:
- The block proposals should not be linkable to a leader. An adversary should not be able to connect together the block proposals of a leader in order to build a profile. In particular, one should not be able to infer a proposer's stake from their past on-chain activity.
- Cryptarchia must not reveal the stake of the leader - that is, it must be a Private Proof of Stake (PPoS) protocol. If the activity of the leader reveals their stake values (e.g. through weighted voting), then this value can be used to reduce the anonymity set for the leader by bucketing the leader as high/low stake and can open him up to targeting.
- Leaders should be protected against network triangulation attacks. This is outside of the scope of this document, but it suffices to say that in-protocol cryptographic privacy is not sufficient to guarantee a leader's privacy. This topic is dealt with directly in Blend Network Specification.
Limitations of Cryptarchia V1
Despite best efforts, it is not possible to provide perfect privacy and censorship resistance to all parties. In particular:
- It is not possible to protect leaders from leaking information about themselves based on the contents of blocks they propose. The tagging attack is an example of this, where an adversary may distribute a transaction to only a small subset of the network. If the block proposal includes this transaction, the adversary learns that the leader was one of those nodes in that subset.
- The leader is a single point of failure (SPOF). Despite all the efforts to protect the leader, the network can be easily censored by the leader. The leader may choose to exclude certain types of transactions from blocks, leading to a worse UX for targeted parties.
These limitations are not considered insurmountable and there are sketches towards solutions that will be developed in following iterations of the protocol.
Design Overview
Cryptarchia is a probabilistic consensus protocol with properties similar to Bitcoin's Nakamoto Consensus.
At a high level, Cryptarchia divides time into slots and at each slot, a leadership lottery is run. To participate in the lottery, a node must have held stake in the chain in the form of a note for a minimum time period. Given a sufficiently aged note, you can check if it has won a slot lottery by cryptographically flipping a weighted coin. The weight of the coin is proportional to the value of your note, thus higher valued notes lead to increased chances of winning. To ensure privacy and avoid revealing the note value, this lottery result is proven within a ZK proof system.
The design starts from the solid foundation provided by Ouroboros Crypsinous: Privacy-Preserving Proof-of-Stake and builds upon it, incorporating the latest research at the intersection of cryptography, consensus and network engineering.
Protocol
Constants
| Symbol | Name | Description | Value |
|---|---|---|---|
| $f$ | slot activation coefficient | The target rate of occupied slots. Not all slots contain blocks, many are empty. (See Block Times & Blend Network Analysis for analysis leading to the choice of value.) | 1/30 |
| $k$ | security parameter | Block depth finality. Blocks deeper than $k$ on any given chain are considered immutable. | 2160 blocks |
| none | slot length | The duration of a single slot. | 1 second |
| MAX_BLOCK_SIZE | max block size | The maximum size of the block body (not including the header) | 1 MB |
| MAX_BLOCK_TXS | max block transactions | The maximum number of transactions in a block | 1024 |
Notation
| Symbol | Name | Description | Value |
|---|---|---|---|
| $s$ | slot security parameter | Sufficient slots such that $k$ blocks have been produced with high probability. | $3\lfloor \frac{k}{f}\rfloor$ |
| $T$ | the block tree | This is the block tree observed by a node. | |
| $F_T$ | tips of block tree $T$ | The set of concurrent forks of some block tree $T$. | $F_T={b\in T:\forall c \in T\space \textbf{parent}(c) \neq b }$ |
| $c_{loc}$ | tip of local chain | The chain that a node considers to be the honest chain. | $c_{loc} \in F_{T}$ |
| $B_\text{imm}$ | the latest immutable block | The latest block which was committed (finalized) by the chain maintenance. | $B_\text{imm} \in \textbf{ancestors}(c_{loc})$ |
| $sl$ | slot number | Index of slot. $sl=0$ denotes the genesis slot. | $sl=0,1,2,3,\dots$ |
| $ep$ | epoch number | Index of epoch. $ep=0$ denotes the genesis epoch. | $ep=0,1,2,3,\dots$ |
Latest Immutable Block
The latest immutable block $B_\text{imm}$ is the most recent block considered permanently finalized. The blocks deeper than $B_\text{imm}$ in the local chain $c_{loc}$ are never to be reorganized.
This is maintained locally by the Chain Maintenance procedure. When the Online fork choice rule is in use, $B_\text{imm}$ corresponds to the $k$-deep block. However, it may be deeper than the $k$-deep block if the fork choice rule has been switched from Online to Bootstrap. Unlike the $k$-deep block, $B_\text{imm}$ does not advance as new blocks are added unless the Online fork choice rule is used.
The details of fork choice rule transitions are defined in the bootstrap spec: Cryptarchia v1 Bootstrapping & Synchronization.
Slot
Time is divided up into slots of equal length, where one instance of the leadership lottery is held in each slot. A slot is said to be occupied if some validator has won the leadership lottery and proposed a block for that slot, otherwise the slot is said to be unoccupied.
Epoch
Cryptarchia has a few global variables that are adjusted periodically in order for consensus to function. Namely, the protocol requires:
- Dynamic participation, thus the eligible notes must be refreshed regularly.
- An unpredictable source of randomness for the leadership lottery. This source of randomness is derived from in-protocol activity and thus must be selected carefully to avoid giving adversaries an advantage.
- Approximately constant block production rate achieved by dynamically adjusting the lottery difficulty based on observed participation levels.
The order in which these variables are calculated is important and is done w.r.t. the epoch schedule.
Epoch Schedule
An epoch is divided into 3 phases, as outlined below.
| Epoch Phase | Phase Length | Description |
|---|---|---|
| Stake Distribution Snapshot | $s$ slots | A snapshot of note commitments are taken at the beginning of the epoch. The protocol waits for this value to finalize before entering the next phase. |
| Buffer phase | $s$ slots | After the stake distribution is finalized, the protocol waits another slot finality period before entering the next phase. This is to further ensure that there is at least one honest leader contributing to the epoch nonce randomness. If an adversary can predict the nonce, they can grind their coin secret keys to gain an advantage. |
| Lottery Constants Finalization | $s+\lfloor\frac{k}{f}\rfloor=4\lfloor\frac{k}{f}\rfloor$ slots | On the $2s^{th}$ slot into the epoch, the epoch nonce $\eta$ and the inferred total stake $D$ can be computed. The protocol waits another $4\frac{k}{f}$ slots for these values to finalize. |
The epoch length is the sum of the individual phases: $3\lfloor \frac{k}{f} \rfloor + 3\lfloor \frac{k}{f} \rfloor + 4\lfloor \frac{k}{f} \rfloor = 10 \lfloor \frac{k}{f} \rfloor$ slots.
Epoch State
The epoch state holds the variables derived over the course of the epoch schedule. It is the 3-tuple $(\mathbb{C}_\text{LEAD}, \eta, D)$ described below.
| Symbol | Name | Description | Value |
|---|---|---|---|
| $\mathbb{C}_{\text{LEAD}}$ | Eligible Leader Notes Commitment | A commitment to the set of notes eligible for leadership. | See Eligible Leader Notes |
| $\eta$ | Epoch Nonce | Randomness used in the leadership lottery (selected once per epoch) | See Epoch Nonce |
| $D$ | Inferred Total Stake (Lottery Difficulty) | Total stake inferred from watching the results of the lottery during the course of the epoch. $D$ is used as the stake relativization constant for the following epoch. | See Total Stake Inference |
Eligible Leader Notes
A note is eligible to participate in the leadership lottery if it has not been spent and was a member of the note set at the beginning of the previous epoch, i.e. they are members of $\mathbb{C}_\text{LEAD}$.
Note Ageing
If an adversary knows the epoch nonce $\eta$, they may grind a note that wins the lottery more frequently than should be statistically expected. Thus, it's critical that notes participating in the lottery are sufficiently old to ensure that they have no predictive power over $\eta$.
Epoch Nonce
The epoch nonce $\eta$ is evolved after each block.
Given block $B = (parent, sl, \rho_\text{LEAD}, \dots)$ where:
- $parent$ is the parent of block $B$
- $sl$ is the slot that $B$ is occupying.
- $\rho_\text{LEAD}$ is the epoch nonce entropy contribution from the block's leadership proof
Then, $\eta_B$ is derived as:
$$\eta_{B} = \text{zkHASH}(\text{EPOCH_NONCE_V1}||\eta_{\text{parent}}||\rho_\text{LEAD}||\text{Fr}(sl))$$
where $\text{Fr}(sl)$ maps the slot number to the corresponding scalar in Poseidon's scalar field and $\text{zkHASH}(..)$ is Poseidon2 as specified in Common Cryptographic Components.
The epoch nonce used in the next epoch is $\eta_{B'}$ where $B'$ is the last block before the start of the "Lottery Constants Finalization" phase in the epoch schedule.
Total Stake Inference
Given that stake is private in Cryptarchia, and that the goal is to maintain an approximately constant block rate, the difficulty of the slot lottery must be adjusted based on the level of participation. The details can be found in the Total Stake Inference specification.
Epoch State Pseudocode
At the start of each epoch, each validator must derive the new epoch state variables. This is done through the following protocol:
define compute_epoch_state(ep, tip ∈ T) → (C_LEAD^ep, η^ep, D^ep):
case ep = 0:
The genesis epoch state is hardcoded upon chain initialization.
return (C_GENESIS, η_GENESIS, D_GENESIS)
otherwise:
The epoch state is derived w.r.t. observations in the previous epoch.
First, compute the slot at the start of the previous epoch.
Observations will be queried relative to this slot.
sl_{ep-1} := (ep-1) · EPOCH_LENGTH
Notes eligible for leadership lottery are those present in the
commitment root at the start of the previous epoch.
C_LEAD^ep := commitment_root_at_slot(sl_{ep-1}, tip)
The epoch nonce for epoch ep is the value of η at the beginning
of the lottery constants finalization phase in the epoch schedule
η^ep := epoch_nonce_at_slot(sl_{ep-1} + ⌊6k/f⌋, tip)
Total active stake is inferred from the number of blocks produced
in the previous epoch during the stake freezing phase.
It is also derived from the previous estimate of total stake,
thus recursion is used here to retrieve the previous epochs estimate D^{ep-1}
(_, _, D^{ep-1}) := compute_epoch_state(ep-1, tip)
The number of blocks produced during the first 6k/f slots
of the previous epoch
N_BLOCKS^{ep-1} := |{B ∈ T | sl_{ep-1} ≤ sl_B < sl_{ep-1} + ⌊6k/f⌋}|
D^ep := infer_total_active_stake(D^{ep-1}, N_BLOCKS^{ep-1})
return (C_LEAD^ep, η^ep, D^ep)
Leadership Lottery
A lottery is run for every slot to decide who is eligible to propose a block. For each slot, there can be 0 or more winners. In fact, it's desirable to have short slots and many empty slots to allow for the network to propagate blocks and to reduce the chances of two leaders winning the same slot which are guaranteed forks.
Proof of Leadership
The specifications of how a leader can prove that they have won the lottery are specified in the Proof of Leadership Specification.
Leader Rewards
As an incentive for producing blocks, leaders are rewarded with every block proposal. The rewarding protocol is specified in Anonymous Leaders Reward Protocol.
Block Chain
Fork Choice Rule
Two fork choice rules are used, one during bootstrapping and a second once a node completes bootstrapping.
During bootstrapping, the protocol must be resilient to malicious peers feeding false chains, this calls for a more expensive fork choice rule that can differentiate between malicious long-range attacks and honest chains.
After bootstrapping, the node commits to the most honest looking chain found and switches to a fork choice rule that rejects chains that diverge by more than $k$ blocks.
The details are specified in Cryptarchia Fork Choice Rule.
Block ID
Block ID is defined by the hash of the block header, where hash is Blake2b as specified in Common Cryptographic Components.
def block_id(header: Header) -> hash:
return hash(
b"BLOCK_ID_V1",
header.bedrock_version,
header.parent_block,
header.slot.to_bytes(8, byteorder='little'),
header.block_root,
# PoL fields
header.proof_of_leadership.leader_voucher,
header.proof_of_leadership.entropy_contribution,
header.proof_of_leadership.proof.serialize(),
header.proof_of_leadership.leader_key.compressed(),
)
Block Header
class Header: # 297 bytes
bedrock_version: byte # 1 byte
parent_block: hash # 32 bytes
slot: int # 8 bytes
block_root: hash # 32 bytes
proof_of_leadership: ProofOfLeadership # 224 bytes
class ProofOfLeadership: # 224 bytes
leader_voucher: zkhash # 32 bytes
entropy_contribution: zkhash # 32 bytes
proof: Groth16Proof # 128 bytes
leader_key: Ed25519PublicKey # 32 bytes
Block
Block construction, validation and execution are specified in Block Construction, Validation and Execution Specification.
Block Header Validation
Given block $B=(header, transactions)$ and the block tree $T$ where:
- $header$ is the header defined in Header
- $transactions$ is the sequence of transactions in the block
The function $\textbf{valid_header}(B)$ returns True if all of the following constraints hold, otherwise it returns False.
-
header.version.bedrock_version = 1Ensure bedrock version number. -
bytes(transactions) < MAX_BLOCK_SIZEEnsure block size is smaller than the maximum allowed block size. -
length(transactions) < MAX_BLOCK_TXSEnsure the number of transactions in the block is below the limit. -
merkle_root(transactions) = header.block_rootEnsure block root is over the transaction list. -
header.slot > fetch_header(header.parent_block).slotEnsure the block's slot comes after the parent block's slot. -
wallclock_time() > slot_time(header.slot)Ensure this block's slot time has elapsed. Local time is used in this validation. See Clocks for discussion around clock synchronization. -
header.parent ∈ TEnsure the block's parent has already been accepted into the block tree. -
height(B) > height(B_imm)Ensure the block comes after the latest immutable block. Assuming that $T$ prunes all forks diverged deeper than $B_\text{imm}$, this step, along with step 5, ensures that $B$ is descendant from $B_\text{imm}$. If all forks cannot be pruned completely in the implementation, this step must be replaced withis_ancestor(B_imm, B), which checks whether $B_\text{imm}$ is an ancestor of $B$. -
Verify the leader's right to propose and ensure it is the one proposing this block: Given leadership proof $\pi_\text{LEAD} = (\pi_\text{PoL}, P_\text{LEAD}, \sigma)$, where:
- $\pi_\text{PoL}$ is the slot lottery win proof as defined in Proof of Leadership Specification
- $P_\text{LEAD}$ is the public key committed to in $\pi_\text{PoL}$
- $\sigma$ is a signature
-
A leader's proposal is valid if:
verify_PoL(T, parent, sl, P_LEAD, π_PoL) = Trueverify_signature(block_id(H), σ, P_LEAD) = TrueEnsure that the leader who won the lottery is actually proposing this block since PoL's are not bound to blocks directly.
Chain Maintenance
The chain maintenance procedure on_block(state, B)
governs how the block tree $T$ is updated.
Note: It's assumed that block contents have already been validated by the execution layer w.r.t. the parent block's execution state.
define on_block(state, B) → state':
(c_loc, B_imm, T) := state
if B ∈ T ∨ ¬valid_header(B):
Either B has already been seen or it's invalid, in both cases the block is ignored
return state
T' := T ∪ {B}
c_loc' := B if parent(B) = c_loc
fork_choice(c_loc, F_T', k, s) if parent(B) ≠ c_loc
if fork_choice_rule = ONLINE:
Explicitly commit to the k-deep block
if the Online Fork Choice Rule is being used.
(T', B_imm) := commit(T', c_loc', k)
return (c_loc', B_imm, T')
Commit
The following procedure commits to the block which is $depth$ deep from $c_{loc}$. This procedure computes the new latest immutable block $B_\text{imm}$.
define commit(T, c_loc, depth) → (T', B_imm):
assert fork_choice_rule = ONLINE
Compute the latest immutable block, which is depth deep from c_loc.
B_imm := block_at_depth(c_loc, depth)
Prune all forks diverged deeper than B_imm,
so that future blocks on those forks can be rejected by Block Header Validation.
T' := prune_forks(T, B_imm, c_loc)
return (T', B_imm)
Fork Pruning
The fork pruning procedure removes all blocks which are part of forks diverged deeper than a certain block.
define prune_forks(T, B) → T':
T' := T
for each B_tip ∈ F_T:
If B_tip is a fork diverged deeper than B, prune the fork.
B_div := common_ancestor(B_tip, B)
if B_div ≠ B:
T' := prune_blocks(B_tip, B_div, T)
return T'
define prune_blocks(B_new, B_old, T) → T':
Remove all blocks in the chain within range (B_old, B_new] from T.
(B, T') := (B_new, T)
while B ≠ B_old:
T' := T' \ {B}
B := parent(B)
return T'
Versioning and Protocol Upgrades
Protocol versions are signalled through the bedrock_version field
of the block header.
Protocol upgrades need to be co-ordinated well in advance
to ensure that node operators have enough time to update their node.
Block height is used to schedule the activation of protocol updates.
E.g. bedrock version 35 will be active after block height 32000.
Implementation Considerations
Proof of Stake vs. Proof of Work
From a privacy and resiliency point of view, Proof of Work is highly attractive. The amount of hashing power of a node is private, they can provide a new public key for each block they mine ensuring that their blocks cannot be connected by this identity, and PoW is not susceptible to long range attacks as is PoS. Unfortunately, it is wasteful and demands that leaders have powerful machines. The goal is to ensure strong decentralization by having a low barrier to entry and a good enough level of security can be achieved by having participants have an economic stake in the protocol.
Clocks
Cryptarchia depends on honest nodes having relatively in-sync clocks. The protocol currently relies on NTP to synchronize clocks, this may be improved upon in the future, borrowing ideas from Ouroboros Chronos: Permissionless Clock Synchronization via Proof-of-Stake.
References
Normative
- Proof of Leadership Specification - ZK proof specification for leadership lottery
- Anonymous Leaders Reward Protocol - Leader reward mechanism
- Cryptarchia Fork Choice Rule - Fork choice rule specification
- Block Construction, Validation and Execution Specification - Block structure details
- Common Cryptographic Components - Cryptographic primitives (Blake2b, Poseidon2)
- Cryptarchia v1 Bootstrapping & Synchronization - Bootstrap and synchronization procedures
- Total Stake Inference - Stake inference mechanism
- Block Times & Blend Network Analysis - Analysis for slot activation coefficient
Informative
- Cryptarchia v1 Protocol Specification - Original Cryptarchia v1 Protocol documentation
- Ouroboros Crypsinous: Privacy-Preserving Proof-of-Stake - Foundation for Cryptarchia design
- Ouroboros Chronos: Permissionless Clock Synchronization via Proof-of-Stake - Clock synchronization research
- Blend Network Specification - Network privacy layer
Copyright
Copyright and related rights waived via CC0.
NOMOS-DA-NETWORK
| Field | Value |
|---|---|
| Name | NomosDA Network |
| Slug | 136 |
| Status | raw |
| Editor | Daniel Sanchez Quiros [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Daniel Kashepava [email protected], Gusto Bacvinka [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-25 —
51ef4cd— added nomos/raw/nomosda-network.md (#160)
Introduction
NomosDA is the scalability solution protocol for data availability within the Nomos network.
This document delineates the protocol's structure at the network level,
identifies participants,
and describes the interactions among its components.
Please note that this document does not delve into the cryptographic aspects of the design.
For comprehensive details on the cryptographic operations,
a detailed specification is a work in progress.
Objectives
NomosDA was created to ensure that data from Nomos zones is distributed, verifiable, immutable, and accessible. At the same time, it is optimised for the following properties:
-
Decentralization: NomosDA’s data availability guarantees must be achieved with minimal trust assumptions and centralised actors. Therefore, permissioned DA schemes involving a Data Availability Committee (DAC) had to be avoided in the design. Schemes that require some nodes to download the entire blob data were also off the list due to the disproportionate role played by these “supernodes”.
-
Scalability: NomosDA is intended to be a bandwidth-scalable protocol, ensuring that its functions are maintained as the Nomos network grows. Therefore, NomosDA was designed to minimise the amount of data sent to participants, reducing the communication bottleneck and allowing more parties to participate in the DA process.
To achieve the above properties, NomosDA splits up zone data and distributes it among network participants, with cryptographic properties used to verify the data’s integrity. A major feature of this design is that parties who wish to receive an assurance of data availability can do so very quickly and with minimal hardware requirements. However, this comes at the cost of additional complexity and resources required by more integral participants.
Requirements
In order to ensure that the above objectives are met, the NomosDA network requires a group of participants that undertake a greater burden in terms of active involvement in the protocol. Recognising that not all node operators can do so, NomosDA assigns different roles to different kinds of participants, depending on their ability and willingness to contribute more computing power and bandwidth to the protocol. It was therefore necessary for NomosDA to be implemented as an opt-in Service Network.
Because the NomosDA network has an arbitrary amount of participants, and the data is split into a fixed number of portions (see the Encoding & Verification Specification), it was necessary to define exactly how each portion is assigned to a participant who will receive and verify it. This assignment algorithm must also be flexible enough to ensure smooth operation in a variety of scenarios, including where there are more or fewer participants than the number of portions.
Overview
Network Participants
The NomosDA network includes three categories of participants:
- Executors: Tasked with the encoding and dispersal of data blobs.
- DA Nodes: Receive and verify the encoded data, subsequently temporarily storing it for further network validation through sampling.
- Light Nodes: Employ sampling to ascertain data availability.
Network Distribution
The NomosDA network is segmented into num_subnets subnetworks.
These subnetworks represent subsets of peers from the overarching network,
each responsible for a distinct portion of the distributed encoded data.
Peers in the network may engage in one or multiple subnetworks,
contingent upon network size and participant count.
Sub-protocols
The NomosDA protocol consists of the following sub-protocols:
- Dispersal: Describes how executors distribute encoded data blobs to subnetworks. NomosDA Dispersal
- Replication: Defines how DA nodes distribute encoded data blobs within subnetworks. NomosDA Subnetwork Replication
- Sampling: Used by sampling clients (e.g., light clients) to verify the availability of previously dispersed and replicated data. NomosDA Sampling
- Reconstruction: Describes gathering and decoding dispersed data back into its original form. NomosDA Reconstruction
- Indexing: Tracks and exposes blob metadata on-chain. NomosDA Indexing
Construction
NomosDA Network Registration
Entities wishing to participate in NomosDA must declare their role via SDP (Service Declaration Protocol). Once declared, they're accounted for in the subnetwork construction.
This enables participation in:
- Dispersal (as executor)
- Replication & sampling (as DA node)
- Sampling (as light node)
Subnetwork Assignment
The NomosDA network comprises num_subnets subnetworks,
which are virtual in nature.
A subnetwork is a subset of peers grouped together so nodes know who they should connect with,
serving as groupings of peers tasked with executing the dispersal and replication sub-protocols.
In each subnetwork, participants establish a fully connected overlay,
ensuring all nodes maintain permanent connections for the lifetime of the SDP set
with peers within the same subnetwork.
Nodes refer to nodes in the Data Availability SDP set to ascertain their connectivity requirements across subnetworks.
Assignment Algorithm
The concrete distribution algorithm is described in the following specification: DA Subnetwork Assignation
Executor Connections
Each executor maintains a connection with one peer per subnetwork, necessitating at least num_subnets stable and healthy connections. Executors are expected to allocate adequate resources to sustain these connections. An example algorithm for peer selection would be:
def select_peers(
subnetworks: Sequence[Set[PeerId]],
filtered_subnetworks: Set[int],
filtered_peers: Set[PeerId]
) -> Set[PeerId]:
result = set()
for i, subnetwork in enumerate(subnetworks):
available_peers = subnetwork - filtered_peers
if i not in filtered_subnetworks and available_peers:
result.add(next(iter(available_peers)))
return result
NomosDA Protocol Steps
Dispersal
- The NomosDA protocol is initiated by executors who perform data encoding as outlined in the Encoding Specification.
- Executors prepare and distribute each encoded data portion
to its designated subnetwork (from
0tonum_subnets - 1). - Executors might opt to perform sampling to confirm successful dispersal.
- Post-dispersal, executors publish the dispersed
blob_idand metadata to the mempool.
Replication
DA nodes receive columns from dispersal or replication and validate the data encoding. Upon successful validation, they replicate the validated column to connected peers within their subnetwork. Replication occurs once per blob; subsequent validations of the same blob are discarded.
Sampling
- Sampling is invoked based on the node's current role.
- The node selects
sample_sizerandom subnetworks and queries each for the availability of the corresponding column for the sampled blob. Sampling is deemed successful only if all queried subnetworks respond affirmatively.
- If
num_subnetsis 2048,sample_sizeis 20 as per the sampling research
sequenceDiagram
SamplingClient ->> DANode_1: Request
DANode_1 -->> SamplingClient: Response
SamplingClient ->>DANode_2: Request
DANode_2 -->> SamplingClient: Response
SamplingClient ->> DANode_n: Request
DANode_n -->> SamplingClient: Response
Network Schematics
The overall network and protocol interactions is represented by the following diagram
flowchart TD
subgraph Replication
subgraph Subnetwork_N
N10 -->|Replicate| N20
N20 -->|Replicate| N30
N30 -->|Replicate| N10
end
subgraph ...
end
subgraph Subnetwork_0
N1 -->|Replicate| N2
N2 -->|Replicate| N3
N3 -->|Replicate| N1
end
end
subgraph Sampling
N9 -->|Sample 0| N2
N9 -->|Sample S| N20
end
subgraph Dispersal
Executor -->|Disperse| N1
Executor -->|Disperse| N10
end
Details
Network specifics
The NomosDA network is engineered for connection efficiency. Executors manage numerous open connections, utilizing their resource capabilities. DA nodes, with their resource constraints, are designed to maximize connection reuse.
NomosDA uses multiplexed streams over QUIC connections. For each sub-protocol, a stream protocol ID is defined to negotiate the protocol, triggering the specific protocol once established:
- Dispersal: /nomos/da/{version}/dispersal
- Replication: /nomos/da/{version}/replication
- Sampling: /nomos/da/{version}/sampling
Through these multiplexed streams, DA nodes can utilize the same connection for all sub-protocols. This, combined with virtual subnetworks (membership sets), ensures the overlay node distribution is scalable for networks of any size.
References
- Encoding Specification
- Encoding & Verification Specification
- NomosDA Dispersal
- NomosDA Subnetwork Replication
- DA Subnetwork Assignation
- NomosDA Sampling
- NomosDA Reconstruction
- NomosDA Indexing
- SDP
- invoked based on the node's current role
- 20 as per the sampling research
- multiplexed
- QUIC
Copyright
Copyright and related rights waived via CC0.
NOMOS-DIGITAL-SIGNATURE
| Field | Value |
|---|---|
| Name | Nomos Digital Signature |
| Slug | |
| Status | raw |
| Category | Standards Track |
| Editor | Jimmy Debe [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
99ca13a— New RFC: Nomos Digital Signature (#167)
Abstract
This specification describes the digital signature schemes used across different components in the Nomos system design. Throughout the system, each Nomos layer shares the same signature scheme, ensuring consistent security and interoperability. The specification covers EdDSA for general-purpose signing and ZKSignature for zero-knowledge proof of key ownership.
Keywords: digital signature, EdDSA, Ed25519, zero-knowledge proof, ZKSignature, cryptography, elliptic curve, Curve25519
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Term | Description |
|---|---|
| EdDSA | Edwards-curve Digital Signature Algorithm, a signature scheme based on twisted Edwards curves. |
| Ed25519 | An instance of EdDSA using Curve25519, providing 128-bit security. |
| ZKSignature | A zero-knowledge signature scheme that proves knowledge of a secret key without revealing it. |
| Prover | An entity that generates a cryptographic proof or signature. |
| Verifier | An entity that validates a cryptographic proof or signature. |
| Public Key | The publicly shareable component of a key pair, used for verification. |
| Secret Key | The private component of a key pair, used for signing and proof generation. |
Background
The Nomos Bedrock consists of a few key components that Nomos Network is built on. See the Nomos whitepaper for more information. The Bedrock Mantle component serves as the operating system of Nomos. This includes facilitating operations like writing data to the blockchain or a restricted ledger of notes to support payments and staking. This component also defines how Nomos zones update their state and the coordination between the Nomos zone executor nodes. It is like a system call interface designed to provide a minimal set of operations to interact with lower-level Bedrock services. It is an execution layer that connects Nomos services to provide the necessary functionality for sovereign rollups and zones. See Common Ledger specification for more on Nomos zones.
In order for the Bedrock layer to remain lightweight, it focuses on data availability and verification rather than execution. Native zones on the other hand will be able to define their state transition function and prove to the Bedrock layer their correct execution. The Bedrock layer components share the same digital signature mechanism to ensure security and privacy. This document describes the validation tools that are used with Bedrock services in the Nomos network.
Protocol Specification
The signature schemes used by the provers and verifiers include:
- EdDSA Digital Signature Algorithm
- ZKSignature (Zero-Knowledge Signature)
EdDSA
EdDSA is a signature scheme based on elliptic-curve cryptography, defined over twisted Edwards curves. Nomos uses the Ed25519 instance with Curve25519, providing 128-bit security for general-purpose signing. EdDSA SHOULD NOT be used for ZK circuit construction.
The prover computes the following EdDSA signature using twisted Edwards curve Curve25519:
$$-x^2 + y^2 = 1 - (121665/121666)x^2y^2 \mod{(2^{255} - 19)}$$
- The public key size MUST be 32 bytes.
- The signature size MUST be 64 bytes.
- The public key MUST NOT already exist in the system.
ZKSignature
The ZKSignature scheme enables a prover to demonstrate cryptographic knowledge of a secret key, corresponding to a publicly available key, without revealing the secret key itself. The following is the structure for a proof attesting public key ownership:
class ZkSignaturePublic:
public_keys: list[ZkPublicKey] # The public keys signing the message
msg: hash # The hash of the message
The prover knows a witness:
class ZkSignatureWitness:
# The list of secret keys used to sign the message
secret_keys: list[ZkSecretKey]
Such that the following constraints hold:
- The number of secret keys is equal to the number of public keys:
assert len(secret_keys) == len(public_keys)
- Each public key is derived from the corresponding secret key:
assert all(
notes[i].public_key == hash("NOMOS_KDF", secret_keys[i])
for i in range(len(public_keys))
)
- The proof MUST be embedded in the hashed
msg.
The ZKSignature circuit MUST take a maximum of 32 public keys as inputs.
To prove ownership when using fewer than 32 keys,
the remaining inputs MUST be padded with the public key corresponding
to the secret key 0.
These padding entries are ignored during execution.
The outputs of the circuit have no size limit,
as they MUST be included in the hashed msg.
Security Considerations
Key Management
Secret keys MUST be stored securely and never transmitted in plaintext. Implementations MUST use secure random number generators for key generation.
EdDSA Security
EdDSA provides 128-bit security when used with Ed25519. Implementations MUST validate public keys before use to prevent small subgroup attacks. Signature verification MUST reject malformed signatures.
ZKSignature Security
The ZKSignature scheme relies on the security of the underlying hash function
and the zero-knowledge proof system.
The hash function used for key derivation (NOMOS_KDF) MUST be collision-resistant.
Implementations MUST verify that proofs are well-formed before accepting them.
Replay Protection
Signatures SHOULD include context-specific data (such as timestamps or nonces) to prevent replay attacks across different contexts or time periods.
References
Normative
- RFC 2119 - Key words for use in RFCs to Indicate Requirement Levels
Informative
- Nomos whitepaper - The Nomos Whitepaper
- Common Ledger specification - Common Ledger Specification
- Edwards curves - Twisted Edwards Curves
Copyright
Copyright and related rights waived via CC0.
NOMOS-KEY-TYPES-GENERATION
| Field | Value |
|---|---|
| Name | Nomos Key Types and Generation |
| Slug | 84 |
| Status | raw |
| Category | Standards Track |
| Editor | Mehmet Gonen [email protected] |
| Contributors | Marcin Pawlowski [email protected], Youngjoon Lee [email protected], Alexander Mozeika [email protected], Thomas Lavaur [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document defines the key types used in the Blend protocol and describes the process of generating them.
Keywords: cryptography, keys, Blend, encryption, signing, NQK, NSK, ESK, NEK, EEK
Background
The Blend protocol is a mix network protocol that provides anonymous communication in the Nomos network. It uses layered encryption and message mixing to prevent traffic analysis and ensure sender anonymity. For more details, see Blend Protocol.
This document ensures that the keys are used and generated in a common manner, which is necessary for making the Blend protocol work.
Core nodes are nodes that participate in the Blend network by mixing and forwarding messages. They are registered through the Service Declaration Protocol (SDP) and store their credentials on the Nomos blockchain ledger.
Blend messages are encrypted messages that are routed through the mix network. Each message is encapsulated with multiple layers of encryption, one for each hop in the network.
The keys defined in this specification include:
- Non-ephemeral Quota Key (NQK) — used for proving that a node is a core node.
- Non-ephemeral Signing Key (NSK) — used to authenticate the node on the network level and derive the Non-ephemeral Encryption Key.
- Ephemeral Signing Key (ESK) — used for signing Blend messages, one per encapsulation.
- Non-ephemeral Encryption Key (NEK) — used for deriving shared secrets for message encryption.
- Ephemeral Encryption Key (EEK) — used for encrypting Blend messages, one per encapsulation.
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Document Structure
This specification is organized into two distinct parts to serve different audiences and use cases:
Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations must follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.
Implementation Considerations provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.
Protocol Specification
This section defines the normative cryptographic protocol requirements for interoperability.
Construction
Non-ephemeral Quota Key
A node generates a Non-ephemeral Quota Key (NQK)
that is a ZkSignature (Zero Knowledge Signature Scheme).
The NQK is stored on the Nomos blockchain ledger
as the zk_id field in the DeclarationInfo
(see Service Declaration Protocol)
of the node's outcome of the participation in the
Service Declaration Protocol (SDP).
The NQK is used to prove that the node is part of the set of core nodes as indicated through the SDP.
Properties:
- Type: ZkSignature (Zero Knowledge Signature Scheme)
- Storage: Nomos blockchain ledger (
zk_idfield inDeclarationInfo) - Purpose: Prove core node membership
- Lifecycle: Non-ephemeral (persistent across sessions)
Non-ephemeral Signing Key
A node generates a Non-ephemeral Signing Key (NSK)
using the Ed25519 algorithm (see RFC 8032).
The NSK is stored on the Nomos blockchain ledger
as the provider_id field in the DeclarationInfo
(see Service Declaration Protocol)
of the node's outcome of the participation in the
Service Declaration Protocol (SDP).
The NSK is used to authenticate the node on the network level and to derive the Non-ephemeral Encryption Key.
Properties:
- Type: Ed25519 (see RFC 8032)
- Storage: Nomos blockchain ledger (
provider_idfield inDeclarationInfo) - Purpose:
- Network-level node authentication
- Derivation of Non-ephemeral Encryption Key (NEK)
- Lifecycle: Non-ephemeral (persistent across sessions)
Ephemeral Signing Key
A node generates Ephemeral Signing Keys (ESK) that are proved to be limited in number by the Proof of Quota (PoQ). The PoQ for core nodes requires a valid NQK for the session for which the PoQ is generated.
A unique signing key MUST be generated for every encapsulation as required by the Message Encapsulation Mechanism.
Properties:
- Type: Ed25519
- Quantity: Limited by Proof of Quota (PoQ)
- Requirements: Valid NQK for the session
- Purpose: Signing Blend messages
- Lifecycle: Ephemeral (one per encapsulation)
Security Requirements:
- The key MUST NOT be reused. Otherwise, the messages that reuse the same key can be linked together.
- The node is responsible for not reusing the key.
- A unique signing key MUST be generated for every encapsulation.
Non-ephemeral Encryption Key
A node generates a Non-ephemeral Encryption Key (NEK).
It is an X25519 curve key (see RFC 7748)
derived from the NSK (Ed25519) public key retrieved from the provider_id,
which is stored on the Nomos blockchain ledger
when the node executes the SDP protocol.
The NEK key is used for deriving a shared secret (alongside EEK defined below) for the Blend message encapsulation purposes.
Properties:
- Type: X25519 (see RFC 7748)
- Derivation: Derived from NSK (Ed25519) public key
- Source:
provider_idfield from Nomos blockchain ledger - Purpose: Deriving shared secrets for message encryption
- Lifecycle: Non-ephemeral (persistent across sessions)
Derivation Process:
- Retrieve NSK (Ed25519) public key from
provider_idon Nomos blockchain ledger - Derive X25519 curve key from Ed25519 public key
- Use resulting NEK for shared secret derivation
Ephemeral Encryption Key
A node derives an Ephemeral Encryption Key (EEK) pair using the X25519 curve (see RFC 7748) from the ESK.
A unique encryption key MUST be generated for every encapsulation as required by the Message Encapsulation Mechanism.
Properties:
- Type: X25519 (see RFC 7748)
- Derivation: Derived from ESK (Ed25519)
- Purpose: Encrypting Blend messages
- Lifecycle: Ephemeral (one per encapsulation)
Shared Secret Derivation:
The derivation of a shared secret for the encryption of an encapsulated message requires:
- Sender: EEK (Ephemeral Encryption Key of sender)
- Recipient: X25519 key derived from NEK (Non-ephemeral Encryption Key of recipient)
The shared secret is computed using the X25519 Diffie-Hellman key exchange between the sender's EEK and the recipient's derived NEK.
Security Considerations
Key Reuse
- CRITICAL: Ephemeral keys (ESK, EEK) MUST NOT be reused across different encapsulations
- Key reuse enables message linking, breaking anonymity guarantees
- Implementations MUST enforce unique key generation per encapsulation
Key Derivation
- NEK derivation from NSK MUST use standard Ed25519 to X25519 conversion
- EEK derivation from ESK MUST use standard Ed25519 to X25519 conversion
- Derivations MUST be deterministic for the same input
Proof of Quota
- ESK generation MUST be limited by valid Proof of Quota (PoQ)
- PoQ MUST include valid NQK for the current session
- Implementations MUST verify PoQ before accepting ephemeral signatures
Ledger Storage
- NQK and NSK MUST be retrievable from Nomos blockchain ledger via SDP protocol
- Ledger data MUST be integrity-protected
- Implementations SHOULD verify ledger data authenticity before use
Implementation Considerations
This section provides guidance for implementing the protocol specification.
Key Hierarchy Summary
┌─────────────────────────────────────────────────────────────┐
│ Nomos Blockchain Ledger (SDP Protocol) │
├─────────────────────────────────────────────────────────────┤
│ DeclarationInfo: │
│ - zk_id: NQK (ZkSignature) │
│ - provider_id: NSK (Ed25519) │
└─────────────────────────────────────────────────────────────┘
│
│ Derivation
▼
┌─────────────────────────────────────────────────────────────┐
│ Non-ephemeral Keys (Persistent) │
├─────────────────────────────────────────────────────────────┤
│ NQK (ZkSignature) ──► Proves core node membership │
│ NSK (Ed25519) ──► Network authentication │
│ NEK (X25519) ──► Derived from NSK for encryption │
└─────────────────────────────────────────────────────────────┘
│
│ Per-encapsulation
▼
┌─────────────────────────────────────────────────────────────┐
│ Ephemeral Keys (Per Encapsulation) │
├─────────────────────────────────────────────────────────────┤
│ ESK (Ed25519) ──► Signs Blend messages (via PoQ + NQK) │
│ EEK (X25519) ──► Derived from ESK for encryption │
└─────────────────────────────────────────────────────────────┘
Key Usage Matrix
| Key Type | Algorithm | Storage | Lifecycle | Primary Use | Derived From |
|---|---|---|---|---|---|
| NQK | ZkSignature | Nomos blockchain (zk_id) | Non-ephemeral | Core node proof | Generated |
| NSK | Ed25519 | Nomos blockchain (provider_id) | Non-ephemeral | Authentication | Generated |
| NEK | X25519 | Derived | Non-ephemeral | Shared secret derivation | NSK public key |
| ESK | Ed25519 | Memory | Ephemeral | Message signing | Generated (PoQ-limited) |
| EEK | X25519 | Memory | Ephemeral | Message encryption | ESK |
Implementation Requirements
Implementations of this specification MUST:
- Generate NQK as ZkSignature and store in
DeclarationInfo.zk_id - Generate NSK as Ed25519 and store in
DeclarationInfo.provider_id - Derive NEK from NSK using Ed25519 to X25519 conversion
- Generate unique ESK per encapsulation, limited by PoQ
- Derive EEK from ESK using Ed25519 to X25519 conversion
- Never reuse ephemeral keys across encapsulations
- Verify PoQ includes valid NQK before generating ESK
Implementations SHOULD:
- Securely erase ephemeral keys after use
- Implement key generation auditing
- Validate all derived keys before use
- Monitor for key reuse attempts
Best Practices
Secure Key Management
- Store non-ephemeral keys in secure storage (HSM, secure enclave, or encrypted memory)
- Implement secure key erasure for ephemeral keys immediately after use
- Use constant-time operations for key comparisons to prevent timing attacks
Operational Security
- Log key generation events (without logging key material)
- Monitor for anomalous key usage patterns
- Implement rate limiting on key generation to prevent resource exhaustion
- Regularly audit key lifecycle management
References
Normative
- Blend Protocol - Mix network protocol for anonymous communication in Nomos
- Service Declaration Protocol (SDP) - Protocol for registering core nodes
and storing
DeclarationInfoon the Nomos blockchain ledger - Proof of Quota Specification (PoQ)
- Message Encapsulation Mechanism
- Zero Knowledge Signature Scheme (ZkSignature)
Informative
- Key Types and Generation Specification - Original Key Types and Generation documentation
- RFC 8032 - Edwards-Curve Digital Signature Algorithm (EdDSA)
- RFC 7748 - Elliptic Curves for Security (X25519)
- Ed25519 to Curve25519 conversion: Standard practice for deriving X25519 keys from Ed25519 keys
Copyright
Copyright and related rights waived via CC0.
NOMOS-MESSAGE-ENCAPSULATION
| Field | Value |
|---|---|
| Name | Nomos Message Encapsulation Mechanism |
| Slug | 91 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski |
| Contributors | Youngjoon Lee [email protected], Alexander Mozeika [email protected], Mehmet Gonen [email protected], Álvaro Castro-Castilla [email protected], Daniel Kashepava [email protected], Daniel Sanchez Quiros [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
The message encapsulation mechanism is a core component of the Blend Protocol that ensures privacy and security during node-to-node message transmission. By implementing multiple encryption layers and cryptographic operations, this mechanism keeps messages confidential while concealing their origins. The encapsulation process includes building a multi-layered structure with public headers, private headers, and encrypted payloads, using cryptographic keys and proofs for layer security and authentication, applying verifiable random node selection for message routing, and using shared key derivation for secure inter-node communication.
This document outlines the cryptographic notation, data structures, and algorithms essential to the encapsulation process.
Keywords: Blend, message encapsulation, encryption, privacy, layered encryption, cryptographic proof, routing
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Document Structure
This specification is organized into two distinct parts to serve different audiences and use cases:
Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations must follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.
Implementation Considerations provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.
Protocol Specification
Introduction
The message encapsulation mechanism is part of the Blend Protocol and it describes the cryptographic operations necessary for building and processing messages by a Blend node. See the Blend Protocol Formatting specification for additional context on message structure and formatting conventions.
Notation
Key Collections:
$\mathbf K^{n}h = {(K^{n}{0}, k^{n}{0}, \pi{Q}^{K_{0}^{n}}),...,(K^{n}{h-1}, k^{n}{h-1}, \pi_{Q}^{K_{h-1}^{n}}) }$ is a collection of $h$ key pairs for a node $n$ with proofs of quota, where $K_{i}^{n}$ is the $i$-th public key and $k_{i}^{n}$ is its corresponding private key, and $\pi_{Q}^{K_{i}^{n}}$ is its proof of quota.
Ed25519PublicKey = bytes
Ed25519PrivateKey = bytes
KEY_SIZE = 32
ProofOfQuota = bytes
PROOF_OF_QUOTA_SIZE = 160
KeyCollection = List[KeyPair]
class KeyPair:
signing_public_key: Ed25519PublicKey
signing_private_key: Ed25519PrivateKey
proof_of_quota: ProofOfQuota
class ProofOfQuota:
key_nullifier: zkhash # 32 bytes
proof: bytes # 128 bytes
For more information about key generation mechanism please refer to the Key Types and Generation Specification.
For more information about proof of quota please refer to the Proof of Quota Specification.
Service Declaration Protocol:
$P^n$ is a public key of the node $n$, which is globally accessible using the Service Declaration Protocol (SDP). This notation is used to distinguish the origin of the key, hence the following simplified notation.
For more information about Service Declaration Protocol please refer to the Service Declaration Protocol.
$\mathcal{N} = \text{SDP}(s)$ is the set of nodes globally accessible using the SDP.
Nodes = set[Ed25519PublicKey] # set of signing public keys
$N =|\mathcal{N}|$ is the number of nodes globally accessible using the SDP.
Shared Keys:
$\kappa^{n,m}{i} = k^{n}{i} \cdot P^{m} = p^{m} \cdot K^{n}_{i}$, is a shared key calculated between node $n$ and node $m$ using the $i$-th key of the node $n$, $P^{m}$ is the public key of the node $m$ retrieved from the SDP protocol and $p^m$ is its corresponding private key.
SharedKey = bytes # KEY_SIZE
Proof of Selection:
$\pi^{K^{n}{l},m}{S}$ is the proof of selection of the public key $K^{n}_l$ to the node index $m$ from a set of all nodes $\mathcal N$.
ProofOfSelection = bytes
PROOF_OF_SELECTION_SIZE = 32
For more information about the proof of selection, please refer to the Proof of Selection Specification.
Hash Functions:
$H_{\mathbf N}()$ is a domain-separated hash function dedicated to the node index selection (the implementation of the hash function is blake2b).
def hashds(domain=b"BlendNode", data: bytes) -> bytes:
return Blake2B.hash512(domain + data)
$H_\mathbf{I}()$ is a domain-separated hash function dedicated to the initialization of the blend header (the implementation of the hash function is blake2b).
def hashds(domain=b"BlendInitialization", data: bytes) -> bytes:
return Blake2b.hash512(domain + data)
$H_\mathbf{b}()$ is a domain-separated hash function dedicated to the blend header encryption operations (the implementation of the hash function is blake2b).
def hashds(domain=b"BlendHeader", data: bytes) -> bytes:
return Blake2b.hash512(domain + data)
$H_\mathbf{P}()$ is a domain-separated hash function dedicated to the payload encryption operations (the implementation of the hash function is blake2b).
def hashds(domain=b"BlendPayload", data: bytes) -> bytes:
return Blake2b.hash512(domain + data)
Encapsulation Parameters:
$\beta_{max}$ is the maximal number of blending headers in the private header.
ENCAPSULATION_COUNT: int
Pseudo-Random Generation:
$\text {CSPRBG}()$ is a generalized cryptographically secure pseudo-random bytes generator, it is implemented as BLAKE2b-Based PRNG Construction.
$\text {CSPRBG}()_{x}$ is a cryptographically secure pseudo-random bytes generator whose output is restricted to $x$ bytes, it is implemented as BLAKE2b-Based PRNG Construction.
def pseudo_random(domain: bytes, key: bytes, size: int) -> bytes:
rand = BlakeRng.from_seed(hashds(domain, key)).generate(size)
assert len(rand) == size
return rand
Basic Operations:
$|t|$ returns the length of the $t$ expressed in bytes.
$\oplus$ is a XOR operation.
def xor(a: bytes, b: bytes) -> bytes:
assert len(a) == len(b)
return bytes(x ^ y for x, y in zip(a, b))
Encryption and Decryption:
$E_k(x)=\text{CSPRBG}(k) \oplus x$ is an encryption that uses a cryptographically secure pseudo-random bytes generator with a secret $k$ and payload $x$.
def encrypt(data: bytes, key: bytes) -> bytes:
return xor(data, pseudo_random(b"BlendEncapsulation", key, len(data)))
$D_k(x)=\text{CSPRBG}(k) \oplus x$ is a decryption that uses cryptographically secure pseudo-random bytes generator with a secret $k$ and payload $x$.
def decrypt(data: bytes, key: bytes) -> bytes:
return xor(data, pseudo_random(b"BlendEncapsulation", key, len(data)))
Construction
Message Structure
The following defines the message structure that provides the protocol with the envisioned capabilities.
A node $n$ constructs a message $\mathbf M = (\mathbf H, \mathbf h, \mathbf P)$ according to the format presented below.
class Message:
public_header: PublicHeader
private_header: PrivateHeader
payload: EncryptedPayload
Public Header:
$\mathbf H$ is a public header:
- $V$, version of the header, it is set to $1$.
- $K^{n}_i$, a public key from the set $\mathbf K^n_h$.
- $\pi^{K^{n}i}{Q}$, a corresponding proof of quota for the key $K^{n}_i$ from the set $\mathbf K^n_h$ and contains its proof nullifier.
- $\sigma_{K^{n}_{i}}(\mathbf {h|P}i)$, a signature of the concatenation of the $i$-th encapsulation of the payload $\mathbf P$ and the private header $\mathbf h$, that can be verified by the public key $K^{n}{i}$.
Signature = bytes
SIGNATURE_SIZE = 64
class PublicHeader:
version: int = 1 # u8
signing_public_key: Ed25519PublicKey
proof_of_quota: ProofOfQuota
signature: Signature
Private Header:
$\mathbf h = (\mathbf b_1,...,\mathbf b_{\beta_{max}})$ is an encrypted private header:
$\mathbf b_l$ is a blending header:
- $K^{n}_{l}$, a public key from the set $\mathbf K^n_h$.
- $\pi^{K^{n}{l}}{Q}$, a corresponding proof of quota for the key $K^{n}_l$ from the $\mathbf K^n_h$ and contains its proof nullifier.
- $\sigma_{K^{n}_{l}}(\mathbf {h|P}l)$, a signature of the concatenation of the $l$-th encapsulation of the payload $\mathbf P$ and the private header $\mathbf h$, that can be verified by public key $K^{n}{l}$.
- $\pi^{K^{n}{l+1},m{l+1}}{S}$, a proof of selection of the node index $m{l+1}$ assuming public key $K^{n}_{l+1}$.
- $\Omega$, a flag that indicates that this is the last blending header.
PrivateHeader = List[EncryptedBlendingHeader] # length: ENCAPSULATION_COUNT
EncryptedBlendingHeader = bytes
class BlendingHeader:
signing_public_key: Ed25519PublicKey
proof_of_quota: ProofOfQuota
signature: Signature
proof_of_selection: ProofOfSelection
is_last: bool # 1 byte
Payload:
$\mathbf P$ is a payload.
EncryptedPayload = bytes
PAYLOAD_BODY_SIZE = 34 * 1024
class Payload:
header: PayloadHeader
body: bytes # PAYLOAD_BODY_SIZE
class PayloadHeader:
payload_type: PayloadType # 1 byte
body_len: int # u16
class PayloadType(Enum):
COVER = 0x00
DATA = 0x01
Keys and Proof Generation
For simplicity of the presentation, this specification does not distinguish between signing and encryption keys. However, in practice, such a distinction is necessary, that is:
- The $\mathbf K^n_h$ contains Ephemeral Signing Keys (ESK) that are part of the PoQ generation and are used for message signing; these are included in the public and private headers.
- Shared secret keys used for encryption of messages are generated from an Ephemeral Encryption Key (sender), which is derived from the ESK, and from a Non-ephemeral Encryption Key (NEK) (receiver), which is derived from a Non-ephemeral Signing Key (NSK) retrieved from the SDP protocol.
For more information, look at Key Types and Generation Specification.
The first step is to generate a set of keys alongside all necessary proofs that will be used in the next steps of the algorithm.
Step 1: Generate Key Collection
Generate the collection $\mathbf K^n_h$, where $h$ defines the number of encapsulation layers such that $h \le \beta_{max}$.
def generate_key_collection(num_layers: int) -> List[KeyPair]:
assert num_layers <= ENCAPSULATION_COUNT
# Generate `num_layers` random KeyPairs non-deterministically.
return [KeyPair.random() for _ in range(num_layers)]
The key collection generation requires generation of Proof of Quota (Proof of Quota Specification) for each key, as defined in the following steps.
The ProofOfQuotaPublic (Public values) structure must be filled with public information:
- session, core_quota, leader_quota, core_root, pol_epoch_nonce, pol_t0, pol_t1, pol_ledger_aged are retrieved from the blockchain.
- K_part_one and K_part_two are first and second part of the signature key (KeyPair) generated by the above generate_key_collection.
class ProofOfQuotaPublic:
session: int # Session number (uint64)
core_quota: int # Allowed messages per session for core nodes
leader_quota: int # Allowed messages per session for potential leaders
core_root: zkhash # Merkle root of zk_id of the core nodes
K_part_one: int # First part of the signature public key (16 bytes)
K_part_two: int # Second part of the signature public key (16 bytes)
pol_epoch_nonce: int # PoL Epoch nonce
pol_t0: int # PoL constant t0
pol_t1: int # PoL constant t1
pol_ledger_aged: zkhash # Merkle root of the PoL eligible notes
# Outputs:
key_nullifier: zkhash # derived from session, private index and private sk
The ProofOfQuotaWitness (Witness) structure must be filled as follows:
- If the message contains cover traffic then:
- The core quota is used and the selector=0 value must be specified.
- The index counts the number of cover messages and must be below core_quota.
- The core_sk, core_path, core_path_selector are filled by the node to prove that the node is the core node.
- The rest of the ProofOfQuotaWitness, is filled with arbitrary data.
- If the message contains data then:
- The leader quota is used and the selector=1 value must be specified.
- The index counts the number of data messages and must be below leader_quota.
- The core_sk, core_path, core_path_selector are filled with arbitrary data.
- The rest is filled with Proof of Leadership (PoL) related data.
class ProofOfQuotaWitness:
index: int # This is the index of the generated key. Limiting this index
# limits the maximum number of key generated. (20 bits)
selector: int # Indicates if it's a leader (=1) or a core node (=0)
# This part is filled randomly by potential leaders
core_sk: zkhash # sk corresponding to the zk_id of the core node
core_path: list[zkhash] # Merkle path proving zk_id membership (len = 20)
core_path_selectors: list[bool] # Indicates how to read the core_path (if
# Merkle nodes are left or right in the path)
# This part is filled randomly by core nodes
pol_sl: int # PoL slot
pol_sk_starting_slot: int # PoL starting slot of the slot secrets
pol_note_value: int # PoL note value
pol_note_tx_hash: zkhash # PoL note transaction
pol_note_output_number: int # PoL note transaction output number
pol_noteid_path: list[zkhash] # PoL Merkle path proving noteID membership
# in ledger aged (len = 32)
pol_noteid_path_selectors: list[bool] # Indicates how to read the note_path
# (if Merkle nodes are left or right in the path)
pol_slot_secret: int # PoL slot secret corresponding to sl
pol_slot_secret_path: list[zkhash] # PoL slot secret Merkle path to
# sk_secrets_root (len = 25)
The ProofOfQuotaPublic and ProofOfQuotaWitness are passed to the zero-knowledge circuits that generate the proof $\pi^{K^{n}{l}}{Q}$ which derives the key_nullifier ($\nu_s$) from session, private index, private secret key during proof generation.
Step 2: Select Nodes
Select $h$ nodes from the set of nodes $\mathcal{N}$ in a random and verifiable manner. For $i \in {1,…,h}$, select $l_i = \text{CSPRBG}(H_{\mathbf N}(\rho))_{8} \mod N$, where $\rho$ is a selection randomness (using little-endian encoding), a shared secret derived during Proof of Quota generation, the output of the $\text{CSPRBG}()_8$ is returns 8 bytes (little-endian).
def select_nodes(key_collection: List[KeyPair], nodes: List[Node]) -> List[Node]:
selected_nodes = []
for keypair in key_collection:
rand = pseudo_random(
b"BlendNode",
selection_randomness,
8
)
index = modular_bytes(rand, NUM_NODES)
selected_nodes.append(nodes[index])
return selected_nodes
def modular_bytes(data: bytes, modulus: int) -> int:
# Convert data into an unsigned big integer using little-endian.
return int.from_bytes(data, byteorder='little') % modulus
Step 3: Generate Proofs of Selection
Generate proofs of selection $\pi^{K^{n}i,l_i}{S}$ for $i \in {1,…,h}$, which proves that the public key $K^{n}_i$ correctly maps to the index $l_i$ from the set of nodes $\mathcal{N}$.
Step 4: Retrieve Public Keys
For $i \in {1,…,h}$, retrieve public keys $\mathcal P = { {P^{l_1},..., P^{l_h}} }$ for all $h$ selected nodes using the SDP protocol (defined as provider_id in Identifiers).
def blend_node_signing_public_keys(selected_nodes: List[Node]) -> List[Ed25519PublicKey]:
return [node.signing_public_key for node in selected_nodes]
Step 5: Calculate Shared Keys
For $i \in {1,…,h}$, calculate shared keys from a set of public keys of selected nodes $\kappa^{n,i}{i} = k^{n}{i} \cdot P^{l_i}$.
def derive_shared_keys(key_collection: List[KeyPair],
blend_node_signing_public_keys: List[Ed25519PublicKey]) -> List[SharedKey]:
assert len(key_collection) == len(blend_node_signing_public_keys)
assert len(key_collection) <= ENCAPSULATION_COUNT
shared_keys = []
for (keypair, blend_node_signing_public_key) in zip(key_collection,
blend_node_signing_public_keys):
encryption_private_key = signing_private_key.derive_x25519()
blend_node_encryption_public_key = blend_node_signing_public_key
shared_key = diffie_hellman(encryption_private_key,
blend_node_encryption_public_key)
shared_keys.append(shared_key)
return shared_keys
Node Selection Mechanism:
In step 2 of the algorithm above, the sender constructs a blending path from nodes sampled at random but in a verifiable manner. The nodes are selected deterministically (and randomly) by the key value. The key to node mapping is proven in step 3.
The node selection proof $\pi^{K^n_i,l_i}_{S}$ is constructed in such a way that it proves only the fact that the key $K^n_i$ used for the encryption maps correctly to the node index $l_i$ from the stable set of nodes $\mathcal{N}$. This proof should be considered a private proof intended only for the recipient blend node.
This mechanism intends to limit the possibility of "double spending" the emission token. This restricts the sender's ability to use the same emission token twice, first for constructing and emitting a message and then for claiming a reward for it.
For more information about proof of selection please refer to the Proof of Selection Specification.
Message Initialization
The second step is to create an empty message $\mathbf M$ and fill the private header with random values.
Step 1: Create Empty Message
Create an empty message $\mathbf M$ (filled with zeros).
Step 2: Randomize Private Header
Randomize the private header: For $\mathbf b_i \in \mathbf h = (\mathbf b_{1},...,\mathbf b_{\beta_{max}})$, set $\mathbf b_{i} = \text {CSPRBG}( \rho_{i})_{|\mathbf b|}$, where $\rho_i$ is some random value.
def randomize_private_header() -> PrivateHeader:
blending_headers = []
for _ in range(ENCAPSULATION_COUNT):
blending_header = pseudo_random(b"BlendRandom", entropy(),
BlendingHeader.SIZE)
blending_headers.append(blending_header)
return blending_headers
Step 3: Fill Last Blend Headers
Fill the last $h$ blend headers with reconstructable payloads: For $i = { 1+\beta_{max}-h,...,\beta_{max})$, do the following:
- $t=\beta_{max} - i + 1$
- $r_{t,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|1)){|K|}$
- $r_{t,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|2)){|\pi^{K}_{Q}|}$
- $r_{t,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|3)){|\sigma_{K}(\mathbf P)|}$
- $r_{t,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|4)){|\pi^{K,k}_{S}|}$
- $\mathbf{b}i = { r{t,1}, r_{t,2}, r_{t,3}, r_{t,4} }$.
def fill_last_blending_headers(private_header: PrivateHeader,
shared_keys: List[SharedKeys]) -> PrivateHeader:
assert len(private_header) == ENCAPSULATION_COUNT
assert len(shared_keys) <= ENCAPSULATION_COUNT
pseudo_random_blending_headers = []
for shared_key in shared_keys:
r1 = pseudo_random(b"BlendInitialization", shared_key + b"\\x01", KEY_SIZE)
r2 = pseudo_random(b"BlendInitialization", shared_key + b"\\x02",
PROOF_OF_QUOTA_SIZE)
r3 = pseudo_random(b"BlendInitialization", shared_key + b"\\x03",
SIGNATURE_SIZE)
r4 = pseudo_random(b"BlendInitialization", shared_key + b"\\x04",
PROOF_OF_SELECTION_SIZE)
pseudo_random_blending_headers.append(r1 + r2 + r3 + r4)
# Replace the last `len(shared_keys)` blending headers.
private_header[-num_layers:] = pseudo_random_blending_headers
return private_header
Step 4: Encrypt Last Blend Headers
Encrypt the last $h$ blend headers in a reconstructable manner: For $i={ 1,...,h }$, for $j={1, ..., i }$, encrypt blend header:
$$ \mathbf{b}{\beta{max}-i+1}=E_{H_{\mathbf b}(\kappa^{n,l_j}{j})}(\mathbf b{\beta_{max}-i+1}) $$
def encrypt_last_blending_headers(private_header: PrivateHeader,
shared_keys: List[SharedKeys]) -> PrivateHeader:
assert len(private_header) == ENCAPSULATION_COUNT
assert len(shared_keys) <= ENCAPSULATION_COUNT
for i, _ in enumerate(shared_keys):
index = len(private_header) - i - 1
for shared_key in shared_keys[:i + 1]:
private_header[index] = encrypt(private_header[index], shared_key)
return private_header
This prevents leakage of the encryption sequence when a message is encapsulated less than $\beta_{max}$ times, and enables us to encode the header in a way that it can be reconstructed during the decapsulation.
Message Encapsulation
The final part of the algorithm is the true encapsulation of the payload. That is, given the payload $\mathbf P_0$ and number of encapsulations $h \le \beta_{max}$ we do the following.
For $i \in { 1,…,h }$ do the following:
- If $i=1$ then generate a new ephemeral key pair: $(K^n_0, k^n_0) \notin \mathbf K^n_h$.
- Calculate the signature of the concatenation of the current header and payload: $\sigma_{K^{n}{i-1}}(\mathbf h{i-1}| \mathbf P_{i-1})$.
- Using the shared key $\kappa^{n,l_i}i$, encrypt the payload: $\mathbf{P}i = E{H\mathbf{P}( \kappa^{n,l_i}i)}(\mathbf P{i-1})=\mathbf{P}{i-1} \oplus \text {CSPRBG}(H\mathbf{P}(\kappa^{n,l_i}_i))$.
- Shift blending headers by one downward: $\mathbf b_z \rightarrow \mathbf b_{z+1}$ for $z \in { 1,…,\beta_{max} }$. The first blending header is now empty, and the last blending header is truncated.
- Fill the blending header $\mathbf b_1$, where $1$ refers to the top position:
- If $i=1$ then:
- Fill the proof of quota with random data: $\pi^{K^{n}0}{Q}= \text {CSPRBG}(H_\mathbf{I}(k^{n}0)){|\pi^{K}_{Q}|}$
- Set the last flag to 1: $\Omega=1$
- Else set the last flag to 0: $\Omega = 0$
- $\mathbf{b}1 = { K^n{i-1}, \pi^{K^{n}{i-1}}{Q}, \sigma_{K^{n}{i-1}}(\mathbf h{i-1}|\mathbf P_{i-1}), \pi^{K^{n}i,l_i}{S}, \Omega }$.
- If $i=1$ then:
- Using shared key $\kappa^{n,l_i}i$, encrypt the private header $\mathbf{h}{E_{i}} = E_{H_{\mathbf b}(\kappa^{n,l_i}_i)}(\mathbf{h}_i)$:
For each $\mathbf b_j \in \mathbf h_i = (\mathbf b_1,...,\mathbf b_{m_{max}})$ using a shared key $\kappa^{n,l_i}i$, encrypt the blending header: $\mathbf{b}j = E{H\mathbf{b}(\kappa^{n,l_i}_i)}(\mathbf{b}_j)=\mathbf{b}j \oplus \text {CSPRBG}(H\mathbf{b}(\kappa^{n,l_i}_i))$.
Fill in the public header: $\mathbf H={ K^{n}h, \pi^{K^{n}h}{Q}, \sigma{K^{n}_h}(\mathbf P_h) }$.
The message is encapsulated.
def encapsulate(
private_header: PrivateHeader,
payload: Payload,
shared_keys: List[SharedKeys],
key_collection: List[KeyPair],
list_of_pos: List[ProofOfSelection]
) -> bytes:
# Step 1 ~ 6: Encapsulate private header and payload
prev_keypair = KeyPair.random()
is_first_selected = True
for shared_key, keypair, proof_of_selection) in zip(shared_keys, key_collection,
list_of_pos):
private_header, payload = encapsulate_private_part(
private_header,
payload.bytes(),
shared_key,
prev_keypair.signing_private_key,
prev_keypair.proof_of_quota,
proof_of_selection,
# The first encapsulation is for the last decapsulation.
is_last=is_first_selected,
)
prev_keypair = keypair
is_first = False
# Fill in the public header
public_header = PublicHeader(
prev_keypair.signing_public_key,
prev_keypair.proof_of_quota,
signature=sign(private_part, prev_keypair.signing_private_key),
version=1,
)
return public_header.bytes() + b"".join(private_headers) + payload
def encapsulate_private_part(
private_header: PrivateHeader,
payload: EncryptedPayload,
shared_key: SharedKey,
signing_private_key: Ed25519PrivateKey,
proof_of_quota: ProofOfQuota,
proof_of_selection: ProofOfSelection,
is_last: bool
) -> bytes:
# Step 2: Calculate a signature on `private_header + payload`.
signature = sign(
signing_body(private_header, payload),
signing_private_key
)
# Step 3: Encrypt the payload
payload = encrypt(payload, shared_key)
# Step 4: Shift blending headers by one rightward.
private_header.pop() # Remove the last blending header
# Step 5: Add the new blending header to the front.
blending_header = BlendingHeader(
signing_private_key.public(),
proof_of_quota,
signature,
proof_of_selection,
is_last
)
private_header.insert(0, blending_header.bytes())
# Step 6: Encrypt the private header
for i, _ in enumerate(private_header):
private_header[i] = encrypt(private_header[i], shared_key)
return private_header, payload
def signing_body(private_header: PrivateHeader, payload: EncryptedPayload) -> bytes:
return b"".join(private_headers) + payload
Message Decapsulation
If a message $\mathbf M$ is received by the node and its public header is correct - that is, it was verified according to the relay logic defined here: Relaying - then the node $l$ executes the following logic:
- Calculate the shared secret. Using the key $K^{n}_l \in \mathbf H$ from the public header of the message $\mathbf M$ and the private key $p^l$ of the node $l$ calculate: $\kappa^{n,l}_l = K^{n}_l \cdot p^l$.
- Decrypt the private header using the shared key $\kappa^{n,l}l$. For each $\mathbf b_j \in \mathbf h = (\mathbf b_1,...,\mathbf b{\beta_{max}})$ using a shared key $\kappa^{n,l}l$ decrypt the blending header: $\mathbf{b}j = D{H\mathbf{b}(\kappa^{n,l}_l)}(\mathbf{b}_j)=\mathbf{b}j \oplus \text {CSPRBG}(H\mathbf{b}(\kappa^{n,l}_l))$.
- Verify the header:
- If the proof $\pi^{K^{n}l,l}{S}\in \mathbf b_1$ is not correct, discard the message. That is, if the node index $l$ does not correspond to the $K^{n}_l\in \mathbf H$, then the message must be rejected.
- If the key $K^{n}_l \in \mathbf b_1$ was already seen, discard the message.
- If the proof $\pi^{K^{n}l,l}{Q} \in \mathbf b_1$ is incorrect, discard the message.
- Using the blending header $\mathbf b_1$, set the public header: $\mathbf H_l = {K^{n}l \in \mathbf b_1,\pi^{K^{n}l,l}{Q} \in \mathbf b_1 ,\sigma{K^{n}_l}(\mathbf {h|P}) \in \mathbf b_1}$.
- Decrypt the payload, using the shared key $\kappa^{n,l}l$: $\mathbf{P}l =D{H\mathbf{P}(\kappa^{n,l}l)}=\mathbf{P} \oplus \text {CSPRBG}(H{\mathbf P}(\kappa^{n,l}_l))$.
- Reconstruct the blend header:
- $r_{l,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|1)){|K|}$
- $r_{l,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|2)){|\pi^{K}_{Q}|}$
- $r_{l,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|3)){|\sigma_{K}(\mathbf P)|}$
- $r_{l,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|4)){|\pi^{K,k}_{S}|}$
- $b = { r_{l,1}, r_{l,2}, r_{l,3}, r_{l,4} }$.
- Encrypt the blending header: $\hat b = E_{H_\mathbf{b}(\kappa^{n,{l}}_{l})}(b)$.
- Shift blending headers by one upward: $\mathbf b_z \rightarrow \mathbf b_{z-1}$ for $z \in { 1,…,\beta_{max} }$. The first blending header is truncated, and the last blending header is empty.
- Reconstruct the private header: $\mathbf h_{E_{l}} = {$ $…$ $\mathbf{b}{\beta{max}} = \hat b$, $}$.
- If the signature from the public header does not match the signature of the reconstructed header and the decrypted payload, discard the message: $\text{verify\sig}(\sigma{K^n_l}(\mathbf{h}_{E_l}| \mathbf{P}_l), \mathbf{h}| \mathbf{P},{K^n_l})$.
- The message is decapsulated.
- Follow the message processing logic: Processing.
def decapsulate(
message: bytes,
signing_private_key: Ed25519PrivateKey
) -> bytes:
# Step 1: Derive the shared key.
encryption_private_key = signing_private_key.derive_x25519()
public_header = PublicHeader.from_bytes(
message[Header.SIZE : Header.SIZE + PublicHeader.SIZE]
)
shared_key = diffie_hellman(
encryption_private_key,
public_header.signing_public_key.derive_x25519()
)
# Step 2: Decrypt the private header
private_header = message[
Header.SIZE + PublicHeader.SIZE:
Header.SIZE + PublicHeader.SIZE + (BlendingHeader.SIZE * ENCAPSULATION_COUNT)
]
for i, _ in enumerate(private_header):
private_header[i] = decrypt(private_header[i], shared_key)
# Step 3: Verify the first blending header
first_blending_header = BlendingHeader.from_bytes(private_header[0])
first_blending_header.validate()
# Step 4: Construct the new public header
public_header = PublicHeader(
first_blending_header.signing_public_key,
first_blending_header.proof_of_quota,
first_blending_header.signature,
version= 1,
)
# Step 5: Decrypt the payload
payload_offset = (
Header.SIZE + PublicHeader.SIZE + (BlendingHeader.SIZE * ENCAPSULATION_COUNT)
)
payload = message[payload_offset:]
payload = decrypt(payload, shared_key)
# Step 6: Reconstruct the new blending header
r1 = pseudo_random(b"BlendInitialization", shared_key + b"\\x01", KEY_SIZE)
r2 = pseudo_random(b"BlendInitialization", shared_key + b"\\x02",
PROOF_OF_QUOTA_SIZE)
r3 = pseudo_random(b"BlendInitialization", shared_key + b"\\x03", SIGNATURE_SIZE)
r4 = pseudo_random(b"BlendInitialization", shared_key + b"\\x04",
PROOF_OF_SELECTION_SIZE)
# Step 7: Encrypt the new blending header
encrypted_new_blending_header = encrypt(r1 + r2 + r3 + r4, shared_key)
# Step 8: Shift blending headers by one leftward.
private_header.pop(0) # Remove the first blending header.
# Step 9: Add the new blending header to the end.
private_header.append(encrypted_new_blending_header)
# Step 10: Verify the signature
verify_signature(
first_blending_header.signature,
signing_body(private_header, payload)
first_blending_header.signing_public_key,
)
header = message[0:Header.SIZE]
return header + public_header.bytes() + b"".join(private_header) + payload
Implementation Considerations
Security Considerations
Message Privacy:
- The multi-layered encryption ensures that intermediate nodes cannot determine the message origin or final destination
- Each encapsulation layer uses unique ephemeral keys to prevent correlation attacks
- The reconstructable header mechanism prevents leakage of the encryption sequence
Proof Verification:
- All Proof of Quota (PoQ) proofs must be verified to ensure message authenticity
- Proof of Selection (PoS) proofs prevent double-spending of emission tokens
- Key nullifiers must be checked to prevent key reuse attacks
Key Management:
- Ephemeral keys should be generated using cryptographically secure random sources
- Private keys must never be logged or persisted beyond their required lifetime
- Shared keys derived via Diffie-Hellman must use secure elliptic curve operations
Performance Optimization
Cryptographic Operations:
- BLAKE2b hash operations are efficient but should still be batched when possible
- XOR-based encryption/decryption is computationally inexpensive
- Signature generation and verification are the most expensive operations and should be minimized
Memory Management:
- Private headers with $\beta_{max}$ blending headers can consume significant memory
- Implementations should reuse buffers for encryption/decryption operations
- Payload sizes (34 KiB) should be considered when allocating message buffers
Implementation Notes
Byte Order:
- All multi-byte integers use little-endian encoding unless otherwise specified
- The modular_bytes function converts bytes to integers using little-endian format
- Implementations must maintain consistent endianness throughout
Error Handling:
- Invalid proofs should result in immediate message rejection
- Signature verification failures must discard the message
- Implementations should not leak timing information about verification failures
Integration Points:
- Service Declaration Protocol (SDP) integration is required for node public key retrieval
- Proof of Leadership (PoL) integration is needed for leader quota verification
- The Formatting specification provides additional context for message structure
Appendix
Example: Complete Encapsulation and Decapsulation
The following example demonstrates the above mechanism with $\beta_{max}=4,h=3$. The protocol version in the header is omitted for simplicity.
Initialization
Create Empty Message (Example)
Create an empty message: $\mathbf{M} = (\mathbf{H}=0,\mathbf{h}=0,\mathbf{P}=0)$
Randomize Private Header (Example)
Randomize the private header: $\mathbf h_0 = {$
$\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|}$,
$\mathbf b_2 = \text {CSPRBG}( \rho_{2})_{|\mathbf b|}$,
$\mathbf b_3 = \text {CSPRBG}( \rho_{3})_{|\mathbf b|}$,
$\mathbf b_4 = \text {CSPRBG}( \rho_{4})_{|\mathbf b|}$,
$}$.
Fill Last h Blend Headers (Example)
Fill the last $h$ blend headers with reconstructable payloads: $\mathbf h_0 = {$
$\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|}$,
$\mathbf b_2 = { r_{l_3,1}, r_{l_3,2} ,r_{l_3,3}, r_{l_3,4} }$,
$\mathbf b_3 = { r_{l_2,1}, r_{l_2,2} ,r_{l_2,3}, r_{l_2,4} }$,
$\mathbf b_4 = { r_{l_1,1}, r_{l_1,2} ,r_{l_1,3}, r_{l_1,4} }$,
$}$.
Encrypt Last h Blend Headers (Example)
Encrypt the last $h$ blend headers in a reconstructable manner: $\mathbf h_{E_0} = {$
$\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|}$,
$\mathbf b_2 = E_{H_\mathbf{b}(\kappa^{n,{l_3}}{3})}E{H_\mathbf{b}(\kappa^{n,{l_2}}{2})}E{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}({ r{l_3,1}, r_{l_3,2} ,r_{l_3,3}, r_{l_3,4} })$,
$\mathbf b_3 = E_{H_\mathbf{b}(\kappa^{n,{l_2}}{2})}E{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}({ r{l_2,1}, r_{l_2,2} ,r_{l_2,3}, r_{l_2,4} })$,
$\mathbf b_4 = E_{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}({ r{l_1,1}, r_{l_1,2} ,r_{l_1,3}, r_{l_1,4} })$,
$}$.
Encapsulation
Iteration i=1:
- Generate a new ephemeral key pair: $(K^n_0, k^n_0) \notin \mathbf K^n$.
- Calculate the signature of the header and the payload: $\sigma_{K^{n}0}(\mathbf{h}{E_0}| \mathbf{P}_0)$.
- Using shared key $\kappa^{n,l_1}{1}$ encrypt the payload: $\mathbf P_1 = E{H_\mathbf{P}(\kappa^{n,l_1}_{1})}(\mathbf P_0)$.
- Shift blending headers by one down: $\mathbf h_1 = {$ $\mathbf b_1 = \empty$, ... $}$.
- Fill the first blending header with signature, proof, and flag.
- Using shared key $\kappa^{n,l_1}{1}$ encrypt the private header: $\mathbf{h}{E_{1}} = E_{H_{\mathbf b}(\kappa^{n,l_1}_1)}(\mathbf{h}_1)$.
Iteration i=2:
Continue with similar steps using $\kappa^{n,l_2}_{2}$.
Iteration i=3:
Continue with similar steps using $\kappa^{n,l_3}_{3}$.
The above calculations give us the final message $\mathbf {M = (H,h,P)}$ where:
$\mathbf H = (K^{n}_3,~ \pi^{K^{n}_3}Q,~ \sigma{K^{n}3}(\mathbf{h}{E_3}|\mathbf{P}_3))$,
$\mathbf{h} = \mathbf{h}_{E_3}$ with fully encrypted blending headers,
$\mathbf{P} = \mathbf P_3= E_{H_{\mathbf P_0}(\kappa^{n,l_3}3)}E{H_{\mathbf P}(\kappa^{n,l_2}2)}E{H_{\mathbf P}(\kappa^{n,l_1}_1)}(\mathbf{P}_0)$.
Decapsulation
This section demonstrates decapsulation of the above message. The node doing the processing is the rightful recipient of the message and the public header is verified to be correct.
Node l=l_3:
- Calculate shared secret: $\kappa^{n,l_3}{3}=K^n{3} \cdot p^{l_3}$
- Decrypt the header: $\mathbf h_{l_3} = D_{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf{h})$
- Verify the header (proof of selection, key novelty, proof of quota)
- Reconstruct the public header
- Decrypt the payload: $\mathbf{P}{l_3} = D{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf P)$
- Reconstruct the blend header with pseudo-random values
- Encrypt the reconstructed blend header
- Shift blending headers by one upward
- Reconstruct the private header
- Verify the signature
- Message is decapsulated
- Follow the processing logic
Node l=l_2 and l=l_1:
Similar decapsulation steps are performed by subsequent nodes in the blending path.
References
Normative
- RFC 2119 - Key words for use in LIPs to Indicate Requirement Levels
- Key Types and Generation Specification - Defines the cryptographic key types used in the Blend protocol
- Proof of Quota Specification - ZK-SNARK proof limiting message encapsulations
- Proof of Selection Specification - Verifiable proof of node selection for message routing
- Service Declaration Protocol - Protocol for global node registration and discovery
- Proof of Leadership Specification - Cryptarchia consensus mechanism for leader selection
- BLAKE2b - Cryptographic hash function
- RFC 8032 - Edwards-Curve Digital Signature Algorithm (EdDSA)
- RFC 7748 - Elliptic Curves for Security (X25519)
Informative
- Message Encapsulation Mechanism - Original Message Encapsulation documentation
- Blend Protocol - Context for message structure and formatting conventions
- Blend Protocol Formatting - Message formatting conventions
Copyright
Copyright and related rights waived via CC0.
NOMOS-MESSAGE-FORMATTING
| Field | Value |
|---|---|
| Name | Nomos Message Formatting Specification |
| Slug | 89 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski |
| Contributors | Youngjoon Lee [email protected], Alexander Mozeika [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document specifies the Message Formatting for the Blend Protocol. The Message contains a header and a payload, where the header informs the protocol about the version and the payload type. The Message contains either a drop or a non-drop payload, with fixed-length payloads to prevent adversaries from distinguishing message types based on length. This specification reuses notation from the Notation document and integrates with the Message Encapsulation Mechanism.
Keywords: Blend, message formatting, header, payload, drop, non-drop
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Document Structure
This specification is organized into two distinct parts to serve different audiences and use cases:
Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations MUST follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.
Implementation Details provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.
Protocol Specification
Construction
Message
The Message is a structure that contains a public_header, private_header and a payload.
class Message:
public_header: PublicHeader
private_header: PrivateHeader
payload: bytes
Public Header
The public_header must be generated as the outcome of the Message Encapsulation Mechanism.
The public_header is defined as follows:
class PublicHeader:
version: byte
public_key: PublicKey
proof_of_quota: ProofOfQuota
signature: Signature
Fields:
- version=0x01 is version of the protocol.
- public_key is $K^{n}{i}$, a public key from the set $\mathbf{K}^{n}{h}$ as defined in the Message Encapsulation spec.
- proof_of_quota is $\pi^{K^{n}{i}}{Q}$, a corresponding proof of quota for the key $K^{n}{i}$ from the $\mathbf{K}^{n}{h}$; it also contains the key nullifier.
- signature is $\sigma_{K^{n}{i}}(\mathbf{h|P}{i})$, a signature of the concatenation of the $i$-th encapsulation of the payload $\mathbf{P}$ and the private header $\mathbf{h}$, that can be verified by the public key $K^{n}_{i}$.
Private Header
The private_header must be generated as the outcome of the Message Encapsulation Mechanism.
The private header contains a set of encrypted BlendingHeader entries $\mathbf{h} = (\mathbf{b}{1},...,\mathbf{b}{h_{max}})$.
private_header: list[BlendingHeader]
The size of the set is limited to $\beta_{max}=3$ BlendingHeader entries, as defined in the Global Parameters.
Blending Header:
The BlendingHeader ($\mathbf{b}_{l}$) is defined as follows:
class BlendingHeader:
public_key: PublicKey
proof_of_quota: ProofOfQuota
signature: Signature
proof_of_selection: ProofOfSelection
is_last: byte
Fields:
- public_key is $K^{n}{l}$, a public key from the set $\mathbf{K}^{n}{h}$.
- proof_of_quota is $\pi^{K^{n}{l}}{Q}$, a corresponding proof of quota for the key $K^{n}{l}$ from the $\mathbf{K}^{n}{h}$; it also contains the key nullifier.
- signature is $\sigma_{K^{n}{l}}(\mathbf{h|P}{l})$, a signature of the concatenation of $l$-th encapsulation of the payload $\mathbf{P}$ and the private header $\mathbf{h}$, that can be verified by public key $K^{n}_{l}$.
- proof_of_selection is $\pi^{K^{n}{l+1},m{l+1}}{S}$, a proof of selection of the node index $m{l+1}$ assuming valid proof of quota $\pi^{K^{n}{l}}{Q}$.
- is_last is $\Omega$, a flag that indicates that this is the last encapsulation.
Payload
The Payload is formatted according to the Payload Formatting Specification. The formatted Payload is generated as the outcome of the Message Encapsulation Mechanism.
Maximum Payload Length
The MAX_PAYLOAD_LENGTH parameter defines the maximum length of the payload,
which for version 1 of the Blend Protocol is fixed as MAX_PAYLOAD_LENGTH=34003.
That is, 34kB for the payload body (MAX_BODY_LENGTH)
and 3 bytes for the payload header.
More information about payload formatting can be found in
Payload Formatting Specification.
MAX_PAYLOAD_LENGTH = 34003
MAX_BODY_LENGTH = 34000
PAYLOAD_HEADER_SIZE = 3
Implementation Considerations
Message Size Uniformity
Fixed-Length Design:
- All messages have a fixed total length to prevent traffic analysis attacks
- The payload length is constant regardless of actual content size
- Padding is used to fill unused space in the payload body
- This design prevents adversaries from distinguishing message types based on size
Protocol Version
Version Field:
- The current protocol version is 0x01
- The version field is a single byte in the public header
- Future protocol versions may introduce breaking changes to the message format
- Implementations must validate the version field before processing messages
Header Generation
Dependency on Encapsulation:
- Both public_header and private_header are generated by the Message Encapsulation Mechanism.
- Implementations must not manually construct headers
- The encapsulation mechanism ensures proper cryptographic properties
- Headers include signatures, proofs, and encryption as specified in the Message Encapsulation spec.
Blending Header Limit
Maximum Encapsulation Layers:
- The protocol limits the private header to $\beta_{max}=3$ BlendingHeader entries
- This limit is defined in the Global Parameters
- Each BlendingHeader represents one layer of Message encapsulation
- The limit balances privacy (more layers) with performance and overhead
Integration Points
Required Specifications:
- Message Encapsulation Mechanism: Generates the public and private headers
- Payload Formatting Specification: Defines how to format the payload content
- Notation: Provides mathematical and cryptographic notation used throughout
- Global Parameters: Defines protocol-wide constants like $\beta_{max}$
Security Considerations
Traffic Analysis Protection:
- Fixed message lengths prevent size-based traffic analysis
- All messages appear identical in size on the network
- Cover traffic can be indistinguishable from real data messages
Cryptographic Integrity:
- Signatures in both public and private headers ensure message authenticity
- Proof of Quota prevents spam and resource exhaustion
- Proof of Selection ensures correct node routing
Message Validation:
- Implementations must verify all signatures before processing
- Proof of Quota must be validated to prevent quota violations
- The is_last flag must be checked to determine final message destination
References
Normative
- Message Encapsulation Mechanism - Cryptographic operations for building and processing messages
- Payload Formatting Specification - Defines payload structure and formatting rules
- Blend Protocol - Protocol-wide constants and configuration values
Informative
- Message Formatting Specification - Original Message Formatting documentation
- Blend Protocol Formatting - High-level overview of message formatting in Blend Protocol
Copyright
Copyright and related rights waived via CC0.
NOMOS-P2P-NETWORK
| Field | Value |
|---|---|
| Name | Nomos P2P Network Specification |
| Slug | 135 |
| Status | draft |
| Category | networking |
| Editor | Daniel Sanchez-Quiros [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-25 —
a3a5b91— Created nomos/raw/p2p-network.md file (#169)
Abstract
This specification defines the peer-to-peer (P2P) network layer for Nomos blockchain nodes. The network serves as the comprehensive communication infrastructure enabling transaction dissemination through mempool and block propagation. The specification leverages established libp2p protocols to ensure robust, scalable performance with low bandwidth requirements and minimal latency while maintaining accessibility for diverse hardware configurations and network environments.
Motivation
The Nomos blockchain requires a reliable, scalable P2P network that can:
- Support diverse hardware: From laptops to dedicated servers across various operating systems and geographic locations
- Enable inclusive participation: Allow non-technical users to operate nodes with minimal configuration
- Maintain connectivity: Ensure nodes remain reachable even with limited connectivity or behind NAT/routers
- Scale efficiently: Support large-scale networks (+10k nodes) with eventual consistency
- Provide low-latency communication: Enable efficient transaction and block propagation
Specification
Network Architecture Overview
The Nomos P2P network addresses three critical challenges:
- Peer Connectivity: Mechanisms for peers to join and connect to the network
- Peer Discovery: Enabling peers to locate and identify network participants
- Message Transmission: Facilitating efficient message exchange across the network
Transport Protocol
QUIC Protocol Transport
The Nomos network employs QUIC protocol as the primary transport protocol, leveraging the libp2p protocol implementation.
Rationale for QUIC protocol:
- Rapid connection establishment
- Enhanced NAT traversal capabilities (UDP-based)
- Built-in multiplexing simplifies configuration
- Production-tested reliability
Peer Discovery
Kademlia DHT
The network utilizes libp2p's Kademlia Distributed Hash Table (DHT) for peer discovery.
Protocol Identifiers:
- Mainnet:
/nomos/kad/1.0.0 - Testnet:
/nomos-testnet/kad/1.0.0
Features:
- Proximity-based peer discovery heuristics
- Distributed peer routing table
- Resilient to network partitions
- Automatic peer replacement
Identify Protocol
Complements Kademlia by enabling peer information exchange.
Protocol Identifiers:
- Mainnet:
/nomos/identify/1.0.0 - Testnet:
/nomos-testnet/identify/1.0.0
Capabilities:
- Protocol support advertisement
- Peer capability negotiation
- Network interoperability enhancement
Future Considerations
The current Kademlia implementation is acknowledged as interim. Future improvements target:
- Lightweight design without full DHT overhead
- Highly-scalable eventual consistency
- Support for 10k+ nodes with minimal resource usage
NAT Traversal
The network implements comprehensive NAT traversal solutions to ensure connectivity across diverse network configurations.
Objectives:
- Configuration-free peer connections
- Support for users with varying technical expertise
- Enable nodes on standard consumer hardware
Implementation:
- Tailored solutions based on user network configuration
- Automatic NAT type detection and adaptation
- Fallback mechanisms for challenging network environments
Note: Detailed NAT traversal specifications are maintained in a separate document.
Message Dissemination
Gossipsub Protocol
Nomos employs gossipsub for reliable message propagation across the network.
Integration:
- Seamless integration with Kademlia peer discovery
- Automatic peer list updates
- Efficient message routing and delivery
Topic Configuration
Mempool Dissemination:
- Mainnet:
/nomos/mempool/0.1.0 - Testnet:
/nomos-testnet/mempool/0.1.0
Block Propagation:
- Mainnet:
/nomos/cryptarchia/0.1.0 - Testnet:
/nomos-testnet/cryptarchia/0.1.0
Network Parameters
Peering Degree:
- Minimum recommended: 8 peers
- Rationale: Ensures redundancy and efficient propagation
- Configurable: Nodes may adjust based on resources and requirements
Bootstrapping
Initial Network Entry
New nodes connect to the network through designated bootstrap nodes.
Process:
- Connect to known bootstrap nodes
- Obtain initial peer list through Kademlia
- Establish gossipsub connections
- Begin participating in network protocols
Bootstrap Node Requirements:
- High availability and reliability
- Geographic distribution
- Version compatibility maintenance
Message Encoding
All network messages follow the Nomos Wire Format specification for consistent encoding and decoding across implementations.
Key Properties:
- Deterministic serialization
- Efficient binary encoding
- Forward/backward compatibility support
- Cross-platform consistency
Note: Detailed wire format specifications are maintained in a separate document.
Implementation Requirements
Mandatory Protocols
All Nomos nodes MUST implement:
- Kademlia DHT for peer discovery
- Identify protocol for peer information exchange
- Gossipsub for message dissemination
Optional Enhancements
Nodes MAY implement:
- Advanced NAT traversal techniques
- Custom peering strategies
- Enhanced message routing optimizations
Network Versioning
Protocol versions follow semantic versioning:
- Major version: Breaking protocol changes
- Minor version: Backward-compatible enhancements
- Patch version: Bug fixes and optimizations
Configuration Parameters
Implementation Note
Current Status: The Nomos P2P network implementation uses hardcoded libp2p protocol parameters for optimal performance and reliability. While the node configuration file (config.yaml) contains network-related settings, the core libp2p protocol parameters (Kademlia DHT, Identify, and Gossipsub) are embedded in the source code.
Node Configuration
The following network parameters are configurable via config.yaml:
Network Backend Settings
network:
backend:
host: 0.0.0.0
port: 3000
node_key: <node_private_key>
initial_peers: []
Protocol-Specific Topics
Mempool Dissemination:
- Mainnet:
/nomos/mempool/0.1.0 - Testnet:
/nomos-testnet/mempool/0.1.0
Block Propagation:
- Mainnet:
/nomos/cryptarchia/0.1.0 - Testnet:
/nomos-testnet/cryptarchia/0.1.0
Hardcoded Protocol Parameters
The following libp2p protocol parameters are currently hardcoded in the implementation:
Peer Discovery Parameters
- Protocol identifiers for Kademlia DHT and Identify protocols
- DHT routing table configuration and query timeouts
- Peer discovery intervals and connection management
Message Dissemination Parameters
- Gossipsub mesh parameters (peer degree, heartbeat intervals)
- Message validation and caching settings
- Topic subscription and fanout management
Rationale for Hardcoded Parameters
- Network Stability: Prevents misconfigurations that could fragment the network
- Performance Optimization: Parameters are tuned for the target network size and latency requirements
- Security: Reduces attack surface by limiting configurable network parameters
- Simplicity: Eliminates need for operators to understand complex P2P tuning
Security Considerations
Network-Level Security
- Peer Authentication: Utilize libp2p's built-in peer identity verification
- Message Validation: Implement application-layer message validation
- Rate Limiting: Protect against spam and DoS attacks
- Blacklisting: Mechanism for excluding malicious peers
Privacy Considerations
- Traffic Analysis: Gossipsub provides some resistance to traffic analysis
- Metadata Leakage: Minimize identifiable information in protocol messages
- Connection Patterns: Randomize connection timing and patterns
Denial of Service Protection
- Resource Limits: Impose limits on connections and message rates
- Peer Scoring: Implement reputation-based peer management
- Circuit Breakers: Automatic protection against resource exhaustion
Node Configuration Example
Nomos Node Configuration is an example node configuration
Performance Characteristics
Scalability
- Target Network Size: 10,000+ nodes
- Message Latency: Sub-second for critical messages
- Bandwidth Efficiency: Optimized for limited bandwidth environments
Resource Requirements
- Memory Usage: Minimal DHT routing table overhead
- CPU Usage: Efficient cryptographic operations
- Network Bandwidth: Adaptive based on node role and capacity
References
Original working document, from Nomos Notion: P2P Network Specification.
- libp2p Specifications
- QUIC Protocol Specification
- Kademlia DHT
- Gossipsub Protocol
- Identify Protocol
- Nomos Implementation - Reference implementation and source code
- Nomos Node Configuration - Example node configuration
Copyright
Copyright and related rights waived via CC0.
NOMOS-PAYLOAD-FORMATTING
| Field | Value |
|---|---|
| Name | Nomos Payload Formatting Specification |
| Slug | 97 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski [email protected] |
| Contributors | Youngjoon Lee [email protected], Alexander Mozeika [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Payload formatting for the Blend Protocol. The Payload has a fixed length to prevent traffic analysis attacks, with shorter messages padded using random data.
Keywords: Blend, payload formatting, padding, fixed length, traffic analysis
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Protocol Specification
Construction
Payload
The Payload is a structure that contains a Header and a body.
class Payload:
header: Header
body: bytes
Header
The Header is a structure that contains a body_type and a body_length.
class Header:
body_type: byte
body_length: uint16
Fields:
body_type: A single byte indicating the type of message in the bodybody_length: A uint16 (encoded as little-endian) indicating the actual length of the raw message
Type
Messages are classified into two types:
- Cover message: Traffic used to obscure network patterns and enhance privacy
- Data message: Traffic containing actual protocol data (e.g., block proposals)
The body_type field indicates the message classification:
body_type=0x00: The body contains a cover messagebody_type=0x01: The body contains a data message
Implementations MUST discard messages with any other body_type value,
as this indicates the message was not decapsulated correctly.
Length
The body_length field is a uint16 (encoded as little-endian),
with a theoretical maximum of 65535 bytes.
The body_length MUST be set to the actual length of the raw message in bytes.
Body
The MAX_BODY_LENGTH parameter defines the maximum length of the body.
The maximal length of a raw data message is 33129 bytes (Block Proposal),
so MAX_BODY_LENGTH=33129.
The body length is fixed to MAX_BODY_LENGTH bytes.
If the length of the raw message is shorter than MAX_BODY_LENGTH,
it MUST be padded with random data.
MAX_BODY_LENGTH = 33129
Note: The MAX_BODY_LENGTH (33129 bytes) defined here differs from
MAX_PAYLOAD_LENGTH (34003 bytes) in the Message Formatting specification.
The Message Formatting specification includes additional Message headers
beyond the Payload body.
Implementation Considerations
Fixed-Length Design
Payload Size Uniformity:
- All payloads have a fixed total length to prevent traffic analysis attacks
- The body length is constant at MAX_BODY_LENGTH=33129 bytes regardless of actual content size
- Shorter messages must be padded with random data to fill unused space
- This design prevents adversaries from distinguishing message types based on size
Padding Requirements:
- If len(raw_message) < MAX_BODY_LENGTH, padding is required
- Padding must consist of random data (not zeros or predictable patterns)
- The body_length field indicates where the actual message ends and padding begins
- Implementations must use cryptographically secure random number generation for padding
Header Structure
Total Header Size:
- body_type: 1 byte
- body_length: 2 bytes (uint16, little-endian)
- Total header size: 3 bytes
Endianness:
- The body_length field uses little-endian encoding
- Implementations must correctly encode/decode uint16 values in little-endian format
- This ensures consistent interpretation across different platforms and architectures
Message Type Validation
Valid Types:
- 0x00: Cover message (dummy traffic for privacy)
- 0x01: Data message (actual protocol data or block proposals)
Invalid Type Handling:
- Any body_type value other than 0x00 or 0x01 indicates decapsulation failure
- Messages with invalid types must be discarded immediately
- Implementations should not attempt to process or forward invalid messages
- Invalid types may indicate cryptographic errors or malicious manipulation
Body Length Constraints
Length Validation:
- body_length must be ≤ MAX_BODY_LENGTH (33129 bytes)
- body_length indicates the actual length of the raw message before padding
- Implementations must verify body_length is within valid range before processing
- The theoretical maximum is 65535 bytes (uint16 limit), but the protocol constrains it to 33129
Message Extraction:
- To extract the raw message: raw_message = body[0:body_length]
- Padding data beyond body_length should be discarded
- The padding serves only to maintain fixed payload size
Maximum Message Size
Block Proposal Size:
- The current MAX_BODY_LENGTH=33129 is based on the maximum size of a Block Proposal
- This value may be adjusted in future protocol versions
- Implementations should use the constant rather than hardcoding the value
- Total payload size = 3 bytes (header) + 33129 bytes (body) = 33132 bytes
Total Payload Calculation:
HEADER_SIZE = 3 # 1 byte type + 2 bytes length
MAX_BODY_LENGTH = 33129
MAX_PAYLOAD_LENGTH = HEADER_SIZE + MAX_BODY_LENGTH # 33132 bytes
Cover Traffic
Cover Messages (body_type=0x00):
- Cover messages provide traffic obfuscation to enhance privacy
- They appear indistinguishable from data messages at the network level
- The body of a cover message should contain random data
- Cover messages are discarded after decapsulation
Indistinguishability:
- Cover and data messages have identical size due to fixed-length design
- Both types follow the same formatting and encryption procedures
- Network observers cannot distinguish cover traffic from real data
Integration Points
Required Specifications:
- Message Formatting Specification: Defines the overall message structure that contains the payload
- Message Encapsulation Mechanism: Handles encryption and encapsulation of the formatted payload
- Blend Protocol: Provides high-level overview of payload formatting
Relationship to Message Formatting:
- The Payload Formatting specification defines the internal structure of the payload
- The Message Formatting specification defines how the payload is included in the complete message
- The MAX_PAYLOAD_LENGTH in Message Formatting (34003 bytes) accounts for this payload structure
Security Considerations
Cryptographic Randomness:
- Padding must use cryptographically secure random number generation
- Predictable padding could leak information about message types or content
- Never use zeros, repeated patterns, or pseudo-random generators for padding
Length Information Leakage:
- The fixed-length design prevents length-based traffic analysis
- The body_length field is encrypted as part of the payload
- Only after successful decapsulation can the actual message length be determined
Type Field Security:
- The body_type field is encrypted within the payload
- Invalid types indicate potential security issues (failed decryption, tampering)
- Implementations must discard invalid messages without further processing
Message Validation Sequence:
- Decrypt and extract the payload
- Parse the 3-byte header
- Validate body_type is 0x00 or 0x01
- Validate body_length ≤ MAX_BODY_LENGTH
- Extract raw_message using body_length
- Process or discard based on body_type
References
Normative
- Message Formatting Specification - Defines the overall message structure containing the Payload
- Message Encapsulation Mechanism - Cryptographic operations for encrypting and encapsulating the Payload
- Blend Protocol - Protocol-wide constants and configuration values
Informative
- Payload Formatting Specification - Original Payload Formatting documentation
Copyright
Copyright and related rights waived via CC0.
NOMOS-PROOF-OF-QUOTA
| Field | Value |
|---|---|
| Name | Nomos Proof of Quota Specification |
| Slug | 88 |
| Status | raw |
| Category | Standards Track |
| Editor | Mehmet Gonen [email protected] |
| Contributors | Marcin Pawlowski [email protected], Thomas Lavaur [email protected], Youngjoon Lee [email protected], David Rusu [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document defines an implementation-friendly specification of the Proof of Quota (PoQ), which ensures that there is a limited number of message encapsulations that a node can perform, thereby constraining the number of messages a node can introduce to the Blend network used in Nomos (see NOMOS-BLEND-PROTOCOL). The mechanism regulating these messages is similar to rate-limiting nullifiers.
Keywords: cryptography, zero-knowledge, Blend, quota, rate-limiting, PoQ, nullifier
Document Structure
This specification is organized into two distinct parts to serve different audiences and use cases:
Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations must follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.
Implementation Considerations provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.
Protocol Specification
This section defines the normative cryptographic protocol requirements for the Proof of Quota.
Construction
The Proof of Quota (PoQ) can be satisfied by one of two proof types, depending on the node's role in the network:
-
Proof of Core Quota (PoQ_C): Ensures that the core node is declared and hasn't already produced more keys than the core quota Q_C.
-
Proof of Leadership Quota (PoQ_L): Ensures that the leader node would win the proof of stake for current Cryptarchia epoch (see Cryptarchia Consensus) and hasn't already produced more keys than the leadership quota Q_L. This doesn't guarantee that the node is indeed winning because the PoQ doesn't check if the Proof of Leadership note (representing staked value) is unspent, enabling generation of the proof ahead of time preventing extreme delays.
Validity: The final proof PoQ is valid if either PoQ_C or PoQ_L holds.
Zero-Knowledge Proof Statement
Public Values
A proof attesting that for the following public values derived from blockchain parameters:
Type Definition:
zkhash represents a 256-bit hash value used in zero-knowledge circuits,
typically a Poseidon hash output compatible with the BN256 scalar field.
class ProofOfQuotaPublic:
session: int # Session number (uint64)
core_quota: int # Allowed messages per session for core nodes (20 bits)
leader_quota: int # Allowed messages per session for potential leaders (20 bits)
core_root: zkhash # Merkle root of zk_id of the core nodes
K_part_one: int # First part of the signature public key (16 bytes)
K_part_two: int # Second part of the signature public key (16 bytes)
pol_epoch_nonce: int # PoL Epoch nonce
pol_t0: int # PoL constant t0
pol_t1: int # PoL constant t1
pol_ledger_aged: zkhash # Merkle root of the PoL eligible notes
# Outputs:
key_nullifier: zkhash # Derived from session, private index and private sk
Field Descriptions:
session: Unique session identifier for temporal partitioningcore_quota: Maximum number of message encapsulations allowed per session for core nodes (20-bit value)leader_quota: Maximum number of message encapsulations allowed per session for potential leaders (20-bit value)core_root: Root of Merkle tree containing zk_id values of all declared core nodesK_part_one,K_part_two: Split representation of one-time signature public key (32 bytes total)pol_epoch_nonce: Proof of Leadership epoch nonce for lotterypol_t0,pol_t1: Proof of Leadership threshold constantspol_ledger_aged: Root of Merkle tree containing eligible Proof of Leadership noteskey_nullifier: Output nullifier preventing key reuse within a session
Witness
The prover knows a witness:
class ProofOfQuotaWitness:
index: int # Index of the generated key (20 bits)
selector: bool # Indicates if it's a leader (=1) or core node (=0)
# This part is filled randomly by potential leaders
core_sk: zkhash # sk corresponding to the zk_id of the core node
core_path: list[zkhash] # Merkle path proving zk_id membership (len = 20)
core_path_selectors: list[bool] # Indicates how to read the core_path
# This part is filled randomly by core nodes
pol_sl: int # PoL slot
pol_sk_starting_slot: int # PoL starting slot of the slot secrets
pol_note_value: int # PoL note value
pol_note_tx_hash: zkhash # PoL note transaction
pol_note_output_number: int # PoL note transaction output number
pol_noteid_path: list[zkhash] # PoL Merkle path proving noteID membership (len = 32)
pol_noteid_path_selectors: list[bool] # Indicates how to read the note_path
pol_slot_secret: int # PoL slot secret corresponding to sl
pol_slot_secret_path: list[zkhash] # PoL slot secret Merkle path (len = 25)
Witness Field Descriptions:
index: The index of the generated key. Limiting this index limits the maximum number of keys generated (20 bits enables up to 2^20 = 1,048,576 messages per node per session)selector: Boolean flag indicating node type (1 for leader, 0 for core node)core_sk: Secret key corresponding to the core node's zk_idcore_path: Merkle authentication path for core node membershipcore_path_selectors: Navigation bits for Merkle path (left/right)pol_*: Proof of Leadership witness fields (filled randomly by core nodes)
Note: All inputs and outputs of zero-knowledge proofs are scalar field elements.
Constraints
The following constraints MUST hold for a valid proof:
Step 1: Index Selection and Quota Limitation
The prover selects an index for the chosen key. This index MUST be lower than the allowed quota and not already used. This index is used to derive the key nullifier in Step 4: Key Nullifier Derivation.
Purpose: Limiting the possible values of this index limits the possible nullifiers created, which produces the desired effect of limiting the generation of keys to a certain quota.
Specification: index is 20 bits,
enabling up to 2^20 messages per node per session.
Step 2: Core Node Verification
If the prover indicated that the node is a core node for the proof
(selector is 0), the proof checks that:
-
Core Node Registration: The core node is registered in the set N = SDP(session), where SDP is the Service Declaration Protocol (see Service Declaration Protocol). This is proven by demonstrating knowledge of a
core_skthat corresponds to a declaredzk_id, which is a valid SDP registry for the current session.- The
zk_idvalues are stored in a Merkle tree with a fixed depth of 20 - The root is provided as a public input
- To build the Merkle tree,
zk_idvalues are ordered from smallest to biggest (when seen as natural numbers between 0 and p) - Remaining empty leaves are represented by 0 after the sorting (appended at the end of the vector)
- This structure supports up to 1M validators
- The
-
Index Validity: The index MUST satisfy:
index < core_quota
Step 3: Leader Node Verification
If the prover indicated that the node is a potential leader node for the proof
(selector is 1), the proof checks that:
-
Leadership Lottery: The leader node possesses a note that would win a slot in the consensus lottery. Unlike leadership conditions, the proof of quota doesn't verify that the note is unspent. This enables potential provers to generate the PoQ well in advance. All other lottery constraints are the same as in Circuit Constraints.
-
Index Validity: The index MUST satisfy:
index < leader_quota
Step 4: Key Nullifier Derivation
The prover derives a key_nullifier maintained by blend nodes
during the session for message deduplication purposes:
selection_randomness = zkhash(b"SELECTION_RANDOMNESS_V1", sk, index, session)
key_nullifier = zkhash(b"KEY_NULLIFIER_V1", selection_randomness)
Where sk is:
- The
core_skas defined in the Mantle specification if the node is a core node - The secret key of the PoL note if it's a leader node derived from inputs
Rationale: Two hashes are used because the selection randomness is used in the Proof of Selection to prove the ownership of a valid PoQ.
Step 5: One-Time Signature Key Attachment
The prover attaches a one-time signature key used in the blend protocol.
This public key is split into two 16-byte parts:
K_part_one and K_part_two.
Encoding: When written in little-endian byte order,
the complete public key equals the concatenation K_part_one || K_part_two.
Circuit Implementation
# Verify selector is a boolean
# selector = 1 if it's a potential leader and 0 if it's a core node
selector * (1 - selector) == 0 # Check that selector is indeed a bit
# Verify index is lower than quota
# Equivalent to: index < leader_quota if selector == 1
# or index < core_quota if selector == 0
index < selector * (leader_quota - core_quota) + core_quota
# Check if it's a registered core node
zk_id = zkhash(b"NOMOS_KDF", core_sk)
is_registered = merkle_verify(core_root, core_path, core_path_selectors, zk_id)
# Check if it's a potential leader
is_leader = would_win_leadership(
pol_epoch_nonce,
pol_t0,
pol_t1,
pol_ledger_aged,
pol_sl,
pol_sk_starting_slot,
pol_sk_secrets_root,
pol_note_value,
pol_note_tx_hash,
pol_note_output_number,
pol_noteid_path,
pol_noteid_path_selectors,
pol_slot_secret,
pol_slot_secret_path
)
# Verify that it's a core node or a leader
assert(selector * (is_leader - is_registered) + is_registered == 1)
# Get leader note secret key
pol_sk_secrets_root = get_merkle_root(pol_sk_starting_slot, sl, pol_slot_secret_path)
pol_note_sk = zkhash(b"NOMOS_POL_SK_V1", pol_sk_starting_slot, pol_sk_secrets_root)
# Derive nullifier
selection_randomness = zkhash(
b"SELECTION_RANDOMNESS_V1",
selector * (pol_note_sk - core_sk) + core_sk,
index,
session
)
key_nullifier = zkhash(b"KEY_NULLIFIER_V1", selection_randomness)
Proof Compression
The proof confirming that the PoQ is correct MUST be compressed to a size of 128 bytes.
Uncompressed Format: The UncompressedProof comprises 2 G1 and 1 G2 BN256 elements:
class UncompressedProof:
pi_a: G1 # BN256 element
pi_b: G2 # BN256 element
pi_c: G1 # BN256 element
Compression Requirements:
- Compressed size: 128 bytes
- Curve: BN256 (also known as BN254 or alt_bn128)
- Compression MUST preserve proof validity
Proof Serialization
The ProofOfQuota structure contains key_nullifier and the compressed proof
transformed into bytes.
class ProofOfQuota:
key_nullifier: zkhash # 32 bytes
proof: bytes # 128 bytes
Serialization Format:
- Transform
key_nullifierinto 32 bytes - Compress proof to 128 bytes
- Concatenate:
key_nullifier || proof - Total size: 160 bytes
Deserialization:
Interpret the 160-byte sequence as:
- Bytes 0-31:
key_nullifier - Bytes 32-159:
proof
Security Considerations
Quota Enforcement
- Implementations MUST track
key_nullifiervalues during each session - Duplicate
key_nullifiervalues MUST be rejected - Session transitions MUST clear the nullifier set
Proof Verification
- All Merkle path verifications MUST be performed
- The
selectorbit MUST be verified as boolean (0 or 1) - Index bounds MUST be strictly enforced
- Implementations MUST reject proofs where neither core nor leader conditions hold
Cryptographic Assumptions
- Relies on soundness of the underlying zk-SNARK system
- Assumes collision resistance of
zkhashfunction - Assumes computational Diffie-Hellman assumption on BN256 curve
Note Unspent Condition
- Critical: The PoQ does NOT verify that Proof of Leadership notes are unspent
- This allows pre-generation of proofs to avoid delays
- Implementations SHOULD implement additional checks for actual leadership
Implementation Considerations
This section provides guidance for implementing the Proof of Quota protocol.
Proof Generation
Performance Characteristics:
Implementations SHOULD consider:
- Proof generation is computationally intensive
- Pre-generation is recommended for leader nodes
- Witness preparation involves Merkle path computation
Proof Verification Implementation
Verification Steps:
- Deserialize proof into
key_nullifierandproofcomponents - Verify proof size (160 bytes total)
- Check
key_nullifieragainst session nullifier set - Verify zk-SNARK proof with public inputs
- Add
key_nullifierto session set if valid
Merkle Tree Construction
Core Nodes Merkle Tree
Specification:
- Depth: 20 levels
- Leaf values:
zk_idof declared core nodes - Ordering: Ascending numerical order (as natural numbers 0 to p)
- Empty leaves: Represented by 0, appended after sorted values
- Capacity: 2^20 = 1,048,576 validators
Construction Algorithm:
def build_core_tree(zk_ids: list[int]) -> MerkleTree:
# Sort zk_ids in ascending order
sorted_ids = sorted(zk_ids)
# Pad to 2^20 with zeros
padded = sorted_ids + [0] * (2**20 - len(sorted_ids))
# Build Merkle tree
return MerkleTree(padded, depth=20)
PoL Ledger Merkle Tree
Specification:
- Depth: 32 levels
- Leaf values: Note IDs of eligible PoL notes
- Purpose: Prove note membership in aged ledger
Session Management
Session Lifecycle:
-
Session Start:
- Initialize empty nullifier set
- Load current session parameters (quotas, roots)
- Prepare session number for proofs
-
During Session:
- Verify incoming proofs
- Track nullifiers in set
- Reject duplicate nullifiers
-
Session End:
- Clear nullifier set
- Archive session data
- Transition to next session
Best Practices
Nullifier Set Management
- Use efficient data structure (hash set or Bloom filter with fallback)
- Implement atomic operations for nullifier insertion
- Consider memory constraints for long sessions
Pre-Generation Strategy
For leader nodes:
- Generate proofs before slot assignment
- Cache proofs for multiple indices
- Monitor note status separately from PoQ
Error Handling
Implementations SHOULD handle:
- Invalid proof format
- Duplicate nullifiers
- Index out of bounds
- Merkle path verification failures
- Invalid selector values
References
Normative
- NOMOS-BLEND-PROTOCOL - Blend Protocol specification for Nomos
- Service Declaration Protocol (SDP) - Protocol for declaring core nodes
- Mantle Specification
- Circuit Constraints (Cryptarchia)
- Proof of Selection
- Rate-Limiting Nullifiers - RLN documentation for rate-limiting mechanisms
Informative
- Proof of Quota Specification - Original Proof of Quota documentation
- BN256 Curve Specification
- zk-SNARKs (Zero-Knowledge Succinct Non-Interactive Arguments of Knowledge)
- Cryptarchia Consensus
- Merkle Trees and Authentication Paths
Copyright
Copyright and related rights waived via CC0.
NOMOSDA-CRYPTOGRAPHIC-PROTOCOL
| Field | Value |
|---|---|
| Name | NomosDA Cryptographic Protocol |
| Slug | 148 |
| Status | raw |
| Category | Standards Track |
| Editor | Mehmet Gonen [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Thomas Lavaur [email protected], Daniel Kashepava [email protected], Marcin Pawlowski [email protected], Daniel Sanchez Quiros [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
0ef87b1— New RFC: CODEX-MANIFEST (#191) - 2026-01-30 —
25ebb3a— Replace nomosda-encoding with da-cryptographic-protocol (#264)
Abstract
This document describes the cryptographic protocol underlying NomosDA, the data availability (DA) layer for the Nomos blockchain. NomosDA ensures that all blob data submitted is made available and verifiable by all network participants, including sampling clients and validators. The protocol uses Reed–Solomon erasure coding for data redundancy and KZG polynomial commitments for cryptographic verification, enabling efficient and scalable data availability sampling.
Keywords: NomosDA, data availability, KZG, polynomial commitment, erasure coding, Reed-Solomon, sampling, BLS12-381
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Blob | A unit of data submitted to NomosDA for availability guarantees. |
| Chunk | A 31-byte field element in the BLS12-381 scalar field. |
| DA Node | A node responsible for storing and serving column data. |
| Encoder | The entity that transforms blob data into encoded form with proofs. |
| Sampling Client | A client (e.g., light node) that verifies availability by sampling columns. |
| KZG Commitment | A polynomial commitment using the Kate-Zaverucha-Goldberg scheme. |
| Reed-Solomon Coding | An erasure coding scheme used for data redundancy. |
| Row Polynomial | A polynomial interpolated from chunks in a single row. |
| Combined Polynomial | A random linear combination of all row polynomials. |
Notations
| Symbol | Description |
|---|---|
| $f_i(x)$ | Polynomial interpolated from the chunks in row $i$. |
| $com_i$ | KZG commitment of the row polynomial $f_i(x)$. |
| $f_C(x)$ | Combined polynomial formed as a random linear combination of all row polynomials. |
| $com_C$ | KZG commitment of the combined polynomial $f_C(x)$. |
| $w$ | Primitive $n$-th root of unity in the finite field. In this protocol, $n = 2k$. |
| $h$ | Random scalar generated using the Fiat–Shamir heuristic from row commitments. |
| $\pi_j$ | KZG evaluation proof for column $j$ of the combined polynomial. |
| $v_j$ | Combined evaluation of column $j$ (i.e., $f_C(w^{j-1})$). |
| $k$ | Number of columns in the original data matrix. |
| $\ell$ | Number of rows in the data matrix. |
Background
To achieve data availability, the blob data is first encoded using Reed–Solomon erasure coding and arranged in a matrix format. Each row of the matrix is interpreted as a polynomial and then committed using a KZG polynomial commitment. The columns of this matrix are then distributed across a set of decentralized DA nodes.
Rather than requiring individual proofs for each chunk, NomosDA uses a random linear combination of all row polynomials to construct a single combined polynomial. This allows for generating one proof per column, which enables efficient and scalable verification without sacrificing soundness. Sampling clients verify availability by selecting random columns and checking that the data and proof they receive are consistent with the committed structure. Because each column intersects all rows, even a small number of sampled columns provides strong confidence that the entire blob is available.
Protocol Stages
The protocol is structured around three key stages:
- Encoding: Transform blob data into a matrix with commitments and proofs.
- Dispersal: Distribute columns to DA nodes for storage.
- Sampling: Verify data availability by sampling random columns.
Design Principles
The reason for expanding the original data row-wise is to ensure data availability by sending a column to each DA node and obtaining a sufficient number of responses from different DA nodes for sampling. Three core commitment types are used, and verification is done via column sampling:
-
Row commitment: Ensures the integrity of the original and RS-encoded data and binds the order of chunks within each row.
-
Combined commitment: Constructed by the verifier using a random linear combination of the row commitments. Used to verify the encoder's single proof per column and ensures that the column data is consistent with the committed row structure. Even if a single chunk is invalid, the combined evaluation will likely fail due to the unpredictability of the random coefficients.
-
Column sampling: Allows sampling clients to verify data availability efficiently by checking a small number of columns. With the combined commitment and a single proof, the sampling client can validate that an entire column is consistent with the committed data.
Protocol Specification
Encoding
In the NomosDA protocol, encoders perform the encoding process by dividing the blob data into chunks. Each chunk represents a 31-byte element in the scalar finite field used for the BLS12-381 elliptic curve. 31 bytes are chosen instead of 32 bytes because some 32-byte elements will exceed the BLS12-381 modulus, making it impossible to recover the data later.
The matrix representation has $k$ columns which include $\ell$ chunks each. The row and column numbers used in the representation are decided based on the size of the block data and the number of DA nodes.

Figure 1: Data matrix structure showing chunks and columns. Each chunk is a 31-byte element, and each column contains $\ell$ chunks.
The encoding process consists of three steps:
- Calculating row commitments.
- Expanding the original data using RS coding.
- Computing the combined row polynomial and the combined column proofs.
Row Commitments
The original data chunks are considered in the evaluation form, and unique polynomials are interpolated for each row. For every row $i$, the encoder interpolates a unique degree $k - 1$ polynomial $f_i$ such that $data^{j}_{i} = f_i(w^{j-1})$ for $i = 1, ..., \ell$ row indices and $j = 1, ..., k$ column indices. Recall that $w$ is a primitive element of the field.
Subsequently, 48-byte row commitment values $com_i = com(f_i)$ for these polynomials are computed by the encoder. These commitments ensure the correct ordering of chunks within each row.
Note: In this protocol, elliptic curves are used as a group, thus the entries of $com_i$'s are also elliptic curve points. Let the $x$-coordinate of $com_i$ be represented as $com^{x}{i}$ and the $y$-coordinate of $com_i$ as $com^{y}{j}$. If you have just $com^{x}{i}$ and one bit of $com^{y}{i}$, then you can construct $com_i$. Therefore, there is no need to use both coordinates of $com_i$. However, for the sake of simplicity in this document, the value $com_i$ is used.
Reed-Solomon Expansion
Using RS coding, the encoder extends the original data row-wise to obtain the expanded data matrix. The expansion is calculated by evaluating the row polynomials $f_i$ at the new points $w^{j}$ where $j = k + 1, k + 2, \ldots, 2k$. The current design of NomosDA uses an expansion factor of 2, but it can also work with different factors. This expanded data matrix has rows of length $2k$.

Figure 2: Extended data matrix showing original data ($k$ columns) and extended data ($2k$ columns total) after Reed-Solomon expansion.
Due to the homomorphic property of KZG, the row commitment values calculated in the previous step are also valid for the row polynomials of the extended data.
Combined Row Commitment and Column Proofs
To eliminate the need for generating one proof per chunk, a more efficient technique using random linear combinations of row polynomials is used, allowing only one proof to be generated per column while still ensuring the validity of all underlying row data.

Figure 3: Complete encoding pipeline showing row commitments (step 1), RS-encoding (step 2), and combined row commitment with column data (step 3).
This process consists of the following steps:
Compute the Random Linear Combination Polynomial
Let each row $i \in {1, \ldots, \ell}$ have an associated polynomial $f_i(x)$ and commitment $com_i = com(f_i)$.
The encoder computes random scalar $h \in \mathbb{F}$ using the Fiat–Shamir heuristic,
applying the BLAKE2b hash function with a 31-byte output,
over the row commitments with a domain separation tag DA_V1
to ensure uniqueness and prevent cross-protocol collisions:
$$h = \text{Hash}(\text{'DA_V1'} | com_1 | \ldots | com_{\ell})$$
The resulting digest is interpreted as a field element in the scalar field of BLS12-381.
Then, the encoder computes the combined polynomial $f_C(x)$, defined as:
$$f_C(x) = f_1(x) + h \cdot f_2(x) + h^{2} \cdot f_3(x) + \cdots + h^{\ell-1} \cdot f_{\ell}(x)$$
The corresponding commitment to this polynomial is $com(f_C)$. This value does not need to be computed by the encoder, since the verifier can derive it directly from the row commitments using the same random scalar $h$.
Compute Combined Evaluation Points per Column
For each column $j \in {1, \ldots, 2k}$, the encoder has the set of column values ${data^{j}{1}, data^{j}{2}, \ldots, data^{j}_{\ell}}$, where each value corresponds to $f_i(w^{j-1})$.
The encoder computes the combined evaluation value at column position $j$ directly:
$$v_j = f_C(w^{j-1})$$
Generate One Proof per Column
For each column index $j$, the encoder computes a single KZG evaluation proof $\pi_j$ for the combined polynomial $f_C(x)$ at the evaluation point $w^{j-1}$:
$$eval(f_C, w^{j-1}) \rightarrow (v_j, \pi_j)$$
The result is a set of $2k$ evaluation proofs, one for each column, derived from the combined row structure.
Dispersal
The encoder sends the following information to a DA node in the subnet corresponding to the expanded column number $j$:
- The row commitments ${com_1, com_2, \ldots, com_{\ell}}$.
- The column chunks ${data^{j}{1}, data^{j}{2}, \ldots, data^{j}_{\ell}}$.
- The combined proof of the column chunks $\pi_j$.
This information is also replicated by the receiving node to every other node in the subnet.
Verification
A DA node that receives the column information described above performs the following checks:

Figure 4: Dispersal and verification flow from Encoder to DA Node. The DA Node receives row commitments, column data, and combined proof, then verifies by calculating $h$, $com_C$, and $v_j$.
-
The DA node computes the scalar challenge $h \in \mathbb{F}$ using a Fiat–Shamir hash over the row commitments with a domain separation tag:
$$h = \text{Hash}(\text{'DA_V1'} | com_1 | com_2 | \ldots | com_{\ell})$$
-
The DA node computes the combined commitment $com_C$:
$$com_C = com_1 + h \cdot com_2 + h^{2} \cdot com_3 + \cdots + h^{\ell-1} \cdot com_{\ell}$$
This is the commitment of the following polynomial:
$$f_C(x) = f_1(x) + h \cdot f_2(x) + h^{2} \cdot f_3(x) + \cdots + h^{\ell-1} \cdot f_{\ell}(x)$$
-
The DA node computes:
$$v_j = data^{j}{1} + h \cdot data^{j}{2} + h^{2} \cdot data^{j}{3} + \cdots + h^{\ell-1} \cdot data^{j}{\ell}$$
This represents $f_C(w^{j-1})$, the evaluation of the combined polynomial at the corresponding column index.
-
The DA node verifies that $\pi_j$ is a valid proof:
$$\text{Verify}(com_C, w^{j-1}, v_j, \pi_j) \rightarrow \text{true/false}$$
Sampling
A sampling client, such as a light node, selects a random column index $s \in {1, \ldots, 2k}$. It sends a request for column $s$ to a DA node hosting that column's data. The DA node sends the client the column data $data^{s}_{i}$ and the combined proof $\pi_s$.

Figure 5: Sampling flow between DA Node and Sampling Client. The client requests a random column index $s$, receives the column data and proof, then verifies by calculating $h$, $com_C$, and $v_s$.
Note: The row commitments ${com_1, \ldots, com_{\ell}}$ for a given blob are public and remain unchanged across multiple queries to that blob. If a sampling client has already obtained them, it does not need to request them again.
The verification process run by the sampling client proceeds as follows:
-
Compute the scalar $h \in \mathbb{F}$ using the domain-separated Fiat–Shamir hash:
$$h = \text{Hash}(\text{'DA_V1'} | com_1 | com_2 | \ldots | com_{\ell})$$
-
Compute the combined commitment $com_C$:
$$com_C = com_1 + h \cdot com_2 + h^{2} \cdot com_3 + \cdots + h^{\ell-1} \cdot com_{\ell}$$
-
Compute the combined evaluation value $v_s$ using the received column data:
$$v_s = data^{s}{1} + h \cdot data^{s}{2} + h^{2} \cdot data^{s}{3} + \cdots + h^{\ell-1} \cdot data^{s}{\ell}$$
-
Verify the evaluation proof:
$$\text{Verify}(com_C, w^{s-1}, v_s, \pi_s) \rightarrow \text{true/false}$$
If these checks succeed, then this proves to the sampling client that the column $s$ is correctly encoded and matches the committed data. The sampling client can query several columns to reach a local opinion on the availability of the entire data.
Security Considerations
Fiat–Shamir Security
The random scalar $h$ MUST be computed using the Fiat–Shamir heuristic
with the domain separation tag DA_V1 to prevent cross-protocol attacks.
The hash function MUST be BLAKE2b with a 31-byte output.
Chunk Size
Chunks MUST be 31 bytes to ensure they fit within the BLS12-381 scalar field modulus. Using 32-byte chunks would cause some values to exceed the modulus, making data recovery impossible.
Column Sampling Confidence
The more columns a sampling client verifies, the higher confidence it has in the availability of the entire blob. Implementations SHOULD sample a sufficient number of columns to achieve the desired confidence level.
Proof Validity
If a single chunk is invalid, the combined evaluation will likely fail verification due to the unpredictability of the random coefficients. This provides strong guarantees against malicious encoders attempting to hide invalid data.
Part II: Implementation Considerations
IMPORTANT: The sections above define the normative protocol requirements. All implementations MUST comply with those requirements.
The sections below are non-normative. They provide mathematical background for implementers unfamiliar with the underlying cryptographic concepts.
Mathematical Background
Polynomial Interpolation
Polynomial interpolation is the process of creating a unique polynomial from a set of data. In NomosDA, univariate interpolation is used, where each polynomial is defined over a single variable. There are two main ways to represent polynomials:
Coefficient form: Given a set of coefficients $a_0, a_1, \ldots, a_k \in \mathbb{F}$, a unique polynomial $f(x)$ of degree at most $k$ in coefficient form is:
$$f(x) = a_0 + a_1 x + a_2 x^{2} + \cdots + a_k x^{k}$$
If $a_k \neq 0$, then the degree of $f$ is exactly $k$.
Evaluation form: Let $w \in \mathbb{F}$ be a primitive $k$-th root of unity in the field, i.e., $w^{k} = 1$ and $w^{i} \neq 1$ for all $1 \leq i < k$. Given a dataset $a_0, a_1, \ldots, a_{k-1}$, there exists a unique polynomial $f$ in $\mathbb{F}[X]$ of degree less than $k$ such that:
$$f(w^{i}) = a_i \quad \text{for all } i = 0, 1, \ldots, k - 1$$
This representation of a polynomial using its values at $k$ distinct points is called the evaluation form.
KZG Polynomial Commitment
The KZG polynomial commitment scheme provides a way to commit to a polynomial and provide a proof for an evaluation of this polynomial. This scheme has 4 steps: setup, polynomial commitment, proof evaluation, and proof verification.
The setup phase generates a structured reference string (SRS) and is required only once for all future uses of the scheme. The prover performs the polynomial commitment and proof generation steps, while the verifier checks the validity of the proof against the commitment and the evaluation point.
Setup:
- Choose a generator $g$ of a pairing-friendly elliptic curve group $G$.
- Select the maximum degree $d$ of the polynomials to be committed to.
- Choose a secret parameter $\tau$ and compute global parameters $gp = (g, g^{\tau}, g^{\tau^{2}}, \ldots, g^{\tau^{d}})$. Delete $\tau$ and release the parameters publicly.
Note: The expression $g^{a}$ refers to elliptic curve point addition, i.e., $g^{a} = a * g = g + g + \cdots + g$ where $g$ is the generator point of the group $G$. This is known as multiplicative notation.
Polynomial Commitment: Given a polynomial $f(x) = \sum_{i=0}^{d} a_i x^{i}$, compute the commitment of $f$ as follows:
$$com(f) = g^{f(\tau)} = (g)^{a_0} (g^{\tau})^{a_1} (g^{\tau^{2}})^{a_2} \cdots (g^{\tau^{d}})^{a_d}$$
Proof Evaluation: Given an evaluation $f(u) = v$, compute the proof $\pi = g^{q(\tau)}$, where $q(x) = \frac{f(x) - v}{x - u}$ is called the quotient polynomial and it is a polynomial if and only if $f(u) = v$.
Proof Verification: Given commitment $C = com(f)$, the evaluation point $u$, the evaluation $f(u) = v$, and proof $\pi = g^{q(\tau)}$, verify that:
$$e\left(\frac{C}{g^{v}}, g\right) = e\left(\pi, \frac{g^{\tau}}{g^{u}}\right)$$
where $e$ is a non-trivial bilinear pairing.
Note: The evaluation of the polynomial commitment to the function $f$ at the point $u$, yielding the result $v$ and evaluation proof $\pi$, is represented as: $eval(f, u) \rightarrow v, \pi$. The verification function is defined as: $verify(com(f), u, v, \pi) \rightarrow \text{true/false}$.
Random Linear Combination of Commitments and Evaluations
When multiple committed polynomials are evaluated at the same point, it's possible to verify all evaluations using a single combined proof, thanks to the homomorphic properties of KZG commitments. This technique improves efficiency by reducing multiple evaluation proofs to just one.
Suppose there are $\ell$ polynomials $f_1(x), f_2(x), \ldots, f_{\ell}(x)$ with corresponding commitments $C_i = com(f_i)$, and the goal is to verify that each $f_i(u) = v_i$.
Instead of generating $\ell$ separate proofs and performing $\ell$ pairing checks:
-
Use the Fiat–Shamir heuristic to derive deterministic random scalars $h_1, h_2, \ldots, h_{\ell}$ from the commitments $C_1, \ldots, C_{\ell}$:
$$(h_1, \ldots, h_{\ell}) = \text{Hash}(C_1 | \ldots | C_{\ell})$$
-
Form the combined polynomial:
$$f_C(x) = \sum_{i=1}^{\ell} h_i \cdot f_i(x)$$
-
Compute the combined evaluation:
$$v = f_C(u) = \sum_{i=1}^{\ell} h_i \cdot v_i$$
-
Compute the proof $\pi$ for $f_C(u) = v$ using the standard KZG method:
$$\pi = g^{q(\tau)} \quad \text{where} \quad q(x) = \frac{f_C(x) - v}{x - u}$$
Verification: Given commitments $C_1, \ldots, C_{\ell}$, evaluation point $u$ and value $v = f_C(u)$, and proof $\pi = g^{q(\tau)}$:
The verifier calculates the combined commitment $C = com(f_C)$ using random scalars $h_1, h_2, \ldots, h_{\ell}$:
$$(h_1, \ldots, h_{\ell}) = \text{Hash}(C_1 | \ldots | C_{\ell})$$
$$C = h_1 \cdot com_1 + h_2 \cdot com_2 + \cdots + h_{\ell} \cdot com_{\ell}$$
and checks:
$$e\left(\frac{C}{g^{v}}, g\right) \stackrel{?}{=} e\left(\pi, \frac{g^{\tau}}{g^{u}}\right)$$
This ensures that all original evaluations $f_i(u) = v_i$ are correct with a single proof and a single pairing check. Since the random scalars $h_i$ are generated via Fiat–Shamir, any incorrect $v_i$ will almost certainly cause the combined evaluation to fail verification.
Reed-Solomon Erasure Coding
Reed-Solomon coding, also known as RS coding, is an error-correcting code based on the fact that any $n$-degree polynomial can be uniquely determined by $n + 1$ points satisfying the polynomial equation. It uses the interpreted polynomial over the data set to produce more points in a process called expansion or encoding. Once the data is expanded, any $n$ elements of the total set of points can be used to reconstruct the original data.
Pairing Details
Let $(G_1, .)$, $(G_2, .)$, and $(G_T, .)$ be three cyclic groups of large prime order. A map $e : G_1 \times G_2 \rightarrow G_T$ is a pairing map such that:
$$e(g^{x}, g^{y}) = e(g, g)^{xy} = e(g, g^{xy})$$
Given $g^{x}$ and $g^{y}$, a pairing can check that some element $h = g^{xy}$ without knowing $x$ and $y$.
For the KZG commitment scheme to work, a so-called trusted setup is needed, consisting of a structured reference string (SRS). This is a set of curve points in $G_1$ and $G_2$. For a field element $u \in \mathbb{F}q$, define $u * g_i = g^{u}{i}$. The SRS consists of two sequences of group elements:
$$g^{0}{1}, g^{\tau}{1}, g^{\tau^{2}}{1}, g^{\tau^{3}}{1}, \ldots, g^{\tau^{D}}_{1} \in G_1$$
$$g^{0}{2}, g^{\tau}{2}, g^{\tau^{2}}{2}, g^{\tau^{3}}{2}, \ldots, g^{\tau^{K}}_{2} \in G_2$$
where $\tau \in \mathbb{F}_q$ is a secret field element, not known by either participant. $g_1$ is the generator point of $G_1$ and $g_2$ is the generator point of $G_2$. $D$ is the upper bound for the degree of the polynomials that can be committed to, and $K$ is the maximum number of evaluations to be proven using a batched proof.
Verify Operation: To verify an evaluation proof, the verifier checks the following equation:
$$q(x)(x - u) = f(x) - f(u) = f(x) - v$$
As the verifier does not have access to the actual polynomials $f$ and $q$, the next best thing would be to check that:
$$com(q) \cdot (x - u) = com(f - v)$$
Expanding the definition of $com$:
$$g^{q(\tau)(\tau - u)}{1} = g^{f(\tau) - v}{1}$$
For elliptic curve additive notation this is equivalent to:
$$q(\tau)(\tau - u) * g_1 = f(\tau) * g_1 - v * g_1$$
Now there is a problem, namely, the multiplication on the left-hand side. Pairings allow us to get away with one multiplication. So the verifier actually checks:
$$e(com(q), (\tau * g_2 - u * g_2)) = e(com(f) - v * g_1, g_2)$$
i.e.,
$$e(q(\tau) * g_1, (\tau * g_2 - u * g_2)) = e(f(\tau) * g_1 - v * g_1, g_2)$$
This works because of the bilinearity property of elliptic curve pairings:
$$e(a * g_1, b * g_2) = e(g_1, g_2)^{ab}$$
References
Normative
- BLS12-381 - BLS12-381 elliptic curve specification
Informative
- NomosDA Cryptographic Protocol - Original specification document
- Elliptic Curve Pairings - Background on elliptic curve pairings
Copyright
Copyright and related rights waived via CC0.
NOMOSDA-REWARDING
| Field | Value |
|---|---|
| Name | NomosDA Rewarding |
| Slug | 149 |
| Status | raw |
| Category | Standards Track |
| Editor | Marcin Pawlowski [email protected] |
| Contributors | Alexander Mozeika [email protected], Mehmet Gonen [email protected], Daniel Sanchez Quiros [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
0ef87b1— New RFC: CODEX-MANIFEST (#191) - 2026-01-30 —
3f76dd8— Add NomosDA Rewarding specification (#269)
Abstract
This document specifies the opinion-based rewarding mechanism for the NomosDA (Nomos Data Availability) service. The mechanism incentivizes DA nodes to maintain consistent and high-quality service through peer evaluation using a binary opinion system. Nodes assess the service quality of their counterparts across different subnetworks, and rewards are distributed based on accumulated positive opinions exceeding a defined activity threshold.
Keywords: NomosDA, data availability, rewarding, incentives, peer evaluation, activity proof, quality of service, sampling
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Terminology | Description |
|---|---|
| Block Finality | A period expressed in number of blocks (2160) after which a block is considered finalized, as defined by parameter $k$ in Cryptarchia. |
| Session | A time period during which the same set of nodes executes the protocol. Session length is two block finalization periods (4320 blocks). |
| Activity Proof | A data structure containing binary opinion vectors about other nodes' service quality. |
| Active Message | A message registered on the ledger that contains a node's activity proof for a session. |
| Opinion Threshold | The ratio of positive to negative opinions required for a node to be positively opinionated (default: 10). |
| Activity Threshold | The number of positive opinions ($\theta = N_s/2$) a node must collect to be considered active. |
| DA Node | A node providing data availability service, identified by a unique ProviderId. |
| SDP | Service Declaration Protocol, used to retrieve the list of active DA nodes. |
Notations
| Symbol | Description |
|---|---|
| $s$ | Current session number. |
| $N_s$ | Set of DA nodes (unique ProviderIds) active during session $s$. |
| $S$ | Session length in blocks (4320). |
| $b$ | Block number. |
| $\theta$ | Activity threshold ($N_s/2$). |
| $R_s$ | Base reward for session $s$. |
| $I_s$ | Total income for DA service during session $s$. |
| $R(n)$ | Reward for node $n$. |
Background
The NomosDA service is a crucial component of the Nomos architecture, responsible for ensuring accessibility and retrievability of blockchain data. This specification defines an opinion-based rewarding mechanism that incentivizes DA nodes to maintain consistent and high-quality service.
The approach uses peer evaluation through a binary opinion system, where nodes assess the service quality of their counterparts across different subnetworks of DA. This mechanism balances simplicity and effectiveness by integrating with the existing Nomos architecture while promoting decentralized quality control.
The strength of this approach comes from its economic design, which reduces possibilities for dishonest behaviour and collusion. The reward calculation method divides rewards based on the total number of nodes rather than just active ones, further discouraging manipulation of the opinion system.
Three-Session Operation
The mechanism operates across three consecutive sessions:
-
Session $s$: NomosDA nodes perform sampling of data blobs referenced in blocks. While sampling, nodes interact with and evaluate the service quality of other randomly selected nodes from different subnetworks. Nodes sample both new blocks and old blocks.
-
Session $s + 1$: Nodes formalize their evaluations by submitting Activity Proofs— binary vectors where each bit represents their opinion (positive or negative) about other nodes' service quality. These opinions are tracked separately for new and old blocks. The proofs are recorded on the ledger through Active Messages.
-
Session $s + 2$: Rewards are distributed. Nodes that accumulate positive opinions above the activity threshold receive a fixed reward calculated as a portion of the session's DA service income.
Protocol Specification
Session $s$: Sampling Phase
-
If the number of DA nodes (unique
ProviderIds from declarations) retrieved from the SDP is below the minimum, then do not perform sampling for new blocks. -
If the number of DA nodes retrieved from the SDP for session $s - 1$ was below the minimum, then do not perform sampling for old blocks.
-
If the number of DA nodes retrieved from the SDP is below the minimum for both session $s$ and $s - 1$, then stop and do not execute this protocol.
-
The DA node performs sampling for every new block $b$ it receives, and for an old block $b - S$ for every new block received (where $S = 4320$ is the session length).
-
The node selects at random (without replacement) 20 out of 2048 subnetworks.
Note: The set of nodes selected does not have to be the same for old and new blocks.
-
The node connects to a random node in each of the selected subnetworks. If a node does not respond to a sampling request, another node is selected from the same subnetwork and the sampling request is repeated until success is achieved or a specified limit is reached.
-
-
During sampling, the node measures the quality of service provided by selected nodes as defined in Quality of Service Measurement.
Session $s + 1$: Opinion Submission Phase
-
The DA node generates an Activity Proof that contains opinion vectors, where all DA nodes are rated for positive or negative quality of service for new and old blocks.
-
The DA node sends an Active Message that is registered on the ledger and contains the node's Activity Proof.
Session $s + 2$: Reward Distribution Phase
-
Every node that collected above $\theta$ positive opinions receives a fixed reward as defined in Reward Calculation.
-
The rewards are distributed by the Service Reward Distribution Protocol.
Constructions
Quality of Service Measurement
A node MUST measure the quality of service for each sampling it performs to gather opinions about the quality of service of the entire DA network. These opinions are used to construct the Activity Proof.
The global parameter opinion_threshold is set to 10,
meaning a node must receive 10 positive opinions for each negative opinion
to be positively opinionated (at least 90% positive opinions).
To build an opinions vector describing the quality of data availability sampling, a node MUST:
-
Retrieve $\mathcal{N}_s$, a list of active DA nodes (unique
ProviderIds) for session $s$, from the SDP. -
Retrieve $\mathcal{N}_{s-1}$, a list of active DA nodes for session $s - 1$, from the SDP (can be retained from the previous session).
-
Order $\mathcal{N}s$ and $\mathcal{N}{s-1}$ in ascending lexicographical order by
ProviderIdof each node from both lists. -
Create for each session and independently for old ($\mathcal{N} = \mathcal{N}_{s-1}$) and new ($\mathcal{N} = \mathcal{N}_s$) blocks:
-
positive_opinionsvector of size $N = \text{length}(\mathcal{N})$ where the $i$-th element (integer) represents positive opinions about the $i$-th node from list $\mathcal{N}$. -
negative_opinionsvector of size $N = \text{length}(\mathcal{N})$ where the $i$-th element (integer) represents negative opinions about the $i$-th node from list $\mathcal{N}$. -
blacklistvector of size $N = \text{length}(\mathcal{N})$ where the $i$-th element (bool) marks whether the $i$-th node is blacklisted due to providing an invalid response.
-
-
Send a sampling request to a node $n \in \mathcal{N}$ such that
blacklist[n]==0:-
If the node $n$ responds:
- If the response is valid, then
positive_opinions[n]++ - If the response is not valid, then:
- Clear positive opinions about the node:
positive_opinions[n]=0 - Mark the node as blacklisted:
blacklist[n]=1
- Clear positive opinions about the node:
- If the response is valid, then
-
If the node does not respond, then
negative_opinions[n]++
-
-
When the next session starts, create an opinions binary for every node $i \in \mathcal{N}$:
previous_session_opinions[i] = opinion(i, old.positive_opinions, old.negative_opinions, old.opinions_threshold) current_session_opinions[i] = opinion(i, new.positive_opinions, new.negative_opinions, new.opinions_threshold) def opinion(i, positive_opinions, negative_opinions, opinion_threshold): return (positive_opinions[i] > (negative_opinions[i] * opinion_threshold)) -
A node sets a positive opinion about itself in the
current_session_opinionsvector. -
A node sets a positive opinion about itself in the
previous_session_opinionsif the node was taking part in the protocol during the previous session.
Activity Proof
The Activity Proof structure is:
class ActivityProof:
current_session: SessionNumber
previous_session_opinions_length: int
previous_session_opinions: Opinions
current_session_opinions_length: int
current_session_opinions: Opinions
Opinions is a binary vector of length $N_s$
(total number of nodes identified by unique ProviderIds from declarations)
where each bit represents a node providing DA service for the session.
A bit is set to 1 only when the node considers the sampling service
provided by the DA node to meet quality standards.
Field Descriptions
-
current_session: The session number of the assignations used for forming opinions. -
previous_session_opinions_length: The number of bytes used byprevious_session_opinions. -
previous_session_opinions: Opinions gathered from sampling old blocks. When there are no old blocks (first session after genesis or after a non-operational DA period), these opinions SHOULD NOT be collected nor validated. -
current_session_opinions_length: The number of bytes used bycurrent_session_opinions. -
current_session_opinions: Opinions gathered from sampling new blocks.
Validity Rules
The Activity Proof is valid when:
-
The
current_session_opinionsvector is not provided (andcurrent_session_opinions_length==0) when the DA service was not operational during that session. -
The byte-length of the
previous_session_opinionsvector is:$$|\text{previous_session_opinions}| = \left\lceil \frac{\log_2(N_{s-1} + 1)}{8} \right\rceil$$
-
The
previous_session_opinionsvector is not provided (andprevious_session_opinions_length==0) when the DA service was not operational during that session. -
The byte-length of the
current_session_opinionsvector is:$$|\text{current_session_opinions}| = \left\lceil \frac{\log_2(N_s + 1)}{8} \right\rceil$$
-
The $n$-th node (note that $n \in \mathcal{N}s \not\Rightarrow n \in \mathcal{N}{s-1}$) is represented by the $n$-th bit of the vector (counting nodes from 0), with the vector encoded as little-endian. The rightmost byte of the vector MAY contain bits not mapped to any node; these bits are disregarded.
Activity Threshold
The activity threshold $\theta$ defines the number of positive opinions a node must collect from peers to be considered active for session $s$.
$$\theta = N_s / 2$$
Where $\theta$ controls the number of positive opinions a node must collect to be considered active.
Active Message
Each node for every session constructs an active_message
that MUST follow the specified format.
A node MAY stop sending active_message
when the DA service is non-operational for more than a single session.
The active_message metadata field MUST be populated with:
- A
headercontaining a one-byteversionfield fixed to0x01value. - The
activity_proofas defined above.
Active Message Rules
- An Active Message is stored on the ledger.
- An Active Message is used for calculating the node reward.
- An Active Message for session $s$ MUST only be sent during session $s + 1$; otherwise, it MUST be rejected.
- The ledger MUST only accept a single Active Message per node per session; any duplicate MUST be rejected.
Reward Calculation
The reward calculation follows these steps:
Step 1: Calculate Base Reward
Calculate the base reward for session $s$:
$$R_s = \frac{I_s}{N_s}$$
Where $I_s$ is the income for DA service during session $s$, and $N_s$ is the number of nodes providing DA service during session $s$.
Note: The base reward is fixed to the total number of nodes providing the service instead of the number of active nodes. This disincentivizes nodes from providing dishonest opinions about other nodes to increase their own reward.
The income leftovers MUST be burned or consumed in such a way that will not benefit the nodes.
Step 2: Count Positive Opinions
Count the number of positive opinions for node $n$ in session $s$:
$$\text{opinions}(n, s) = \sum_{i=1}^{N} \text{valid}(\text{activity_proof}(i, n, s))$$
Where $\text{valid}()$ returns true only when the activity_proof for node $n$ is valid
and the opinion about node $n$ is positive for session $s$.
Step 3: Calculate Node Reward
Calculate the reward for node $n$ based on node activity:
$$R(n) = \frac{R_s}{2} \cdot \text{active}(n, s) + \frac{R_{s-1}}{2} \cdot \text{active}(n, s - 1)$$
Where $\text{active}(n, s)$ returns true only when $n \in \mathcal{N}_s$ and the number of positive opinions on node $n$ for session $s$ is greater than or equal to $\theta$:
$$\text{opinions}(n, s) \geq \theta$$
The reward is a function of the node's capacity (quality) to respond to sampling requests for both new and old blocks. Therefore, the reward draws from half of the income from session $s$ (for new blocks) and half of the income from session $s - 1$ (for old blocks).
The base reward $R_s$ is distributed to nodes that both:
- Submitted a valid Activity Proof
- Received positive opinions exceeding the activity threshold for at least one of the sessions
Note: Inactive nodes are not rewarded. Nodes that have not participated in the previous session are not rewarded for the past session.
Security Considerations
Subjective Opinions
The mechanism intentionally uses subjective node opinions rather than strict performance metrics. While this introduces some arbitrariness, it provides a simple and flexible approach that aligns with Nomos' architectural goals.
Dishonest Evaluation
The system has potential for dishonest evaluation. However, the economic design reduces possibilities for dishonest behaviour and collusion:
- The reward calculation divides rewards based on total number of nodes rather than just active ones, discouraging manipulation of the opinion system.
- Income leftovers are burned to prevent benefit from underreporting.
Collusion Resistance
The activity threshold of $N_s/2$ requires a node to receive positive opinions from at least half of all nodes. This makes collusion attacks expensive, as an attacker would need to control a majority of nodes to guarantee rewards for malicious nodes.
References
Normative
- Service Declaration Protocol - Protocol for declaring DA node participation
Informative
- NomosDA Rewarding - Original specification document
- Analysis of Sampling Strategy - Motivation for sampling 20 subnetworks
Copyright
Copyright and related rights waived via CC0.
P2P-HARDWARE-REQUIREMENTS
| Field | Value |
|---|---|
| Name | Nomos p2p Network Hardware Requirements Specification |
| Slug | 137 |
| Status | raw |
| Category | infrastructure |
| Editor | Daniel Sanchez-Quiros [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-25 —
34bbd7a— Created nomos/raw/hardware-requirements.md file (#172)
Abstract
This specification defines the hardware requirements for running various types of Nomos blockchain nodes. Hardware needs vary significantly based on the node's role, from lightweight verification nodes to high-performance Zone Executors. The requirements are designed to support diverse participation levels while ensuring network security and performance.
Motivation
The Nomos network is designed to be inclusive and accessible across a wide range of hardware configurations. By defining clear hardware requirements for different node types, we enable:
- Inclusive Participation: Allow users with limited resources to participate as Light Nodes
- Scalable Infrastructure: Support varying levels of network participation based on available resources
- Performance Optimization: Ensure adequate resources for computationally intensive operations
- Network Security: Maintain network integrity through properly resourced validator nodes
- Service Quality: Define requirements for optional services that enhance network functionality
Important Notice: These hardware requirements are preliminary and subject to revision based on implementation testing and real-world network performance data.
Specification
Node Types Overview
Hardware requirements vary based on the node's role and services:
- Light Node: Minimal verification with minimal resources
- Basic Bedrock Node: Standard validation participation
- Service Nodes: Enhanced capabilities for optional network services
Light Node
Light Nodes provide network verification with minimal resource requirements, suitable for resource-constrained environments.
Target Use Cases:
- Mobile devices and smartphones
- Single-board computers (Raspberry Pi, etc.)
- IoT devices with network connectivity
- Users with limited hardware resources
Hardware Requirements:
| Component | Specification |
|---|---|
| CPU | Low-power processor (smartphone/SBC capable) |
| Memory (RAM) | 512 MB |
| Storage | Minimal (few GB) |
| Network | Reliable connection, 1 Mbps free bandwidth |
Basic Bedrock Node (Validator)
Basic validators participate in Bedrock consensus using typical consumer hardware.
Target Use Cases:
- Individual validators on consumer hardware
- Small-scale validation operations
- Entry-level network participation
Hardware Requirements:
| Component | Specification |
|---|---|
| CPU | 2 cores, 2 GHz modern multi-core processor |
| Memory (RAM) | 1 GB minimum |
| Storage | SSD with 100+ GB free space, expandable |
| Network | Reliable connection, 1 Mbps free bandwidth |
Service-Specific Requirements
Nodes can optionally run additional Bedrock Services that require enhanced resources beyond basic validation.
Data Availability (DA) Service
DA Service nodes store and serve data shares for the network's data availability layer.
Service Role:
- Store blockchain data and blob data long-term
- Serve data shares to requesting nodes
- Maintain high availability for data retrieval
Additional Requirements:
| Component | Specification | Rationale |
|---|---|---|
| CPU | Same as Basic Bedrock Node | Standard processing needs |
| Memory (RAM) | Same as Basic Bedrock Node | Standard memory needs |
| Storage | Fast SSD, 500+ GB free | Long-term chain and blob storage |
| Network | High bandwidth (10+ Mbps) | Concurrent data serving |
| Connectivity | Stable, accessible external IP | Direct peer connections |
Network Requirements:
- Capacity to handle multiple concurrent connections
- Stable external IP address for direct peer access
- Low latency for efficient data serving
Blend Protocol Service
Blend Protocol nodes provide anonymous message routing capabilities.
Service Role:
- Route messages anonymously through the network
- Provide timing obfuscation for privacy
- Maintain multiple concurrent connections
Additional Requirements:
| Component | Specification | Rationale |
|---|---|---|
| CPU | Same as Basic Bedrock Node | Standard processing needs |
| Memory (RAM) | Same as Basic Bedrock Node | Standard memory needs |
| Storage | Same as Basic Bedrock Node | Standard storage needs |
| Network | Stable connection (10+ Mbps) | Multiple concurrent connections |
| Connectivity | Stable, accessible external IP | Direct peer connections |
Network Requirements:
- Low-latency connection for effective message blending
- Stable connection for timing obfuscation
- Capability to handle multiple simultaneous connections
Executor Network Service
Zone Executors perform the most computationally intensive work in the network.
Service Role:
- Execute Zone state transitions
- Generate zero-knowledge proofs
- Process complex computational workloads
Critical Performance Note: Zone Executors perform the heaviest computational work in the network. High-performance hardware is crucial for effective participation and may provide competitive advantages in execution markets.
Hardware Requirements:
| Component | Specification | Rationale |
|---|---|---|
| CPU | Very high-performance multi-core processor | Zone logic execution and ZK proving |
| Memory (RAM) | 32+ GB strongly recommended | Complex Zone execution requirements |
| Storage | Same as Basic Bedrock Node | Standard storage needs |
| GPU | Highly recommended/often necessary | Efficient ZK proof generation |
| Network | High bandwidth (10+ Mbps) | Data dispersal and high connection load |
GPU Requirements:
- NVIDIA: CUDA-enabled GPU (RTX 3090 or equivalent recommended)
- Apple: Metal-compatible Apple Silicon
- Performance Impact: Strong GPU significantly reduces proving time
Network Requirements:
- Support for 2048+ direct UDP connections to DA Nodes (for blob publishing)
- High bandwidth for data dispersal operations
- Stable connection for continuous operation
Note: DA Nodes utilizing libp2p connections need sufficient capacity to receive and serve data shares over many connections.
Implementation Requirements
Minimum Requirements
All Nomos nodes MUST meet:
- Basic connectivity to the Nomos network via libp2p
- Adequate storage for their designated role
- Sufficient processing power for their service level
- Reliable network connection with appropriate bandwidth for QUIC transport
Optional Enhancements
Node operators MAY implement:
- Hardware redundancy for critical services
- Enhanced cooling for high-performance configurations
- Dedicated network connections for service nodes utilizing libp2p protocols
- Backup power systems for continuous operation
Resource Scaling
Requirements may vary based on:
- Network Load: Higher network activity increases resource demands
- Zone Complexity: More complex Zones require additional computational resources
- Service Combinations: Running multiple services simultaneously increases requirements
- Geographic Location: Network latency affects optimal performance requirements
Security Considerations
Hardware Security
- Secure Storage: Use encrypted storage for sensitive node data
- Network Security: Implement proper firewall configurations
- Physical Security: Secure physical access to node hardware
- Backup Strategies: Maintain secure backups of critical data
Performance Security
- Resource Monitoring: Monitor resource usage to detect anomalies
- Redundancy: Plan for hardware failures in critical services
- Isolation: Consider containerization or virtualization for service isolation
- Update Management: Maintain secure update procedures for hardware drivers
Performance Characteristics
Scalability
- Light Nodes: Minimal resource footprint, high scalability
- Validators: Moderate resource usage, network-dependent scaling
- Service Nodes: High resource usage, specialized scaling requirements
Resource Efficiency
- CPU Usage: Optimized algorithms for different hardware tiers
- Memory Usage: Efficient data structures for constrained environments
- Storage Usage: Configurable retention policies and compression
- Network Usage: Adaptive bandwidth utilization based on libp2p capacity and QUIC connection efficiency
References
Copyright
Copyright and related rights waived via CC0.
P2P-NAT-SOLUTION
| Field | Value |
|---|---|
| Name | Nomos P2P Network NAT Solution Specification |
| Slug | 138 |
| Status | raw |
| Category | networking |
| Editor | Antonio Antonino [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Daniel Sanchez-Quiros [email protected], Petar Radovic [email protected], Gusto Bacvinka [email protected], Youngjoon Lee [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-25 —
cfb3b78— Created nomos/raw/p2p-nat-solution.md draft (#174)
Abstract
This specification defines a comprehensive NAT (Network Address Translation) traversal solution for the Nomos P2P network. The solution enables nodes to automatically determine their NAT status and establish both outbound and inbound connections regardless of network configuration. The strategy combines AutoNAT, dynamic port mapping protocols, and continuous verification to maximize public reachability while maintaining decentralized operation.
Motivation
Network Address Translation presents a critical challenge for Nomos participants, particularly those operating on consumer hardware without technical expertise. The Nomos network requires a NAT traversal solution that:
- Automatic Operation: Works out-of-the-box without user configuration
- Inclusive Participation: Enables nodes on consumer hardware to participate effectively
- Decentralized Approach: Leverages the existing Nomos P2P network rather than centralized services
- Progressive Fallback: Escalates through increasingly complex protocols as needed
- Dynamic Adaptation: Handles changing network environments and configurations
The solution must ensure that nodes can both establish outbound connections and accept inbound connections from other peers, maintaining network connectivity across diverse NAT configurations.
Specification
Terminology
- Public Node: A node that is publicly reachable via a public IP address or valid port mapping
- Private Node: A node that is not publicly reachable due to NAT/firewall restrictions
- Dialing: The process of establishing a connection using the libp2p protocol stack
- NAT Status: Whether a node is publicly reachable or hidden behind NAT
Key Design Principles
Optional Configuration
The NAT traversal strategy must work out-of-the-box whenever possible. Users who do not want to engage in configuration should only need to install the node software package. However, users requiring full control must be able to configure every aspect of the strategy.
Decentralized Operation
The solution leverages the existing Nomos P2P network for coordination rather than relying on centralized third-party services. This maintains the decentralized nature of the network while providing necessary NAT traversal capabilities.
Progressive Fallback
The protocol begins with lightweight checks and escalates through more complex and resource-intensive protocols. Failure at any step moves the protocol to the next stage in the strategy, ensuring maximum compatibility across network configurations.
Dynamic Network Environment
Unless explicitly configured for static addresses, each node's public or private status is assumed to be dynamic. A once publicly-reachable node can become unreachable and vice versa, requiring continuous monitoring and adaptation.
Node Discovery Considerations
The Nomos public network encourages participation from a large number of nodes, many deployed through simple installation procedures. Some nodes will not achieve Public status, but the discovery protocol must track these peers and allow other nodes to discover them. This prevents network partitioning and ensures Private nodes remain accessible to other participants.
NAT Traversal Protocol
Protocol Requirements
Each node MUST:
- Run an AutoNAT client, except for nodes statically configured as Public
- Use the Identify protocol to advertise support for:
/nomos/autonat/2/dial-requestfor main network/nomos-testnet/autonat/2/dial-requestfor public testnet/nomos/autonat/2/dial-backand/nomos-testnet/autonat/2/dial-backrespectively
NAT State Machine
The NAT traversal process follows a multi-phase state machine:
graph TD
Start@{shape: circle, label: "Start"} -->|Preconfigured public IP or port mapping| StaticPublic[Statically configured as<br/>**Public**]
subgraph Phase0 [Phase 0]
Start -->|Default configuration| Boot
end
subgraph Phase1 [Phase 1]
Boot[Bootstrap and discover AutoNAT servers]--> Inspect
Inspect[Inspect own IP addresses]-->|At least 1 IP address in the public range| ConfirmPublic[AutoNAT]
end
subgraph Phase2 [Phase 2]
Inspect -->|No IP addresses in the public range| MapPorts[Port Mapping Client<br/>UPnP/NAT-PMP/PCP]
MapPorts -->|Successful port map| ConfirmMapPorts[AutoNAT]
end
ConfirmPublic -->|Node's IP address reachable by AutoNAT server| Public[**Public** Node]
ConfirmPublic -->|Node's IP address not reachable by AutoNAT server or Timeout| MapPorts
ConfirmMapPorts -->|Mapped IP address and port reachable by AutoNAT server| Public
ConfirmMapPorts -->|Mapped IP address and port not reachable by AutoNAT server or Timeout| Private
MapPorts -->|Failure or Timeout| Private[**Private** Node]
subgraph Phase3 [Phase 3]
Public -->Monitor
Private --> Monitor
end
Monitor[Network Monitoring] -->|Restart| Inspect
Phase Implementation
Phase 0: Bootstrapping and Identifying Public Nodes
If the node is statically configured by the operator to be Public, the procedure stops here.
The node utilizes bootstrapping and discovery mechanisms to find other Public nodes. The Identify protocol confirms which detected Public nodes support AutoNAT v2.
Phase 1: NAT Detection
The node starts an AutoNAT client and inspects its own addresses. For each public IP address, the node verifies public reachability via AutoNAT. If any public IP addresses are confirmed, the node assumes Public status and moves to Phase 3. Otherwise, it continues to Phase 2.
Phase 2: Automated Port Mapping
The node attempts to secure port mapping on the default gateway using:
- PCP (Port Control Protocol) - Most reliable
- NAT-PMP (NAT Port Mapping Protocol) - Second most reliable
- UPnP-IGD (Universal Plug and Play Internet Gateway Device) - Most widely deployed
Port Mapping Algorithm:
def try_port_mapping():
# Step 1: Get the local IPv4 address
local_ip = get_local_ipv4_address()
# Step 2: Get the default gateway IPv4 address
gateway_ip = get_default_gateway_address()
# Step 3: Abort if local or gateway IP could not be determined
if not local_ip or not gateway_ip:
return "Mapping failed: Unable to get local or gateway IPv4"
# Step 4: Probe the gateway for protocol support
supports_pcp = probe_pcp(gateway_ip)
supports_nat_pmp = probe_nat_pmp(gateway_ip)
supports_upnp = probe_upnp(gateway_ip) # Optional for logging
# Step 5-9: Try protocols in order of reliability
# PCP (most reliable) -> NAT-PMP -> UPnP -> fallback attempts
protocols = [
(supports_pcp, try_pcp_mapping),
(supports_nat_pmp, try_nat_pmp_mapping),
(True, try_upnp_mapping), # Always try UPnP
(not supports_pcp, try_pcp_mapping), # Fallback
(not supports_nat_pmp, try_nat_pmp_mapping) # Last resort
]
for supported, mapping_func in protocols:
if supported:
mapping = mapping_func(local_ip, gateway_ip)
if mapping:
return mapping
return "Mapping failed: No protocol succeeded"
If mapping succeeds, the node uses AutoNAT to confirm public reachability. Upon confirmation, the node assumes Public status. Otherwise, it assumes Private status.
Port Mapping Sequence:
sequenceDiagram
box Node
participant AutoNAT Client
participant NAT State Machine
participant Port Mapping Client
end
participant Router
alt Mapping is successful
Note left of AutoNAT Client: Phase 2
Port Mapping Client ->> +Router: Requests new mapping
Router ->> Port Mapping Client: Confirms new mapping
Port Mapping Client ->> NAT State Machine: Mapping secured
NAT State Machine ->> AutoNAT Client: Requests confirmation<br/>that mapped address<br/>is publicly reachable
alt Node asserts Public status
AutoNAT Client ->> NAT State Machine: Mapped address<br/>is publicly reachable
Note left of AutoNAT Client: Phase 3<br/>Network Monitoring
else Node asserts Private status
AutoNAT Client ->> NAT State Machine: Mapped address<br/>is not publicly reachable
Note left of AutoNAT Client: Phase 3<br/>Network Monitoring
end
else Mapping fails, node asserts Private status
Note left of AutoNAT Client: Phase 2
Port Mapping Client ->> Router: Requests new mapping
Router ->> Port Mapping Client: Refuses new mapping or Timeout
Port Mapping Client ->> NAT State Machine: Mapping failed
Note left of AutoNAT Client: Phase 3<br/>Network Monitoring
end
Phase 3: Network Monitoring
Unless explicitly configured, nodes must monitor their network status and restart from Phase 1 when changes are detected.
Public Node Monitoring:
A Public node must restart when:
- AutoNAT client no longer confirms public reachability
- A previously successful port mapping is lost or refresh fails
Private Node Monitoring:
A Private node must restart when:
- It gains a new public IP address
- Port mapping is likely to succeed (gateway change, sufficient time passed)
Network Monitoring Sequence:
sequenceDiagram
participant AutoNAT Server
box Node
participant AutoNAT Client
participant NAT State Machine
participant Port Mapping Client
end
participant Router
Note left of AutoNAT Server: Phase 3<br/>Network Monitoring
par Refresh mapping and monitor changes
loop periodically refreshes mapping
Port Mapping Client ->> Router: Requests refresh
Router ->> Port Mapping Client: Confirms mapping refresh
end
break Mapping is lost, the node loses Public status
Router ->> Port Mapping Client: Refresh failed or mapping dropped
Port Mapping Client ->> NAT State Machine: Mapping lost
NAT State Machine ->> NAT State Machine: Restart
end
and Monitor public reachability of mapped addresses
loop periodically checks public reachability
AutoNAT Client ->> AutoNAT Server: Requests dialback
AutoNAT Server ->> AutoNAT Client: Dialback successful
end
break
AutoNAT Server ->> AutoNAT Client: Dialback failed or Timeout
AutoNAT Client ->> NAT State Machine: Public reachability lost
NAT State Machine ->> NAT State Machine: Restart
end
end
Note left of AutoNAT Server: Phase 1
Public Node Responsibilities
A Public node MUST:
-
Run an AutoNAT server
-
Listen on and advertise via Identify protocol its publicly reachable multiaddresses:
/{public_peer_ip}/udp/{port}/quic-v1/p2p/{public_peer_id} -
Periodically renew port mappings according to protocol recommendations
-
Maintain high availability for AutoNAT services
Peer Dialing
Other peers can always dial a Public peer using its publicly reachable multiaddresses:
/{public_peer_ip}/udp/{port}/quic-v1/p2p/{public_peer_id}
Implementation Requirements
Mandatory Components
All Nomos nodes MUST implement:
- AutoNAT client for NAT status detection
- Port mapping clients for PCP, NAT-PMP, and UPnP-IGD
- Identify protocol for capability advertisement
- Network monitoring for status change detection
Optional Enhancements
Nodes MAY implement:
- Custom port mapping retry strategies
- Enhanced network change detection
- Advanced AutoNAT server load balancing
- Backup connectivity mechanisms
Configuration Parameters
AutoNAT Configuration
autonat:
client:
dial_timeout: 15s
max_peer_addresses: 16
throttle_global_limit: 30
throttle_peer_limit: 3
server:
dial_timeout: 30s
max_peer_addresses: 16
throttle_global_limit: 30
throttle_peer_limit: 3
Port Mapping Configuration
port_mapping:
pcp:
timeout: 30s
lifetime: 7200s # 2 hours
retry_interval: 300s
nat_pmp:
timeout: 30s
lifetime: 7200s
retry_interval: 300s
upnp:
timeout: 30s
lease_duration: 7200s
retry_interval: 300s
Security Considerations
NAT Traversal Security
- Port Mapping Validation: Verify that requested port mappings are actually created
- AutoNAT Server Trust: Implement peer reputation for AutoNAT servers
- Gateway Communication: Secure communication with NAT devices
- Address Validation: Validate public addresses before advertisement
Privacy Considerations
- IP Address Exposure: Public nodes necessarily expose IP addresses
- Traffic Analysis: Monitor for patterns that could reveal node behavior
- Gateway Information: Minimize exposure of internal network topology
Denial of Service Protection
- AutoNAT Rate Limiting: Implement request throttling for AutoNAT services
- Port Mapping Abuse: Prevent excessive port mapping requests
- Resource Exhaustion: Limit concurrent NAT traversal attempts
Performance Characteristics
Scalability
- AutoNAT Server Load: Distributed across Public nodes
- Port Mapping Overhead: Minimal ongoing resource usage
- Network Monitoring: Efficient periodic checks
Reliability
- Fallback Mechanisms: Multiple protocols ensure high success rates
- Continuous Monitoring: Automatic recovery from connectivity loss
- Protocol Redundancy: Multiple port mapping protocols increase reliability
References
- Multiaddress spec
- Identify protocol spec
- AutoNAT v2 protocol spec
- Circuit Relay v2 protocol spec
- PCP - RFC 6887
- NAT-PMP - RFC 6886
- UPnP IGD - RFC 6970
Copyright
Copyright and related rights waived via CC0.
P2P-NETWORK-BOOTSTRAPPING
| Field | Value |
|---|---|
| Name | Nomos P2P Network Bootstrapping Specification |
| Slug | 134 |
| Status | raw |
| Category | networking |
| Editor | Daniel Sanchez-Quiros [email protected] |
| Contributors | Álvaro Castro-Castilla [email protected], Petar Radovic [email protected], Gusto Bacvinka [email protected], Antonio Antonino [email protected], Youngjoon Lee [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-25 —
aa8a3b0— Created nomos/raw/p2p-network-bootstrapping.md draft (#175)
Introduction
Nomos network bootstrapping is the process by which a new node discovers peers and synchronizes with the existing decentralized network. It ensures that a node can:
- Discover Peers – Find other active nodes in the network.
- Establish Connections – Securely connect to trusted peers.
- Negotiate (libp2p) Protocols - Ensure that other peers operate in the same protocols as the node needs.
Overview
The Nomos P2P network bootstrapping strategy relies on a designated subset of bootstrap nodes to facilitate secure and efficient node onboarding. These nodes serve as the initial entry points for new network participants.
Key Design Principles
Trusted Bootstrap Nodes
A curated set of publicly announced and highly available nodes ensures reliability during initial peer discovery. These nodes are configured with elevated connection limits to handle a high volume of incoming bootstrapping requests from new participants.
Node Configuration & Onboarding
New node operators must explicitly configure their instances with the addresses of bootstrap nodes. This configuration may be preloaded or dynamically fetched from a trusted source to minimize manual setup.
Network Integration
Upon initialization, the node establishes connections with the bootstrap nodes and begins participating in Nomos networking protocols. Through these connections, the node discovers additional peers, synchronizes with the network state, and engages in protocol-specific communication (e.g., consensus, block propagation).
Security & Decentralization Considerations
Trust Minimization: While bootstrap nodes provide initial connectivity, the network rapidly transitions to decentralized peer discovery to prevent over-reliance on any single entity.
Authenticated Announcements: The identities and addresses of bootstrap nodes are publicly verifiable to mitigate impersonation attacks. From libp2p documentation:
To authenticate each others' peer IDs, peers encode their peer ID into a self-signed certificate, which they sign using their host's private key.
Dynamic Peer Management: After bootstrapping, nodes continuously refine their peer lists to maintain a resilient and distributed network topology.
This approach ensures rapid, secure, and scalable network participation while preserving the decentralized ethos of the Nomos protocol.
Protocol
Protocol Overview
The bootstrapping protocol follows libp2p conventions for peer discovery and connection establishment. Implementation details are handled by the underlying libp2p stack with Nomos-specific configuration parameters.
Bootstrapping Process
Step-by-Step bootstrapping process
-
Node Initial Configuration: New nodes load pre-configured bootstrap node addresses. Addresses may be
IPorDNSembedded in a compatible libp2p PeerId multiaddress. Node operators may chose to advertise more than one address. This is out of the scope of this protocol. For example:/ip4/198.51.100.0/udp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5Nor/dns/foo.bar.net/udp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N -
Secure Connection: Nodes establish connections to bootstrap nodes announced addresses. Verifies network identity and protocol compatibility.
-
Peer Discovery: Requests and receives validated peer lists from bootstrap nodes. Each entry includes connectivity details as per the peer discovery protocol engaging after the initial connection.
-
Network Integration: Iteratively connects to discovered peers. Gradually build peer connections.
-
Protocol Engagement: Establishes required protocol channels (gossip/consensus/sync). Begins participating in network operations.
-
Ongoing Maintenance: Continuously evaluates and refreshes peer connections. Ideally removes the connection to the bootstrap node itself. Bootstrap nodes may chose to remove the connection on their side to keep high availability for other nodes.
sequenceDiagram
participant Nomos Network
participant Node
participant Bootstrap Node
Node->>Node: Fetches bootstrapping addresses
loop Interacts with bootstrap node
Node->>+Bootstrap Node: Connects
Bootstrap Node->>-Node: Sends discovered peers information
end
loop Connects to Network participants
Node->>Nomos Network: Engages in connections
Node->>Nomos Network: Negotiates protocols
end
loop Ongoing maintenance
Node-->>Nomos Network: Evaluates peer connections
alt Bootstrap connection no longer needed
Node-->>Bootstrap Node: Disconnects
else Bootstrap enforces disconnection
Bootstrap Node-->>Node: Disconnects
end
end
Implementation Details
The bootstrapping process for the Nomos p2p network uses the QUIC transport as specified in the Nomos network specification.
Bootstrapping is separated from the network's peer discovery protocol. It assumes that there is one protocol that would engage as soon as the connection with the bootstrapping node triggers. Currently Nomos network uses kademlia as the current first approach for the Nomos p2p network, this comes granted.
Bootstrap Node Requirements
Bootstrap nodes MUST fulfill the following requirements:
- High Availability: Maintain uptime of 99.5% or higher
- Connection Capacity: Support minimum 1000 concurrent connections
- Geographic Distribution: Deploy across multiple regions
- Protocol Compatibility: Support all required Nomos network protocols
- Security: Implement proper authentication and rate limiting
Network Configuration
Bootstrap node addresses are distributed through:
- Hardcoded addresses in node software releases
- DNS seeds for dynamic address resolution
- Community-maintained lists with cryptographic verification
Security Considerations
Trust Model
Bootstrap nodes operate under a minimal trust model:
- Nodes verify peer identities through cryptographic authentication
- Bootstrap connections are temporary and replaced by organic peer discovery
- No single bootstrap node can control network participation
Attack Mitigation
Sybil Attack Protection: Bootstrap nodes implement connection limits and peer verification to prevent malicious flooding.
Eclipse Attack Prevention: Nodes connect to multiple bootstrap nodes and rapidly diversify their peer connections.
Denial of Service Resistance: Rate limiting and connection throttling protect bootstrap nodes from resource exhaustion attacks.
Performance Characteristics
Bootstrapping Metrics
- Initial Connection Time: Target < 30 seconds to first bootstrap node
- Peer Discovery Duration: Discover minimum viable peer set within 2 minutes
- Network Integration: Full protocol engagement within 5 minutes
Resource Requirements
Bootstrap Nodes
- Memory: Minimum 4GB RAM
- Bandwidth: 100 Mbps sustained
- Storage: 50GB available space
Regular Nodes
- Memory: 512MB for bootstrapping process
- Bandwidth: 10 Mbps during initial sync
- Storage: Minimal requirements
References
- P2P Network Specification (internal document)
- libp2p QUIC Transport
- libp2p Peer IDs and Addressing
- Ethereum bootnodes
- Bitcoin peer discovery
- Cardano nodes connectivity
- Cardano peer sharing
Copyright
Copyright and related rights waived via CC0.
Blockchain Deprecated Specifications
Deprecated Blockchain specifications kept for archival and reference purposes.
CONSENSUS-CLARO
| Field | Value |
|---|---|
| Name | Claro Consensus Protocol |
| Slug | 140 |
| Status | deprecated |
| Category | Standards Track |
| Editor | Corey Petty [email protected] |
| Contributors | Álvaro Castro-Castilla, Mark Evenson |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-02-15 —
1ddddc7— update to tree structure (#128) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-02 —
f52c54f— Update and rename CLARO.md to claro.md - 2024-01-27 —
01e2781— Create CLARO.md
Abstract
This document specifies Claro: a Byzantine, fault-tolerant, binary decision agreement algorithm that utilizes bounded memory for its execution. Claro is a novel variant of the Snow family providing a probabilistic leaderless BFT consensus algorithm that achieves metastablity via network sub-sampling. We present an application context of the use of Claro in an efficient, leaderless, probabilistic permission-less consensus mechanism. We outline a simple taxonomy of Byzantine adversaries, leaving explicit explorations of to subsequent publication.
NOTE: We have renamed this variant to Claro from Glacier
in order to disambiguate from a previously released research endeavor by
Amores-Sesar, Cachin, and Tedeschi.
Their naming was coincidentally named the same as our work but
is sufficiently differentiated from how ours works.
Motivation
This work is a part of a larger research endeavor to explore highly scalable Byzantine Fault Tolerant (BFT) consensus protocols. Consensus lies at the heart of many decentralized protocols, and thus its characteristics and properties are inherited by applications built on top. Thus, we seek to improve upon the current state of the art in two main directions: base-layer scalability and censorship resistance.
Avalanche has shown to exibit the former in a production environment in a way that is differentiated from Nakamoto consensus and other Proof of Stake (PoS) protocols based in practical Byzantine Fault Tolerant (pBFT) methodologies. We aim to understand its limitations and improve upon them.
Background
Our starting point is Avalanche’s Binary Byzantine Agreement algorithm, called Snowball. As long as modifications allow a DAG to be constructed later on, this simplifies the design significantly. The DAG stays the same in principle: it supports confidence, but the core algorithm can be modeled without.
The concept of the Snowball algorithm is relatively simple. Following is a simplified description (lacking some details, but giving an overview). For further details, please refer to the Avalanche paper.
- The objective is to vote yes/no on a decision (this decision could be a single bit or, in our DAG use case, whether a vertex should be included or not).
- Every node has an eventually-consistent complete view of the network. It will select at random k nodes, and will ask their opinion on the decision (yes/no).
- After this sampling is finished,
if there is a vote that has more than an
alphathreshold, it accumulates one count for this opinion, as well as changes its opinion to this one. But, if a different opinion is received, the counter is reset to 1. If no thresholdalphais reached, the counter is reset to 0 instead. - After several iterations of this algorithm,
we will reach a threshold
beta, and decide on that as final.
Next, we will proceed to describe our new algorithm, based on Snowball.
We have identified a shortcoming of the Snowball algorithm that was a perfect starting point for devising improvements. The scenario is as follows:
- There is a powerful adversary in the network, that controls a large percentage of the node population: 10% to ~50%.
- This adversary follows a strategy that allows them to rapidly change the decision bit (possibly even in a coordinated way) so as to maximally confuse the honest nodes.
- Under normal conditions,
honest nodes will accumulate supermajorities soon enough, and
reach the
betathreshold. However, when an honest node performs a query and does not reach the thresholdalphaof responses, the counter will be set to 0. - The highest threat to Snowball is an adversary
that keeps it from reaching the
betathreshold, managing to continuously reset the counter, and steering Snowball away from making a decision.
This document only outlines the specification to Claro. Subsequent analysis work on Claro (both on its performance and how it differentiates with Snowball) will be published shortly and this document will be updated.
Claro Algorithm Specification
The Claro consensus algorithm computes a boolean decision on a proposition via a set of distributed computational nodes. Claro is a leaderless, probabilistic, binary consensus algorithm with fast finality that provides good reliability for network and Byzantine fault tolerance.
Algorithmic concept
Claro is an evolution of the Snowball Byzantine Binary Agreement (BBA) algorithm, in which we tackle specifically the perceived weakness described above. The main focus is going to be the counter and the triggering of the reset. Following, we elaborate the different modifications and features that have been added to the reference algorithm:
- Instead of allowing the latest evidence to change the opinion completely, we take into account all accumulated evidence, to reduce the impact of high variability when there is already a large amount of evidence collected.
- Eliminate the counter and threshold scheme,
and introduce instead two regimes of operation:
- One focused on grabbing opinions and reacting as soon as possible. This part is somewhat closer conceptually to the reference algorithm.
- Another one focused on interpreting the accumulated data instead of reacting to the latest information gathered.
- Finally, combine those two phases via a transition function. This avoids the creation of a step function, or a sudden change in behavior that could complicate analysis and understanding of the dynamics. Instead, we can have a single algorithm that transfers weight from one operation to the other as more evidence is gathered.
- Additionally, we introduce a function for weighted sampling.
This will allow the combination of different forms of weighting:
- Staking
- Heuristic reputation
- Manual reputation.
It’s worth delving a bit into the way the data is interpreted in order to reach a decision. Our approach is based conceptually on the paper Confidence as Higher-Order Uncertainty, which describes a frequentist approach to decision certainty. The first-order certainty, measured by frequency, is caused by known positive evidence, and the higher-order certainty is caused by potential positive evidence. Because confidence is a relative measurement defined on evidence, it naturally follows comparing the amount of evidence the system knows with the amount that it will know in the near future (defining “near” as a constant).
Intuitively, we are looking for a function of evidence, w,
call it c for confidence, that satisfies the following conditions:
- Confidence
cis a continuous and monotonically increasing function ofw. (More evidence, higher confidence.) - When
w = 0,c = 0. (Without any evidence, confidence is minimum.) - When
wgoes to infinity,cconverges to 1. (With infinite evidence, confidence is maximum.)
The paper describes also a set of operations for the evidence/confidence pairs, so that different sources of knowledge could be combined. However, we leave here the suggestion of a possible research line in the future combining an algebra of evidence/confidence pairs with swarm-propagation algorithm like the one described in this paper.
Initial opinion
A proposal is formulated to which consensus of truth or falsity is
desired. Each node that participates starts the protocol with an
opinion on the proposal, represented in the sequel as NO, NONE,
and YES.
A new proposition is discovered either by local creation or in
response to a query, a node checks its local opinion. If the node can
compute a justification of the proposal, it sets its opinion to one of
YES or NO. If it cannot form an opinion, it leaves its opinion as
NONE.
For now, we will ignore the proposal dissemination process and assume all nodes participating have an initial opinion to respond to within a given request. Further research will relax this assumption and analyze timing attacks on proposal propagation through the network.
The node then participates in a number of query rounds in which it
solicits other node's opinion in query rounds. Given a set of N
leaderless computational nodes, a gossip-based protocol is presumed to
exist which allows members to discover, join, and leave a weakly
transitory maximally connected graph. Joining this graph allows each
node to view a possibly incomplete node membership list of all other
nodes. This view may change as the protocol advances, as nodes join
and leave. Under generalized Internet conditions, the membership of
the graph would experience a churn rate varying across different
time-scales, as the protocol rounds progress. As such, a given node
may not have a view on the complete members participating in the
consensus on a proposal in a given round.
The algorithm is divided into 4 phases:
- Querying
- Computing
confidence,evidence, andaccumulated evidence - Transition function
- Opinion and Decision
Setup Parameters
The node initializes the following integer ratios as constants:
# The following values are constants chosen with justification from experiments
# performed with the adversarial models
#
confidence_threshold
<-- 1
# constant look ahead for number of rounds we expect to finalize a
# decision. Could be set dependent on number of nodes
# visible in the current gossip graph.
look_ahead
<-- 19
# the confidence weighting parameter (aka alpha_1)
certainty
<-- 4 / 5
doubt ;; the lack of confidence weighting parameter (aka alpha_2)
<-- 2 / 5
k_multiplier ;; neighbor threshold multiplier
<-- 2
;;; maximal threshold multiplier, i.e. we will never exceed
;;; questioning k_initial * k_multiplier ^ max_k_multiplier_power peers
max_k_multiplier_power
<-- 4
;;; Initial number of nodes queried in a round
k_initial
<-- 7
;;; maximum query rounds before termination
max_rounds ;; placeholder for simulation work, no justification yet
<-- 100
The following variables are needed to keep the state of Claro:
;; current number of nodes to attempt to query in a round
k
<-- k_original
;; total number of votes examined over all rounds
total_votes
<-- 0
;; total number of YES (i.e. positive) votes for the truth of the proposal
total_positive
<-- 0
;; the current query round, an integer starting from zero
round
<-- 0
Phase One: Query
A node selects k nodes randomly from the complete pool of peers in the
network. This query is can optionally be weighted, so the probability
of selecting nodes is proportional to their
Node Weighting $$ P(i) = \frac{w_i}{\sum_{j=0}^{j=N} w_j} $$
where w is evidence.
The list of nodes is maintained by a separate protocol (the network layer),
and eventual consistency of this knowledge in the network
suffices. Even if there are slight divergences in the network view
from different nodes, the algorithm is resilient to those.
A query is sent to each neighbor with the node's current opinion of
the proposal.
Each node replies with their current opinion on the proposal.
See the wire protocol Interoperability section for details on the semantics and syntax of the "on the wire" representation of this query.
Adaptive querying. An additional optimization in the query
consists of adaptively growing the k constant in the event of
high confusion. We define high confusion as the situation in
which neither opinion is strongly held in a query (i.e. a
threshold is not reached for either yes or no). For this, we will
use the alpha threshold defined below. This adaptive growth of
the query size is done as follows:
Every time the threshold is not reached, we multiply k by a
constant. In our experiments, we found that a constant of 2 works
well, but what really matters is that it stays within that order of
magnitude.
The growth is capped at 4 times the initial k value. Again, this
is an experimental value, and could potentially be increased. This
depends mainly on complex factors such as the size of the query
messages, which could saturate the node bandwidth if the number of
nodes queried is too high.
When the query finishes, the node now initializes the following two values:
new_votes
<-- |total vote replies received in this round to the current query|
positive_votes
<-- |YES votes received from the query|
Phase Two: Computation
When the query returns, three ratios are used later on to compute the
transition function and the opinion forming. Confidence encapsulates
the notion of how much we know (as a node) in relation to how much we
will know in the near future (this being encoded in the look-ahead
parameter l.) Evidence accumulated keeps the ratio of total positive
votes vs the total votes received (positive and negative), whereas the
evidence per round stores the ratio of the current round only.
Parameters $$ \begin{array}{lc} \text{Look-ahead parameter} & l = 20 \newline \text{First evidence parameter} & \alpha_1 = 0.8 \newline \text{Second evidence parameter} & \alpha_2 = 0.5 \newline \end{array} $$
Computation
$$
\begin{array}{lc}
\text{Confidence} & c_{accum} \impliedby \frac{total\ votes}
{total\ votes + l} \newline
\text{Total accumulated evidence}& e_{accum} \impliedby \frac{total\ positive
votes}{total\ votes} \newline
\text{Evidence per round} & e_{round} \impliedby \frac{round\ positive
votes}{round\ votes} \newline
\end{array}
$$
The node runs the new_votes and positive_votes parameters received
in the query round through the following algorithm:
total_votes
+== new_votes
total_positive
+== positive_votes
confidence
<-- total_votes / (total_votes + look_ahead)
total_evidence
<-- total_positive / total_votes
new_evidence
<-- positive_votes / new_votes
evidence
<-- new_evidence * ( 1 - confidence ) + total_evidence * confidence
alpha
<-- doubt * ( 1 - confidence ) + certainty * confidence
Phase Three: Computation
In order to eliminate the need for a step function (a conditional in the code), we introduce a transition function from one regime to the other. Our interest in removing the step function is twofold:
-
Simplify the algorithm. With this change the number of branches is reduced, and everything is expressed as a set of equations.
-
The transition function makes the regime switch smooth, making it harder to potentially exploit the sudden regime change in some unforeseen manner. Such a swift change in operation mode could potentially result in a more complex behavior than initially understood, opening the door to elaborated attacks. The transition function proposed is linear with respect to the confidence.
Transition Function $$ \begin{array}{cl} evidence & \impliedby e_{round} (1 - c_{accum}) + e_{accum} c_{accum} \newline \alpha & \impliedby \alpha_1 (1 - c_{accum}) + \alpha_2 c_{accum} \newline \end{array} $$
Since the confidence is modeled as a ratio that depends on the
constant l, we can visualize the transition function at
different values of l. Recall that this constant encapsulates
the idea of “near future” in the frequentist certainty model: the
higher it is, the more distant in time we consider the next
valuable input of evidence to happen.
We have observed via experiment that for a transition function to be useful, we need establish two requirements:
-
The change has to be balanced and smooth, giving an opportunity to the first regime to operate and not jump directly to the second regime.
-
The convergence to 1.0 (fully operating in the second regime) should happen within a reasonable time-frame. We’ve set this time-frame experimentally at 1000 votes, which is in the order of ~100 queries given a
kof 9.
[[ Note: Avalanche uses k = 20, as an experimental result from their deployment. Due to the fundamental similarities between the algorithms, it’s a good start for us. ]]
The node updates its local opinion on the consensus proposal by
examining the relationship between the evidence accumulated for a
proposal with the confidence encoded in the alpha parameter:
IF
evidence > alpha
THEN
opinion <-- YES
ELSE IF
evidence < 1 - alpha
THEN
opinion <-- NO
If the opinion of the node is NONE after evaluating the relation
between evidence and alpha, adjust the number of uniform randomly
queried nodes by multiplying the neighbors k by the k_multiplier
up to the limit of k_max_multiplier_power query size increases.
;; possibly increase number nodes to uniformly randomly query in next round
WHEN
opinion is NONE
AND
k < k_original * k_multiplier ^ max_k_multiplier_power
THEN
k <-- k * k_multiplier
Decision
The next step is a simple one: change our opinion if the threshold
alpha is reached. This needs to be done separately for the YES/NO
decision, checking both boundaries. The last step is then to decide
on the current opinion. For that, a confidence threshold is
employed. This threshold is derived from the network size, and is
directly related to the number of total votes received.
Decision $$ \begin{array}{cl} evidence > \alpha & \implies \text{opinion YES} \newline evidence < 1 - \alpha & \implies \text{opinion NO} \newline if\ \text{confidence} > c_{target} & THEN \ \text{finalize decision} \newline \end{array} $$
After the OPINION phase is executed, the current value of confidence
is considered: if confidence exceeds a threshold derived from the
network size and directly related to the total votes received, an
honest node marks the decision as final, and always returns this
opinion is response to further queries from other nodes on the
network.
IF
confidence > confidence_threshold
OR
round > max_rounds
THEN
finalized <-- T
QUERY LOOP TERMINATES
ELSE
round +== 1
QUERY LOOP CONTINUES
Thus, after the decision phase, either a decision has been finalized and the local node becomes quiescent never initiating a new query, or it initiates a new query.
Termination
A local round of Claro terminates in one of the following execution model considerations:
-
No queries are received for any newly initiated round for temporal periods observed via a locally computed passage of time. See the following point on local time.
-
The
confidenceon the proposal exceeds our threshold for finalization. -
The number of
roundsexecuted would be greater thanmax_rounds.
Quiescence
After a local node has finalized an opinion into a decision, it enters a quiescent
state whereby it never solicits new votes on the proposal. The local
node MUST reply with the currently finalized decision.
Clock
The algorithm only requires that nodes have computed the drift of observation of the passage of local time, not that that they have coordinated an absolute time with their peers. For an implementation of a phase locked-loop feedback to measure local clock drift see NTP.
Further points
Node receives information during round
In the query step, the node is envisioned as packing information into
the query to cut down on the communication overhead a query to each of
this k nodes containing the node's own current opinion on the
proposal (YES, NO, or NONE). The algorithm does not currently
specify how a given node utilizes this incoming information. A
possible use may be to count unsolicited votes towards a currently
active round, and discard the information if the node is in a
quiescent state.
Problems with Weighting Node Value of Opinions
If the view of other nodes is incomplete, then the sum of the optional weighting will not be a probability distribution normalized to 1.
The current algorithm doesn't describe how the initial opinions are formed.
Implementation status
The following implementations have been created for various testing and simulation purposes:
- Rust
- Python - FILL THIS IN WITH NEWLY CREATED REPO
- Common Lisp - FILL THIS IN WITH NEWLY CREATED REPO
Wire Protocol
For interoperability we present a wire protocol semantics by requiring
the validity of the following statements expressed in Notation3 (aka
n3) about any query performed by a query node:
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix Claro <https://rdf.logos.co/protocol/Claro#> .
Claro:query
:holds (
:_0 [ rdfs:label "round";
a xsd:postitiveInteger; ],
rdfs:comment """
The current round of this query
A value of zero corresponds to the initial round.
""" ;
:_1 [ rdfs:label "uri";
rdfs:comment """
A unique URI for the proposal.
It MAY be possible to examine the proposal by resolving this resource,
and its associated URIs.
""" ;
a xsd:anyURI ],
:_2 [ rdfs:label "opinion";
rdfs:comment """
The opinion on the proposal
One of the strings "YES" "NO" or "NONE".
""" ;
# TODO constrain as an enumeration on three values efficiently
a xsd:string ]
) .
Nodes are advised to use Waku messages to include their own metadata in serializations as needed.
Syntax
The semantic description presented above can be reliably round-tripped through a suitable serialization mechanism. JSON-LD provides a canonical mapping to UTF-8 JSON.
At their core, the query messages are a simple enumeration of the three possible values of the opinion:
{ NO, NONE, YES }
When represented via integers, such as choosing
{ -1, 0, +1 }
the parity summations across network invariants often become easier to manipulate.
Security Considerations
Privacy
In practice, each honest node gossips its current opinion which reduces the number of messages that need to be gossiped for a given proposal. The resulting impact on the privacy of the node's opinion is not currently analyzed.
Security with respect to various Adversarial Models
Adversarial models have been tested for which the values for current parameters of Claro have been tuned. Exposition of the justification of this tuning need to be completed.
Local Strategies
Random Adversaries
A random adversary optionally chooses to respond to all queries with a random decision. Note that this adversary may be in some sense Byzantine but not malicious. The random adversary also models some software defects involved in not "understanding" how to derive a truth value for a given proposition.
Infantile Adversary
Like a petulant child, an infantile adversary responds with the opposite vote of the honest majority on an opinion.
Omniscient Adversaries
Omniscient adversaries have somehow gained an "unfair" participation in
consensus by being able to control f of N nodes with a out-of-band
"supra-liminal" coordination mechanism. Such adversaries use this
coordinated behavior to delay or sway honest majority consensus.
Passive Gossip Adversary
The passive network omniscient adversary is fully aware at all times of the network state. Such an adversary can always chose to vote in the most efficient way to block the distributed consensus from finalizing.
Active Gossip Adversary
An omniscient gossip adversary somehow not only controls f of N
nodes, but has also has corrupted communications between nodes such
that she may inspect, delay, and drop arbitrary messages. Such an
adversary uses capability to corrupt consensus away from honest
decisions to ones favorable to itself. This adversary will, of
course, choose to participate in an honest manner until defecting is
most advantageous.
Future Directions
Although we have proposed a normative description of the implementation of the underlying binary consensus algorithm (Claro), we believe we have prepared for analysis its adversarial performance in a manner that is amenable to replacement by another member of the snow* family.
We have presumed the existence of a general family of algorithms that can be counted on to vote on nodes in the DAG in a fair manner. Avalanche provides an example of the construction of votes on UTXO transactions. One can express all state machine, i.e. account-based models as checkpoints anchored in UTXO trust, so we believe that this presupposition has some justification. We can envision a need for tooling abstraction that allow one to just program the DAG itself, as they should be of stable interest no matter if Claro isn't.
Informative References
Normative References
Copyright
Copyright and related rights waived via CC0
Storage LIPs
Specifications related the Logos Storage decentralised data platform. Visit Storage specs to view the new Storage specifications currently under discussion.
Storage Raw Specifications
Early-stage Storage specifications collected before reaching draft status.
CODEX-BLOCK-EXCHANGE
| Field | Value |
|---|---|
| Name | Codex Block Exchange Protocol |
| Slug | 111 |
| Status | raw |
| Category | Standards Track |
| Editor | Codex Team |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-12-12 —
b2f3564— Improved codex/raw/codex-block-exchange.md file (#215) - 2025-11-19 —
63107d3— Created new codex/raw/codex-block-exchange.md file (#211)
Specification Status
This specification contains a mix of:
- Verified protocol elements: Core message formats, protobuf structures, and addressing modes confirmed from implementation
- Design specifications: Payment flows, state machines, and negotiation strategies representing intended behavior
- Recommended values: Protocol limits and timeouts that serve as guidelines (actual implementations may vary)
- Pending verification: Some technical details (e.g., multicodec 0xCD02) require further validation
Sections marked with notes indicate areas where implementation details may differ from this specification.
Abstract
The Block Exchange (BE) is a core Codex component responsible for peer-to-peer content distribution across the network. It manages the sending and receiving of data blocks between nodes, enabling efficient data sharing and retrieval. This specification defines both an internal service interface and a network protocol for referring to and providing data blocks. Blocks are uniquely identifiable by means of an address and represent fixed-length chunks of arbitrary data.
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Term | Description |
|---|---|
| Block | Fixed-length chunk of arbitrary data, uniquely identifiable |
| Standalone Block | Self-contained block addressed by SHA256 hash (CID) |
| Dataset Block | Block in ordered set, addressed by dataset CID + index |
| Block Address | Unique identifier for standalone/dataset addressing |
| WantList | List of block requests sent by a peer |
| Block Delivery | Transmission of block data from one peer to another |
| Block Presence | Indicator of whether peer has requested block |
| Merkle Proof | Proof verifying dataset block position correctness |
| CodexProof | Codex-specific Merkle proof format verifying a block's position within a dataset tree |
| Stream | Bidirectional libp2p communication channel between two peers for exchanging messages |
| Peer Context Store | Internal data structure tracking active peer connections, their WantLists, and exchange state |
| CID | Content Identifier - hash-based identifier for content |
| Multicodec | Self-describing format identifier for data encoding |
| Multihash | Self-describing hash format |
Motivation
The Block Exchange module serves as the fundamental layer for content distribution in the Codex network. It provides primitives for requesting and delivering blocks of data between peers, supporting both standalone blocks and blocks that are part of larger datasets. The protocol is designed to work over libp2p streams and integrates with Codex's discovery, storage, and payment systems.
When a peer wishes to obtain a block, it registers its unique address with the Block Exchange, and the Block Exchange will then be in charge of procuring it by finding a peer that has the block, if any, and then downloading it. The Block Exchange will also accept requests from peers which might want blocks that the node has, and provide them.
Discovery Separation: Throughout this specification we assume that if a peer wants a block, then the peer has the means to locate and connect to peers which either: (1) have the block; or (2) are reasonably expected to obtain the block in the future. In practical implementations, the Block Exchange will typically require the support of an underlying discovery service, e.g., the Codex DHT, to look up such peers, but this is beyond the scope of this document.
The protocol supports two distinct block types to accommodate different use cases: standalone blocks for independent data chunks and dataset blocks for ordered collections of data that form larger structures.
Block Format
The Block Exchange protocol supports two types of blocks:
Standalone Blocks
Standalone blocks are self-contained pieces of data addressed by their SHA256 content identifier (CID). These blocks are independent and do not reference any larger structure.
Properties:
- Addressed by content hash (SHA256)
- Default size: 64 KiB
- Self-contained and independently verifiable
Dataset Blocks
Dataset blocks are part of ordered sets and are addressed by a
(datasetCID, index) tuple.
The datasetCID refers to the Merkle tree root of the entire dataset,
and the index indicates the block's position within that dataset.
Formally, we can define a block as a tuple consisting of raw data and
its content identifier: (data: seq[byte], cid: Cid), where standalone
blocks are addressed by cid, and dataset blocks can be addressed
either by cid or a (datasetCID, index) tuple.
Properties:
- Addressed by
(treeCID, index)tuple - Part of a Merkle tree structure
- Require Merkle proof for verification
- Must be uniformly sized within a dataset
- Final blocks MUST be zero-padded if incomplete
Block Specifications
All blocks in the Codex Block Exchange protocol adhere to the following specifications:
| Property | Value | Description |
|---|---|---|
| Default Block Size | 64 KiB | Standard size for data blocks |
| Maximum Block Size | 100 MiB | Upper limit for block data field |
| Multicodec | codex-block (0xCD02)* | Format identifier |
| Multihash | sha2-256 (0x12) | Hash algorithm for addressing |
| Padding Requirement | Zero-padding | Incomplete final blocks padded |
Note: *The multicodec value 0xCD02 is not currently registered in the official multiformats multicodec table. This may be a reserved/private code pending official registration.
Protocol Limits
To ensure network stability and prevent resource exhaustion, implementations SHOULD enforce reasonable limits. The following are recommended values (actual implementation limits may vary):
| Limit | Recommended Value | Description |
|---|---|---|
| Maximum Block Size | 100 MiB | Maximum size of block data in BlockDelivery |
| Maximum WantList Size | 1000 entries | Maximum entries per WantList message |
| Maximum Concurrent Requests | 256 per peer | Maximum simultaneous block requests per peer |
| Stream Timeout | 60 seconds | Idle stream closure timeout |
| Request Timeout | 300 seconds | Maximum time to fulfill a block request |
| Maximum Message Size | 105 MiB | Maximum total message size (protobuf) |
| Maximum Pending Bytes | 10 GiB | Maximum pending data per peer connection |
Note: These values are not verified from implementation and serve as reasonable guidelines. Actual implementations MAY use different limits based on their resource constraints and deployment requirements.
Enforcement:
- Implementations MUST reject messages exceeding their configured size limits
- Implementations SHOULD track per-peer request counts
- Implementations SHOULD close streams exceeding configured timeout limits
- Implementations MAY implement stricter or more lenient limits based on local resources
Service Interface
The Block Exchange module exposes two core primitives for block management:
requestBlock
async def requestBlock(address: BlockAddress) -> Block
Registers a block address for retrieval and returns the block data when available. This function can be awaited by the caller until the block is retrieved from the network or local storage.
Parameters:
address: BlockAddress - The unique address identifying the block to retrieve
Returns:
Block- The retrieved block data
cancelRequest
async def cancelRequest(address: BlockAddress) -> bool
Cancels a previously registered block request.
Parameters:
address: BlockAddress - The address of the block request to cancel
Returns:
bool- True if the cancellation was successful, False otherwise
Dependencies
The Block Exchange module depends on and interacts with several other Codex components:
| Component | Purpose |
|---|---|
| Discovery Module | DHT-based peer discovery for locating nodes |
| Local Store (Repo) | Persistent block storage for local blocks |
| Advertiser | Announces block availability to the network |
| Network Layer | libp2p connections and stream management |
Protocol Specification
Protocol Identifier
The Block Exchange protocol uses the following libp2p protocol identifier:
/codex/blockexc/1.0.0
Version Negotiation
The protocol version is negotiated through libp2p's multistream-select protocol during connection establishment. The following describes standard libp2p version negotiation behavior; actual Codex implementation details may vary.
Protocol Versioning
Version Format: /codex/blockexc/<major>.<minor>.<patch>
- Major version: Incompatible protocol changes
- Minor version: Backward-compatible feature additions
- Patch version: Backward-compatible bug fixes
Current Version: 1.0.0
Version Negotiation Process
1. Initiator opens stream
2. Initiator proposes: "/codex/blockexc/1.0.0"
3. Responder checks supported versions
4. If supported:
Responder accepts: "/codex/blockexc/1.0.0"
→ Connection established
5. If not supported:
Responder rejects with: "na" (not available)
→ Try fallback version or close connection
Compatibility Rules
Major Version Compatibility:
- Major version
1.x.xis incompatible with2.x.x - Nodes MUST support only their major version
- Cross-major-version communication requires protocol upgrade
Minor Version Compatibility:
- Version
1.1.0MUST be backward compatible with1.0.0 - Newer minors MAY include optional features
- Older nodes ignore unknown message fields (protobuf semantics)
Patch Version Compatibility:
- All patches within same minor version are fully compatible
- Patches fix bugs without changing protocol behavior
Multi-Version Support
Implementations MAY support multiple protocol versions simultaneously:
Supported protocols (in preference order):
1. /codex/blockexc/1.2.0 (preferred, latest features)
2. /codex/blockexc/1.1.0 (fallback, stable)
3. /codex/blockexc/1.0.0 (legacy support)
Negotiation Strategy:
- Propose highest supported version first
- If rejected, try next lower version
- If all rejected, connection fails
- Track peer's supported version for future connections
Feature Detection
For optional features within same major.minor version:
Method 1: Message field presence
- Send message with optional field
- Peer ignores if not supported (protobuf default)
Method 2: Capability exchange (future extension)
- Exchange capability bitmask in initial message
- Enable features only if both peers support
Version Upgrade Path
Backward Compatibility:
- New versions MUST handle messages from older versions
- Unknown message fields silently ignored
- Unknown WantList flags ignored
- Unknown BlockPresence types treated as DontHave
Forward Compatibility:
- Older versions MAY ignore new message types
- Critical features require major version bump
- Optional features use minor version bump
Connection Model
The protocol operates over libp2p streams. When a node wants to communicate with a peer:
- The initiating node dials the peer using the protocol identifier
- A bidirectional stream is established
- Both sides can send and receive messages on this stream
- Messages are encoded using Protocol Buffers
- The stream remains open for the duration of the exchange session
- Peers track active connections in a peer context store
The protocol handles peer lifecycle events:
- Peer Joined: When a peer connects, it is added to the active peer set
- Peer Departed: When a peer disconnects gracefully, its context is cleaned up
- Peer Dropped: When a peer connection fails, it is removed from the active set
Message Flow Examples
This section illustrates typical message exchange sequences for common block exchange scenarios.
Example 1: Standalone Block Request
Scenario: Node A requests a standalone block from Node B
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.entries[0]: |
| address.cid = QmABC123 |
| wantType = wantBlock |
| priority = 0 |
| |
|<-- Message(blockPresences, payload) ----|
| blockPresences[0]: |
| address.cid = QmABC123 |
| type = presenceHave |
| payload[0]: |
| cid = QmABC123 |
| data = <64 KiB block data> |
| address.cid = QmABC123 |
| |
Steps:
- Node A sends WantList requesting block with
wantType = wantBlock - Node B checks local storage, finds block
- Node B responds with BlockPresence confirming availability
- Node B includes BlockDelivery with actual block data
- Node A verifies CID matches SHA256(data)
- Node A stores block locally
Example 2: Dataset Block Request with Merkle Proof
Scenario: Node A requests a dataset block from Node B
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.entries[0]: |
| address.leaf = true |
| address.treeCid = QmTree456 |
| address.index = 42 |
| wantType = wantBlock |
| |
|<-- Message(payload) ---------------------|
| payload[0]: |
| cid = QmBlock789 |
| data = <64 KiB zero-padded data> |
| address.leaf = true |
| address.treeCid = QmTree456 |
| address.index = 42 |
| proof = <CodexProof bytes> |
| |
Steps:
- Node A sends WantList for dataset block at specific index
- Node B locates block in dataset
- Node B generates CodexProof for block position in Merkle tree
- Node B delivers block with proof
- Node A verifies proof against treeCid
- Node A verifies block data integrity
- Node A stores block with dataset association
Example 3: Block Presence Check (wantHave)
Scenario: Node A checks if Node B has a block without requesting full data
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.entries[0]: |
| address.cid = QmCheck999 |
| wantType = wantHave |
| sendDontHave = true |
| |
|<-- Message(blockPresences) -------------|
| blockPresences[0]: |
| address.cid = QmCheck999 |
| type = presenceHave |
| price = 0x00 (free) |
| |
Steps:
- Node A sends WantList with
wantType = wantHave - Node B checks local storage without loading block data
- Node B responds with BlockPresence only (no payload)
- Node A updates peer availability map
- If Node A decides to request, sends new WantList with
wantType = wantBlock
Example 4: Block Not Available
Scenario: Node A requests block Node B doesn't have
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.entries[0]: |
| address.cid = QmMissing111 |
| wantType = wantBlock |
| sendDontHave = true |
| |
|<-- Message(blockPresences) -------------|
| blockPresences[0]: |
| address.cid = QmMissing111 |
| type = presenceDontHave |
| |
Steps:
- Node A requests block with
sendDontHave = true - Node B checks storage, block not found
- Node B sends BlockPresence with
presenceDontHave - Node A removes Node B from candidates for this block
- Node A queries discovery service for alternative peers
Example 5: WantList Cancellation
Scenario: Node A cancels a previous block request
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.entries[0]: |
| address.cid = QmCancel222 |
| cancel = true |
| |
Steps:
- Node A sends WantList entry with
cancel = true - Node B removes block request from peer's want queue
- Node B stops any pending block transfer for this address
- No response message required for cancellation
Example 6: Delta WantList Update
Scenario: Node A adds requests to existing WantList
Node A Node B
| |
|--- Message(wantlist) ------------------>|
| wantlist.full = false |
| wantlist.entries[0]: |
| address.cid = QmNew1 |
| wantType = wantBlock |
| wantlist.entries[1]: |
| address.cid = QmNew2 |
| wantType = wantBlock |
| |
Steps:
- Node A sends WantList with
full = false(delta update) - Node B merges entries with existing WantList for Node A
- Node B begins processing new requests
- Previous WantList entries remain active
Sequence Diagrams
These diagrams illustrate the complete flow of block exchange operations including service interface, peer discovery, and network protocol interactions.
Complete Block Request Flow
The protocol supports two strategies for WantBlock requests, each with different trade-offs. Implementations may choose the strategy based on network conditions, peer availability, and resource constraints.
Strategy 1: Parallel Request (Low Latency)
In this strategy, the requester sends wantType = wantBlock to all
discovered peers simultaneously.
This minimizes latency as the first peer to respond with the block
data wins, but it wastes bandwidth since multiple peers may send
the same block data.
Trade-offs:
- Pro: Lowest latency - block arrives as soon as any peer can deliver it
- Pro: More resilient to slow or unresponsive peers
- Con: Bandwidth-wasteful - multiple peers may send duplicate data
- Con: Higher network overhead for the requester
- Best for: Time-critical data retrieval, unreliable networks
sequenceDiagram
participant Client
participant BlockExchange
participant LocalStore
participant Discovery
participant PeerA
participant PeerB
participant PeerC
Client->>BlockExchange: requestBlock(address)
BlockExchange->>LocalStore: checkBlock(address)
LocalStore-->>BlockExchange: Not found
BlockExchange->>Discovery: findPeers(address)
Discovery-->>BlockExchange: [PeerA, PeerB, PeerC]
par Send wantBlock to all peers
BlockExchange->>PeerA: Message(wantlist: wantBlock)
BlockExchange->>PeerB: Message(wantlist: wantBlock)
BlockExchange->>PeerC: Message(wantlist: wantBlock)
end
Note over PeerA,PeerC: All peers start preparing block data
PeerB-->>BlockExchange: Message(payload: BlockDelivery)
Note over BlockExchange: First response wins
BlockExchange->>BlockExchange: Verify block
BlockExchange->>LocalStore: Store block
par Cancel requests to other peers
BlockExchange->>PeerA: Message(wantlist: cancel)
BlockExchange->>PeerC: Message(wantlist: cancel)
end
Note over PeerA,PeerC: May have already sent data (wasted bandwidth)
BlockExchange-->>Client: Return block
Strategy 2: Two-Phase Discovery (Bandwidth Efficient)
In this strategy, the requester first sends wantType = wantHave to
discover which peers have the block, then sends wantType = wantBlock
only to a single selected peer.
This conserves bandwidth but adds an extra round-trip of latency.
Trade-offs:
- Pro: Bandwidth-efficient - only one peer sends block data
- Pro: Enables price comparison before committing to a peer
- Pro: Allows selection based on peer reputation or proximity
- Con: Higher latency due to extra round-trip for presence check
- Con: Selected peer may become unavailable between phases
- Best for: Large blocks, paid content, bandwidth-constrained networks
sequenceDiagram
participant Client
participant BlockExchange
participant LocalStore
participant Discovery
participant PeerA
participant PeerB
participant PeerC
Client->>BlockExchange: requestBlock(address)
BlockExchange->>LocalStore: checkBlock(address)
LocalStore-->>BlockExchange: Not found
BlockExchange->>Discovery: findPeers(address)
Discovery-->>BlockExchange: [PeerA, PeerB, PeerC]
Note over BlockExchange: Phase 1: Discovery
par Send wantHave to all peers
BlockExchange->>PeerA: Message(wantlist: wantHave)
BlockExchange->>PeerB: Message(wantlist: wantHave)
BlockExchange->>PeerC: Message(wantlist: wantHave)
end
PeerA-->>BlockExchange: BlockPresence(presenceDontHave)
PeerB-->>BlockExchange: BlockPresence(presenceHave, price=X)
PeerC-->>BlockExchange: BlockPresence(presenceHave, price=Y)
BlockExchange->>BlockExchange: Select best peer (PeerB: lower price)
Note over BlockExchange: Phase 2: Retrieval
BlockExchange->>PeerB: Message(wantlist: wantBlock)
PeerB-->>BlockExchange: Message(payload: BlockDelivery)
BlockExchange->>BlockExchange: Verify block
BlockExchange->>LocalStore: Store block
BlockExchange-->>Client: Return block
Hybrid Approach
Implementations MAY combine both strategies:
- Use two-phase discovery for large blocks or paid content
- Use parallel requests for small blocks or time-critical data
- Adaptively switch strategies based on network conditions
flowchart TD
A[Block Request] --> B{Block Size?}
B -->|Small < 64 KiB| C[Parallel Strategy]
B -->|Large >= 64 KiB| D{Paid Content?}
D -->|Yes| E[Two-Phase Discovery]
D -->|No| F{Network Condition?}
F -->|Reliable| E
F -->|Unreliable| C
C --> G[Return Block]
E --> G
Dataset Block Verification Flow
sequenceDiagram
participant Requester
participant Provider
participant Verifier
Requester->>Provider: WantList(leaf=true, treeCid, index)
Provider->>Provider: Load block at index
Provider->>Provider: Generate CodexProof
Provider->>Requester: BlockDelivery(data, proof)
Requester->>Verifier: Verify proof
alt Proof valid
Verifier-->>Requester: Valid
Requester->>Requester: Verify CID
alt CID matches
Requester->>Requester: Store block
Requester-->>Requester: Success
else CID mismatch
Requester->>Requester: Reject block
Requester->>Provider: Disconnect
end
else Proof invalid
Verifier-->>Requester: Invalid
Requester->>Requester: Reject block
Requester->>Provider: Disconnect
end
Payment Flow with State Channels
sequenceDiagram
participant Buyer
participant Seller
participant StateChannel
Buyer->>Seller: Message(wantlist)
Seller->>Seller: Check block availability
Seller->>Buyer: BlockPresence(price)
alt Buyer accepts price
Buyer->>StateChannel: Create update
StateChannel-->>Buyer: Signed state
Buyer->>Seller: Message(payment: StateChannelUpdate)
Seller->>StateChannel: Verify update
alt Payment valid
StateChannel-->>Seller: Valid
Seller->>Buyer: BlockDelivery(data)
Buyer->>Buyer: Verify block
Buyer->>StateChannel: Finalize
else Payment invalid
StateChannel-->>Seller: Invalid
Seller->>Buyer: BlockPresence(price)
end
else Buyer rejects price
Buyer->>Seller: Message(wantlist.cancel)
end
Peer Lifecycle Management
sequenceDiagram
participant Network
participant BlockExchange
participant PeerStore
participant Peer
Network->>BlockExchange: PeerJoined(Peer)
BlockExchange->>PeerStore: AddPeer(Peer)
BlockExchange->>Peer: Open stream
loop Active exchange
BlockExchange->>Peer: Message(wantlist/payload)
Peer->>BlockExchange: Message(payload/presence)
end
alt Graceful disconnect
Peer->>BlockExchange: Close stream
BlockExchange->>PeerStore: RemovePeer(Peer)
else Connection failure
Network->>BlockExchange: PeerDropped(Peer)
BlockExchange->>PeerStore: RemovePeer(Peer)
BlockExchange->>BlockExchange: Requeue pending requests
end
Message Format
All messages use Protocol Buffers encoding for serialization. The main message structure supports multiple operation types in a single message.
Main Message Structure
message Message {
Wantlist wantlist = 1;
// Field 2 reserved for future use
repeated BlockDelivery payload = 3;
repeated BlockPresence blockPresences = 4;
int32 pendingBytes = 5;
AccountMessage account = 6;
StateChannelUpdate payment = 7;
}
Fields:
wantlist: Block requests from the sender- Field 2: Reserved (unused, see note below)
payload: Block deliveries (actual block data)blockPresences: Availability indicators for requested blockspendingBytes: Number of bytes pending deliveryaccount: Account information for micropaymentspayment: State channel update for payment processing
Note on Missing Field 2:
Field number 2 is intentionally skipped in the Message protobuf definition. This is a common protobuf practice for several reasons:
- Protocol Evolution: Field 2 may have been used in earlier versions and removed, with the field number reserved to prevent reuse
- Forward Compatibility: Reserving field numbers ensures old clients can safely ignore new fields
- Implementation History: May have been used during development and removed before final release
The gap does not affect protocol operation. Protobuf field numbers need not be sequential, and skipping numbers is standard practice for protocol evolution.
Block Address
The BlockAddress structure supports both standalone and dataset block addressing:
message BlockAddress {
bool leaf = 1;
bytes treeCid = 2; // Present when leaf = true
uint64 index = 3; // Present when leaf = true
bytes cid = 4; // Present when leaf = false
}
Fields:
leaf: Indicates if this is dataset block (true) or standalone (false)treeCid: Merkle tree root CID (present whenleaf = true)index: Position of block within dataset (present whenleaf = true)cid: Content identifier of the block (present whenleaf = false)
Addressing Modes:
- Standalone Block (
leaf = false): Direct CID reference to a standalone content block - Dataset Block (
leaf = true): Reference to a block within an ordered set, identified by a Merkle tree root and an index. The Merkle root may refer to either a regular dataset, or a dataset that has undergone erasure-coding
WantList
The WantList communicates which blocks a peer desires to receive:
message Wantlist {
enum WantType {
wantBlock = 0;
wantHave = 1;
}
message Entry {
BlockAddress address = 1;
int32 priority = 2;
bool cancel = 3;
WantType wantType = 4;
bool sendDontHave = 5;
}
repeated Entry entries = 1;
bool full = 2;
}
WantType Values:
wantBlock (0): Request full block deliverywantHave (1): Request availability information only (presence check)
Entry Fields:
address: The block being requestedpriority: Request priority (currently always 0, reserved for future use)cancel: If true, cancels a previous want for this blockwantType: Specifies whether full block or presence is desiredwantHave (1): Only check if peer has the blockwantBlock (0): Request full block data
sendDontHave: If true, peer should respond even if it doesn't have the block
Priority Field Clarification:
The priority field is currently fixed at 0 in all implementations and is
reserved for future protocol extensions. Originally intended for request
prioritization, this feature is not yet implemented.
Current Behavior:
- All WantList entries use
priority = 0 - Implementations MUST accept priority values but MAY ignore them
- Blocks are processed in order received, not by priority
Future Extensions:
The priority field is reserved for:
- Bandwidth Management: Higher priority blocks served first during congestion
- Time-Critical Data: Urgent blocks (e.g., recent dataset indices) prioritized
- Fair Queueing: Priority-based scheduling across multiple peers
- QoS Tiers: Different service levels based on payment/reputation
Implementation Notes:
- Senders SHOULD set
priority = 0for compatibility - Receivers MUST NOT reject messages with non-zero priority
- Future protocol versions may activate priority-based scheduling
- When activated, higher priority values = higher priority (0 = lowest)
WantList Fields:
entries: List of block requestsfull: If true, replaces all previous entries; if false, delta update
Delta Updates:
WantLists support delta updates for efficiency.
When full = false, entries represent additions or modifications to
the existing WantList rather than a complete replacement.
Block Delivery
Block deliveries contain the actual block data along with verification information:
message BlockDelivery {
bytes cid = 1;
bytes data = 2;
BlockAddress address = 3;
bytes proof = 4;
}
Fields:
cid: Content identifier of the blockdata: Raw block data (up to 100 MiB)address: The BlockAddress identifying this blockproof: Merkle proof (CodexProof) verifying block correctness (required for dataset blocks)
Merkle Proof Verification:
When delivering dataset blocks (address.leaf = true):
- The delivery MUST include a Merkle proof (CodexProof)
- The proof verifies that the block at the given index is correctly part of the Merkle tree identified by the tree CID
- This applies to all datasets, irrespective of whether they have been erasure-coded or not
- Recipients MUST verify the proof before accepting the block
- Invalid proofs result in block rejection
Block Presence
Block presence messages indicate whether a peer has or does not have a requested block:
enum BlockPresenceType {
presenceHave = 0;
presenceDontHave = 1;
}
message BlockPresence {
BlockAddress address = 1;
BlockPresenceType type = 2;
bytes price = 3;
}
Fields:
address: The block address being referencedtype: Whether the peer has the block or notprice: Price in wei (UInt256 format, see below)
UInt256 Price Format:
The price field encodes a 256-bit unsigned integer representing the cost in
wei (the smallest Ethereum denomination, where 1 ETH = 10^18 wei).
Encoding Specification:
- Format: 32 bytes, big-endian byte order
- Type: Unsigned 256-bit integer
- Range: 0 to 2^256 - 1
- Zero Price:
0x0000000000000000000000000000000000000000000000000000000000000000(block is free)
Examples:
Free (0 wei):
0x0000000000000000000000000000000000000000000000000000000000000000
1 wei:
0x0000000000000000000000000000000000000000000000000000000000000001
1 gwei (10^9 wei):
0x000000000000000000000000000000000000000000000000000000003b9aca00
0.001 ETH (10^15 wei):
0x00000000000000000000000000000000000000000000000000038d7ea4c68000
1 ETH (10^18 wei):
0x0000000000000000000000000000000000000000000000000de0b6b3a7640000
Maximum (2^256 - 1):
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
Conversion Logic:
# Wei to bytes (big-endian)
def wei_to_bytes(amount_wei: int) -> bytes:
return amount_wei.to_bytes(32, byteorder='big')
# Bytes to wei
def bytes_to_wei(price_bytes: bytes) -> int:
return int.from_bytes(price_bytes, byteorder='big')
# ETH to wei to bytes
def eth_to_price_bytes(amount_eth: float) -> bytes:
amount_wei = int(amount_eth * 10**18)
return wei_to_bytes(amount_wei)
Payment Messages
Payment-related messages for micropayments using Nitro state channels.
Account Message:
message AccountMessage {
bytes address = 1; // Ethereum address to which payments should be made
}
Fields:
address: Ethereum address for receiving payments
Concrete Message Examples
This section provides real-world examples of protobuf messages for different block exchange scenarios.
Example 1: Simple Standalone Block Request
Scenario: Request a single standalone block
Protobuf (wire format representation):
Message {
wantlist: Wantlist {
entries: [
Entry {
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 // CID bytes
}
priority: 0
cancel: false
wantType: wantBlock // 0
sendDontHave: true
}
]
full: true
}
}
Hex representation (sample):
0a2e 0a2c 0a24 0001 5512 2012 20b9 4d27
b993 4d3e 08a5 2e52 d7da 7dab fac4 84ef
e37a 5380 ee90 88f7 ace2 efcd e910 0018
0020 0028 011201 01
Example 2: Dataset Block Request
Scenario: Request block at index 100 from dataset
Protobuf:
Message {
wantlist: Wantlist {
entries: [
Entry {
address: BlockAddress {
leaf: true
treeCid: 0x0155a0e40220c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 // Tree CID
index: 100
}
priority: 0
cancel: false
wantType: wantBlock
sendDontHave: true
}
]
full: false // Delta update
}
}
Example 3: Block Delivery with Proof
Scenario: Provider sends dataset block with Merkle proof
Protobuf:
Message {
payload: [
BlockDelivery {
cid: 0x0155a0e40220a1b2c3d4e5f6071829... // Block CID
data: <65536 bytes of block data> // 64 KiB
address: BlockAddress {
leaf: true
treeCid: 0x0155a0e40220c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
index: 100
}
proof: <CodexProof bytes> // Merkle proof data
// Contains: path indices, sibling hashes, tree height
// Format: Implementation-specific (e.g., [height][index][hash1][hash2]...[hashN])
// Size varies by tree depth (illustrative: ~1KB for depth-10 tree)
}
]
}
Example 4: Block Presence Response
Scenario: Provider indicates block availability with price
Protobuf:
Message {
blockPresences: [
BlockPresence {
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
}
type: presenceHave // 0
price: 0x00000000000000000000000000000000000000000000000000038d7ea4c68000 // 0.001 ETH in wei
}
]
}
Example 5: Payment Message
Scenario: Send payment via state channel update
Protobuf:
Message {
account: AccountMessage {
address: 0x742d35Cc6634C0532925a3b844200a717C48D6d9 // 20 bytes Ethereum address
}
payment: StateChannelUpdate {
update: <JSON bytes>
// Contains signed Nitro state as UTF-8 JSON string
// Example: {"channelId":"0x1234...","nonce":42,...}
}
}
Example 6: Multiple Operations in One Message
Scenario: Combined WantList, BlockPresence, and Delivery
Protobuf:
Message {
wantlist: Wantlist {
entries: [
Entry {
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220... // Requesting new block
}
wantType: wantBlock
priority: 0
cancel: false
sendDontHave: true
}
]
full: false
}
blockPresences: [
BlockPresence {
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220... // Response to previous request
}
type: presenceHave
price: 0x00 // Free
}
]
payload: [
BlockDelivery {
cid: 0x0155a0e40220... // Delivering another block
data: <65536 bytes>
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220...
}
}
]
pendingBytes: 131072 // 128 KiB more data pending
}
Example 7: WantList Cancellation
Scenario: Cancel multiple pending requests
Protobuf:
Message {
wantlist: Wantlist {
entries: [
Entry {
address: BlockAddress {
leaf: false
cid: 0x0155a0e40220abc123...
}
cancel: true // Cancellation flag
},
Entry {
address: BlockAddress {
leaf: true
treeCid: 0x0155a0e40220def456...
index: 50
}
cancel: true
}
]
full: false
}
}
CID Format Details
CID Structure:
CID v1 format (multibase + multicodec + multihash):
[0x01] [0x55] [0xa0] [0xe4] [0x02] [0x20] [<32 bytes SHA256 hash>]
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ Hash digest
│ │ │ │ │ └───────── Hash length (32)
│ │ │ │ └──────────────── Hash algorithm (SHA2-256)
│ │ │ └─────────────────────── Codec size
│ │ └────────────────────────────── Codec (raw = 0x55)
│ └───────────────────────────────────── Multicodec prefix
└──────────────────────────────────────────── CID version (1)
Actual: 0x01 55 a0 e4 02 20 <hash bytes>
Example Block CID Breakdown:
Full CID: 0x0155a0e40220b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
Parts:
Version: 0x01 (CID v1)
Multicodec: 0x55 (raw)
Codec Size: 0xa0e402 (codex-block = 0xCD02, varint encoded)*
Hash Type: 0x20 (SHA2-256)
Hash Len: 0x12 (20) (32 bytes)
Hash: b94d27b993... (32 bytes SHA256)
State Channel Update:
message StateChannelUpdate {
bytes update = 1; // Signed Nitro state, serialized as JSON
}
Fields:
update: Nitro state channel update containing payment information
Payment Flow and Price Negotiation
The Block Exchange protocol integrates with Nitro state channels to enable micropayments for block delivery.
Payment Requirements
When Payment is Required:
- Blocks marked as paid content by the provider
- Provider's local policy requires payment for specific blocks
- Block size exceeds free tier threshold (implementation-defined)
- Requester has insufficient credit with provider
Free Blocks:
- Blocks explicitly marked as free (
price = 0x00) - Blocks exchanged between trusted peers
- Small metadata blocks (implementation-defined)
Price Discovery
Initial Price Advertisement:
- Requester sends WantList with
wantType = wantHave - Provider responds with BlockPresence including
pricefield - Price encoded as UInt256 in wei (smallest Ethereum unit)
- Requester evaluates price against local policy
Price Format:
price: bytes (32 bytes, big-endian UInt256)
Example: 0x0000000000000000000000000000000000000000000000000de0b6b3a7640000
represents 1 ETH = 10^18 wei
Payment Negotiation Process
Step 1: Price Quote
Requester → Provider: Message(wantlist: wantHave)
Provider → Requester: BlockPresence(type=presenceHave, price=<amount>)
Step 2: Payment Decision
Requester evaluates price:
- Accept: Proceed to payment
- Reject: Send cancellation
- Counter: Not supported in current protocol (future extension)
Step 3: State Channel Update
If accepted:
Requester:
1. Load existing state channel with Provider
2. Create new state with updated balance
3. Sign state update
4. Encode as JSON
Requester → Provider: Message(payment: StateChannelUpdate(update=<signed JSON>))
Step 4: Payment Verification
Provider:
1. Decode state channel update
2. Verify signatures
3. Check balance increase matches price
4. Verify state channel validity
5. Check nonce/sequence number
If valid:
Provider → Requester: BlockDelivery(data, proof)
Else:
Provider → Requester: BlockPresence(price) // Retry with correct payment
Step 5: Delivery and Finalization
Requester:
1. Receive and verify block
2. Store block locally
3. Finalize state channel update
4. Update peer credit balance
Payment State Machine
Note: The following state machine represents a design specification for payment flow logic. Actual implementation may differ.
State: INIT
→ Send wantHave
→ Transition to PRICE_DISCOVERY
State: PRICE_DISCOVERY
← Receive BlockPresence(price)
→ If price acceptable: Transition to PAYMENT_CREATION
→ If price rejected: Transition to CANCELLED
State: PAYMENT_CREATION
→ Create state channel update
→ Send payment message
→ Transition to PAYMENT_PENDING
State: PAYMENT_PENDING
← Receive BlockDelivery: Transition to DELIVERY_VERIFICATION
← Receive BlockPresence(price): Transition to PAYMENT_FAILED
State: PAYMENT_FAILED
→ Retry with corrected payment: Transition to PAYMENT_CREATION
→ Abort: Transition to CANCELLED
State: DELIVERY_VERIFICATION
→ Verify block
→ If valid: Transition to COMPLETED
→ If invalid: Transition to DISPUTE
State: COMPLETED
→ Finalize state channel
→ End
State: CANCELLED
→ Send cancellation
→ End
State: DISPUTE
→ Reject block
→ Dispute state channel update
→ End
State Channel Integration
Account Message Usage:
Sent early in connection to establish payment address:
Message {
account: AccountMessage {
address: 0x742d35Cc6634C0532925a3b8... // Ethereum address
}
}
State Channel Update Format:
{
"channelId": "0x1234...",
"nonce": 42,
"balances": {
"0x742d35Cc...": "1000000000000000000", // Seller balance
"0x8ab5d2F3...": "500000000000000000" // Buyer balance
},
"signatures": [
"0x789abc...", // Buyer signature
"0x456def..." // Seller signature
]
}
Error Scenarios
Insufficient Funds:
- State channel balance < block price
- Response: BlockPresence with price (retry after funding)
Invalid Signature:
- State update signature verification fails
- Response: Reject payment, close stream if repeated
Nonce Mismatch:
- State update nonce doesn't match expected sequence
- Response: Request state sync, retry with correct nonce
Channel Expired:
- State channel past expiration time
- Response: Refuse payment, request new channel creation
Error Handling
The Block Exchange protocol defines error handling for common failure scenarios:
Verification Failures
Merkle Proof Verification Failure:
- Condition: CodexProof validation fails for dataset block
- Action: Reject block delivery, do NOT store block
- Response: Send BlockPresence with
presenceDontHavefor the address - Logging: Log verification failure with peer ID and block address
- Peer Management: Track repeated failures; disconnect after threshold
CID Mismatch:
- Condition: SHA256 hash of block data doesn't match provided CID
- Action: Reject block delivery immediately
- Response: Close stream and mark peer as potentially malicious
- Logging: Log CID mismatch with peer ID and expected/actual CIDs
Network Failures
Stream Disconnection:
- Condition: libp2p stream closes unexpectedly during transfer
- Action: Cancel pending block requests for that peer
- Recovery: Attempt to request blocks from alternative peers
- Timeout: Wait for stream timeout (60s) before peer cleanup
Missing Blocks:
- Condition: Peer responds with
presenceDontHavefor requested block - Action: Remove peer from candidates for this block
- Recovery: Query discovery service for alternative peers
- Fallback: If no peers have block, return error to
requestBlockcaller
Request Timeout:
- Condition: Block not received within request timeout (300s)
- Action: Cancel request with that peer
- Recovery: Retry with different peer if available
- User Notification: If all retry attempts exhausted,
requestBlockreturns timeout error
Protocol Violations
Oversized Messages:
- Condition: Message exceeds maximum size limits
- Action: Close stream immediately
- Peer Management: Mark peer as non-compliant
- No Response: Do not send error message (message may be malicious)
Invalid WantList:
- Condition: WantList exceeds entry limit or contains malformed addresses
- Action: Ignore malformed entries, process valid ones
- Response: Continue processing stream
- Logging: Log validation errors for debugging
Payment Failures:
- Condition: State channel update invalid or payment insufficient
- Action: Do not deliver blocks requiring payment
- Response: Send BlockPresence with price indicating payment needed
- Stream: Keep stream open for payment retry
Recovery Strategies
Retry Responsibility Model
The protocol defines a clear separation between system-level and caller-level retry responsibilities:
System-Level Retry (Automatic):
The Block Exchange module automatically retries in these scenarios:
- Peer failure: If a peer disconnects or times out, the system transparently tries alternative peers from the discovery set
- Transient errors: Network glitches, temporary unavailability
- Peer rotation: Automatic failover to next available peer
The caller's requestBlock call remains pending during system-level retries.
This is transparent to the caller.
Caller-Level Retry (Manual):
The caller is responsible for retry decisions when:
- All peers exhausted: No more peers available from discovery
- Permanent failures: Block doesn't exist in the network
- Timeout exceeded: Request timeout (300s) expired
- Verification failures: All peers provided invalid data
In these cases, requestBlock returns an error and the caller decides
whether to retry, perhaps after waiting or refreshing the peer list
via discovery.
Retry Flow:
requestBlock(address)
│
├─► System tries Peer A ──► Fails
│ │
│ └─► System tries Peer B ──► Fails (automatic, transparent)
│ │
│ └─► System tries Peer C ──► Success ──► Return block
│
└─► All peers failed ──► Return error to caller
│
└─► Caller decides: retry? wait? abort?
Peer Rotation:
When a peer fails to deliver blocks:
- Mark peer as temporarily unavailable for this block
- Query discovery service for alternative peers
- Send WantList to new peers
- Implement exponential backoff before retrying failed peer
Graceful Degradation:
- If verification fails, request block from alternative peer
- If all peers fail, propagate error to caller
- Clean up resources (memory, pending requests) on unrecoverable failures
Error Propagation:
- Service interface functions (
requestBlock,cancelRequest) return errors to callers only after system-level retries are exhausted - Internal errors logged for debugging
- Network errors trigger automatic peer rotation before surfacing to caller
- Verification errors result in block rejection and peer reputation impact
Security Considerations
Block Verification
- All dataset blocks MUST include and verify Merkle proofs before acceptance
- Standalone blocks MUST verify CID matches the SHA256 hash of the data
- Peers SHOULD reject blocks that fail verification immediately
DoS Protection
- Implementations SHOULD limit the number of concurrent block requests per peer
- Implementations SHOULD implement rate limiting for WantList updates
- Large WantLists MAY be rejected to prevent resource exhaustion
Data Integrity
- All blocks MUST be validated before being stored or forwarded
- Zero-padding in dataset blocks MUST be verified to prevent data corruption
- Block sizes MUST be validated against protocol limits
Privacy Considerations
- Block requests reveal information about what data a peer is seeking
- Implementations MAY implement request obfuscation strategies
- Presence information can leak storage capacity details
Rationale
Design Decisions
Two-Tier Block Addressing: The protocol supports both standalone and dataset blocks to accommodate different use cases. Standalone blocks are simpler and don't require Merkle proofs, while dataset blocks enable efficient verification of large datasets without requiring the entire dataset.
WantList Delta Updates: Supporting delta updates reduces bandwidth consumption when peers only need to modify a small portion of their wants, which is common in long-lived connections.
Separate Presence Messages: Decoupling presence information from block delivery allows peers to quickly assess availability without waiting for full block transfers.
Fixed Block Size: The 64 KiB default block size balances efficient network transmission with manageable memory overhead.
Zero-Padding Requirement: Requiring zero-padding for incomplete dataset blocks ensures uniform block sizes within datasets, simplifying Merkle tree construction and verification.
Protocol Buffers: Using Protocol Buffers provides efficient serialization, forward compatibility, and wide language support.
Copyright
Copyright and related rights waived via CC0.
References
Normative
- RFC 2119 - Key words for use in LIPs to Indicate Requirement Levels
- libp2p: https://libp2p.io
- Protocol Buffers: https://protobuf.dev
- Multihash: https://multiformats.io/multihash/
- Multicodec: https://github.com/multiformats/multicodec
Informative
- Codex Documentation: https://docs.codex.storage
- Codex Block Exchange Module Spec: https://github.com/codex-storage/codex-docs-obsidian/blob/main/10%20Notes/Specs/Block%20Exchange%20Module%20Spec.md
- Merkle Trees: https://en.wikipedia.org/wiki/Merkle_tree
- Content Addressing: https://en.wikipedia.org/wiki/Content-addressable_storage
CODEX-COMMUNITY-HISTORY
| Field | Value |
|---|---|
| Name | Codex Community History |
| Slug | 76 |
| Status | raw |
| Contributors | Jimmy Debe [email protected] |
Timeline
- 2026-01-30 —
d5a9240— chore: removed archived (#283) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document describes how nodes in Status Communities archive historical message data of their communities. Not requiring to follow the time range limit provided by 13/WAKU2-STORE nodes using the BitTorrent protocol. It also describes how the archives are distributed to community members via the Status network, so they can fetch them and gain access to a complete message history.
Background
Messages are stored permanently by 13/WAKU2-STORE nodes for a configurable time range, which is limited by the overall storage provided by a 13/WAKU2-STORE nodes. Messages older than that period are no longer provided by 13/WAKU2-STORE nodes, making it impossible for other nodes to request historical messages that go beyond that time range. This raises issues in the case of Status communities, where recently joined members of a community are not able to request complete message histories of the community channels.
Terminology
| Name | Description |
|---|---|
| Waku node | A 10/WAKU2 node that implements 11/WAKU2-RELAY |
| Store node | A 10/WAKU2 node that implements 13/WAKU2-STORE |
| Waku network | A group of 10/WAKU2 nodes forming a graph, connected via 11/WAKU2-RELAY |
| Status user | A Status account that is used in a Status consumer product, such as Status Mobile or Status Desktop |
| Status node | A Status client run by a Status application |
| Control node | A Status node that owns the private key for a Status community |
| Community member | A Status user that is part of a Status community, not owning the private key of the community |
| Community member node | A Status node with message archive capabilities enabled, run by a community member |
| Live messages | 14/WAKU2-MESSAGE received through the Waku network |
| BitTorrent client | A program implementing the BitTorrent protocol |
| Torrent/Torrent file | A file containing metadata about data to be downloaded by BitTorrent clients |
| Magnet link | A link encoding the metadata provided by a torrent file (Magnet URI scheme) |
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Message History Archive
Message history archives are represented as WakuMessageArchive and
created from a 14/WAKU2-MESSAGE exported from the local database.
The following describes the protocol buffer for WakuMessageArchive :
syntax = "proto3";
message WakuMessageArchiveMetadata {
uint8 version = 1;
uint64 from = 2;
uint64 to = 3;
repeated string content_Topic = 4;
}
message WakuMessageArchive {
uint8 version = 1;
WakuMessageArchiveMetadata metadata = 2;
repeated WakuMessage messages = 3; // `WakuMessage` is provided by 14/WAKU2-MESSAGE
bytes padding = 4;
}
The from field SHOULD contain a timestamp of the time range's lower bound.
This type parallels to the timestamp of a WakuMessage.
The to field SHOULD contain a timestamp of the time range's the higher bound.
The contentTopic field MUST contain a list of all community channel contentTopics.
The messages field MUST contain all messages that belong in the archive, given its from,
to, and contentTopic fields.
The padding field MUST contain the amount of zero bytes needed for the protobuf encoded WakuMessageArchive.
The overall byte size MUST be a multiple of the pieceLength used to divide the data into pieces.
This is needed for seamless encoding and
decoding of archival data when interacting with BitTorrent,
as explained in creating message archive torrents.
Message History Archive Index
Control nodes MUST provide message archives for the entire community history.
The entire history consists of a set of WakuMessageArchive,
where each archive contains a subset of historical WakuMessage for a time range of seven days.
All the WakuMessageArchive are concatenated into a single file as a byte string, see Ensuring reproducible data pieces.
Control nodes MUST create a message history archive index,
WakuMessageArchiveIndex with metadata,
that allows receiving nodes to only fetch the message history archives they are interested in.
WakuMessageArchiveIndex
syntax = "proto3"
message WakuMessageArchiveIndexMetadata {
uint8 version = 1
WakuMessageArchiveMetadata metadata = 2
uint64 offset = 3
uint64 num_pieces = 4
}
message WakuMessageArchiveIndex {
map<string, WakuMessageArchiveIndexMetadata> archives = 1
}
A WakuMessageArchiveIndex is a map where the key is the KECCAK-256 hash of the WakuMessageArchiveIndexMetadata,
is derived from a 7-day archive
and the value is an instance of that WakuMessageArchiveIndexMetadata corresponding to that archive.
The offset field MUST contain the position at which the message history archive starts in the byte string
of the total message archive data.
This MUST be the sum of the length of all previously created message archives in bytes, see creating message archive torrents.
The control node MUST update the WakuMessageArchiveIndex every time it creates one or
more WakuMessageArchives and bundle it into a new torrent.
For every created WakuMessageArchive,
there MUST be a WakuMessageArchiveIndexMetadata entry in the archives field WakuMessageArchiveIndex.
Creating Message Archive Torrents
Control nodes MUST create a .torrent file containing metadata for all message history archives. To create a .torrent file, and later serve the message archive data on the BitTorrent network, control nodes MUST store the necessary data in dedicated files on the file system.
A torrent's source folder MUST contain the following two files:
data: Contains all protobuf encodedWakuMessageArchive's (as bit strings) concatenated in ascending order based on their timeindex: Contains the protobuf encodedWakuMessageArchiveIndex
Control nodes SHOULD store these files in a dedicated folder that is identifiable via a community identifier.
Ensuring Reproducible Data Pieces
The control node MUST ensure that the byte string from the protobuf encoded data
is equal to the byte string data from the previously generated message archive torrent.
Including the data of the latest seven days worth of messages encoded as WakuMessageArchive.
Therefore, the size of data grows every seven days as it's append-only.
Control nodes MUST ensure that the byte size,
for every individual WakuMessageArchive encoded protobuf,
is a multiple of pieceLength using the padding field.
If the WakuMessageArchive is not a multiple of pieceLength,
its padding field MUST be filled with zero bytes and
the WakuMessageArchive MUST be re-encoded until its size becomes a multiple of pieceLength.
This is necessary because the content of the data file will be split into pieces of pieceLength when the torrent file is created,
and the SHA1 hash of every piece is then stored in the torrent file and
later used by other nodes to request the data for each individual data piece.
By fitting message archives into a multiple of pieceLength and
ensuring they fill the possible remaining space with zero bytes,
control nodes prevent the next message archive from occupying that remaining space of the last piece,
which will result in a different SHA1 hash for that piece.
Example: Without padding
Let WakuMessageArchive "A1" be of size 20 bytes:
0 11 22 33 44 55 66 77 88 99
10 11 12 13 14 15 16 17 18 19
With a pieceLength of 10 bytes, A1 will fit into 20 / 10 = 2 pieces:
0 11 22 33 44 55 66 77 88 99 // piece[0] SHA1: 0x123
10 11 12 13 14 15 16 17 18 19 // piece[1] SHA1: 0x456
Example: With padding
Let WakuMessageArchive "A2" be of size 21 bytes:
0 11 22 33 44 55 66 77 88 99
10 11 12 13 14 15 16 17 18 19
20
With a pieceLength of 10 bytes,
A2 will fit into 21 / 10 = 2 pieces.
The remainder will introduce a third piece:
0 11 22 33 44 55 66 77 88 99 // piece[0] SHA1: 0x123
10 11 12 13 14 15 16 17 18 19 // piece[1] SHA1: 0x456
20 // piece[2] SHA1: 0x789
The next WakuMessageArchive "A3" will be appended ("#3") to the existing data and
occupy the remaining space of the third data piece.
The piece at index 2 will now produce a different SHA1 hash:
0 11 22 33 44 55 66 77 88 99 // piece[0] SHA1: 0x123
10 11 12 13 14 15 16 17 18 19 // piece[1] SHA1: 0x456
20 #3 #3 #3 #3 #3 #3 #3 #3 #3 // piece[2] SHA1: 0xeef
#3 #3 #3 #3 #3 #3 #3 #3 #3 #3 // piece[3]
By filling up the remaining space of the third piece with A2 using its padding field, it is guaranteed that its SHA1 will stay the same:
0 11 22 33 44 55 66 77 88 99 // piece[0] SHA1: 0x123
10 11 12 13 14 15 16 17 18 19 // piece[1] SHA1: 0x456
20 0 0 0 0 0 0 0 0 0 // piece[2] SHA1: 0x999
#3 #3 #3 #3 #3 #3 #3 #3 #3 #3 // piece[3]
#3 #3 #3 #3 #3 #3 #3 #3 #3 #3 // piece[4]
Seeding Message History Archives
The control node MUST seed the generated torrent until a new WakuMessageArchive is created.
The control node SHOULD NOT seed torrents for older message history archives. Only one torrent at a time SHOULD be seeded.
Creating Magnet Links
Once a torrent file for all message archives is created, the control node MUST derive a magnet link, following the Magnet URI scheme using the underlying BitTorrent protocol client.
Message Archive Distribution
Message archives are available via the BitTorrent network as they are being seeded by the control node. Other community member nodes will download the message archives, from the BitTorrent network, after receiving a magnet link that contains a message archive index.
The control node MUST send magnet links containing message archives and
the message archive index to a special community channel.
The content_Topic of that special channel follows the following format:
/{application-name}/{version-of-the-application}/{content-topic-name}/{encoding}
All messages sent with this special channel's content_Topic MUST be instances of ApplicationMetadataMessage,
with a 62/STATUS-PAYLOADS of CommunityMessageArchiveIndex.
Only the control node MAY post to the special channel. Other messages on this specified channel MUST be ignored by clients. Community members MUST NOT have permission to send messages to the special channel. However, community member nodes MUST subscribe to a special channel, to receive a 14/WAKU2-MESSAGE containing magnet links for message archives.
Canonical Message Histories
Only control nodes are allowed to distribute messages with magnet links, via the special channel for magnet link exchange. Status nodes MUST ignore all messages in the special channel that aren't signed by a control node. Since the magnet links are created from the control node's database (and previously distributed archives), the message history provided by the control node becomes the canonical message history and single source of truth for the community.
Community member nodes MUST replace messages in their local database with the messages extracted from archives within the same time range. Messages that the control node didn't receive MUST be removed and are no longer part of the message history of interest, even if it already existed in a community member node's database.
Fetching Message History Archives
The process of fetching message history:
- Receive message archive index magnet link as described in Message archive distribution,
- Download the index file from the torrent, then determine which message archives to download
- Download individual archives
Community member nodes subscribe to the special channel of the control nodes that publish magnet links for message history archives. Two RECOMMENDED scenarios in which community member nodes can receive such a magnet link message from the special channel:
- The member node receives it via live messages, by listening to the special channel.
- The member node requests messages for a time range of up to 30 days from store nodes (this is the case when a new community member joins a community.)
- Downloading message archives
When community member nodes receive a message with a CommunityMessageHistoryArchive 62/STATUS-PAYLOADS,
they MUST extract the magnet_uri.
Then SHOULD pass it to their underlying BitTorrent client to fetch the latest message history archive index,
which is the index file of the torrent, see [Creating message archive torrents].
Due to the nature of distributed systems,
there's no guarantee that a received message is the "last" message.
This is especially true when community member nodes request historical messages from store nodes.
Therefore, community member nodes MUST wait for 20 seconds after receiving the last CommunityMessageArchive,
before they start extracting the magnet link to fetch the latest archive index.
Once a message history archive index is downloaded and
parsed back into WakuMessageArchiveIndex,
community member nodes use a local lookup table to determine which of the listed archives are missing,
using the KECCAK-256 hashes stored in the index.
For this lookup to work,
member nodes MUST store the KECCAK-256 hashes,
of the WakuMessageArchiveIndexMetadata provided by the index file,
for all of the message history archives that have been downloaded into their local database.
Given a WakuMessageArchiveIndex, member nodes can access individual WakuMessageArchiveIndexMetadata to download individual archives.
Community member nodes MUST choose one of the following options:
- Download all archives: Request and download all data pieces for the data provided by the torrent (this is the case for new community member nodes that haven't downloaded any archives yet.)
- Download only the latest archive: Request and
download all pieces starting at the offset of the latest
WakuMessageArchiveIndexMetadata(this is the case for any member node that already has downloaded all previous history and is now interested in only the latest archive). - Download specific archives: Look into from and
to fields of every
WakuMessageArchiveIndexMetadataand determine the pieces for archives of a specific time range (can be the case for member nodes that have recently joined the network and are only interested in a subset of the complete history).
Storing Historical Messages
When message archives are fetched,
community member nodes MUST unwrap the resulting WakuMessage instances into ApplicationMetadataMessage instances
and store them in their local database.
Community member nodes SHOULD NOT store the wrapped WakuMessage messages.
All messages within the same time range MUST be replaced with the messages provided by the message history archive.
Community members' nodes MUST ignore the expiration state of each archive message.
Security Considerations
Multiple Community Owners
It is possible for control nodes to export the private key of their owned community and pass it to other users so they become control nodes as well. This means it's possible for multiple control nodes to exist for one community.
This might conflict with the assumption that the control node serves as a single source of truth. Multiple control nodes can have different message histories. Not only will multiple control nodes multiply the amount of archive index messages being distributed to the network, but they might also contain different sets of magnet links and their corresponding hashes. Even if just a single message is missing in one of the histories, the hashes presented in the archive indices will look completely different, resulting in the community member node downloading the corresponding archive. This might be identical to an archive that was already downloaded, except for that one message.
Copyright
Copyright and related rights waived via CC0.
References
- 13/WAKU2-STORE
- BitTorrent protocol
- Status network
- 10/WAKU2
- 11/WAKU2-RELAY
- 14/WAKU2-MESSAGE
- 62/STATUS-PAYLOADS
CODEX-DHT
| Field | Value |
|---|---|
| Name | Codex Discovery |
| Slug | 75 |
| Status | raw |
| Contributors | Jimmy Debe [email protected], Giuliano Mega [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document explains the Codex DHT (Distributed Hash Table) component. The DHT maps content IDs (CIDs) into providers of that content.
Background and Overview
Codex is a network of nodes, identified as providers, participating in a decentralized peer-to-peer storage protocol. The decentralized storage solution offers data durability guarantees, incentive mechanisms and data persistence guarantees.
The Codex DHT is the service responsible for helping providers find other peers hosting both dataset and standalone blocks1 in the Codex network. It maps content IDs -- which identify blocks and datasets -- into lists of providers -- which identify and provide the information required to connect to those providers.
The Codex DHT is a modified version of discv5, with the following differences:
- it uses libp2p SPRs instead of Ethereum's ENRs to identify peers and convey connection information;
- it extends the DHT message interface with
GET_PROVIDERS/ADD_PROVIDERrequests for managing provider lists; - it replaces discv5 packet encoding with protobuf.
The Codex DHT is, indeed, closer to the libp2p DHT than to discv5 in terms of what it provides. Historically, this is because the Nim version of libp2p did not implement the Kad DHT spec at the time, so project builders opted to adapt the nim-eth Kademlia-based discv5 DHT instead.
A Codex provider will support this protocol at no extra cost other than the use of resources to store node records, and the bandwidth to serve queries and process data advertisements. As it is usually the case with DHTs, any publicly reachable node running the DHT protocol can be used as a bootstrap node into the Codex network.
Service Interface
The two core primitives provided by the Codex DHT on top of discv5 are:
def addProvider(cid: NodeId, provider: SignedPeerRecord)
def getProviders(cid: NodeId): List[SignedPeerRecord]
where NodeId is a 256-bit string, obtained from the keccak256 hash function of the node's public key, the same used to sign peer records.
By convention, we convert from libp2p CIDs to NodeId by taking the keccak256 hash of the CID's contents. For reference, the Nim implementation of this conversion looks like:
proc toNodeId*(cid: Cid): NodeId =
## Cid to discovery id
##
readUintBE[256](keccak256.digest(cid.data.buffer).data)
Wire Format
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
As in discv5, all messages in the Codex DHT MUST be encoded with a request_id, which SHALL be serialized before the actual message data, as per the message envelope below:
message MessageEnvelope {
bytes request_id = 1; // RequestId (max 8 bytes)
bytes message_data = 2; // Encoded specific message
}
Signed peer records are simply libp2p peer records wrapped in a signed envelope and MUST be serialized according to the libp2p protobuf wire formats.
Providers MUST2 support the standard discv5 messages, with the following additions:
ADD_PROVIDER request (0x0B)
message AddProviderMessage {
bytes content_id = 1; // NodeId - 32 bytes, big-endian
Envelope signed_peer_record = 2;
}
Registers the peer in signed_peer_record as a provider of the content identified by content_id.
GET_PROVIDERS request (0x0C)
message GetProvidersMessage {
bytes content_id = 1; // NodeId - 32 bytes, big-endian
}
Requests the list of providers of the content identified by content_id.
PROVIDERS response (0x0D)
message ProvidersMessage {
uint32 total = 1;
repeated Envelope signed_peer_records = 2;
}
Returns the list of known providers of the content identified by content_id. total is currently always set to $1$.
Copyright
Copyright and related rights waived via CC0.
References
This is actually stronger than necessary, but we'll refine it over time.
This should link to the block exchange spec once it's done.
CODEX-MANIFEST
| Field | Value |
|---|---|
| Name | Codex Manifest |
| Slug | 145 |
| Status | raw |
| Category | Standards Track |
| Tags | codex, manifest, metadata, cid |
| Editor | Jimmy Debe [email protected] |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-30 —
0ef87b1— New RFC: CODEX-MANIFEST (#191)
Abstract
This specification defines the Codex Manifest, a metadata structure that describes datasets stored on the Codex network. The manifest contains essential information such as the Merkle tree root CID, block size, dataset size, and optional attributes like filename and MIME type. Similar to BitTorrent's metainfo files, the Codex Manifest enables content identification and retrieval but is itself content-addressed and announced on the Codex DHT.
Keywords: manifest, metadata, CID, Merkle tree, content addressing, BitTorrent, DHT, protobuf
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Definitions
| Term | Description |
|---|---|
| Manifest | A metadata structure describing a dataset stored on the Codex network. |
| CID | Content Identifier, a self-describing content-addressed identifier used in IPFS and Codex. |
| Codex Tree | A Merkle tree structure computed over the blocks in a dataset. See CODEX-MERKLE-TREE. |
| treeCid | The CID of the root of the Codex Tree corresponding to a dataset. |
| Block | A fixed-size chunk of data in the dataset. |
| Multicodec | A self-describing protocol identifier from the Multicodec table. |
Background
The Codex Manifest provides the description of the metadata uploaded to the Codex network. It is in many ways similar to the BitTorrent metainfo file, also known as .torrent files. For more information, see BEP3 from BitTorrent Enhancement Proposals (BEPs). While the BitTorrent metainfo files are generally distributed out-of-band, the Codex Manifest receives its own content identifier (CIDv1) that is announced on the Codex DHT. See the CODEX-DHT specification for more details.
In version 1 of the BitTorrent protocol,
when a user wants to upload (seed) some content to the BitTorrent network,
the client chunks the content into pieces.
For each piece, a hash is computed and
included in the pieces attribute of the info dictionary in the BitTorrent metainfo file.
In Codex,
instead of hashes of individual pieces,
a Merkle tree is computed over the blocks in the dataset.
The CID of the root of this Merkle tree is included as the treeCid attribute in the Codex Manifest.
See CODEX-MERKLE-TREE for more information.
Version 2 of the BitTorrent protocol also uses Merkle trees and
includes the root of the tree in the info dictionary for each .torrent file.
The Codex Manifest CID has the ability to uniquely identify the content and enables retrieval of that content from any Codex client.
Protocol Specification
Manifest Encoding
The manifest is encoded using Protocol Buffers (proto3) and serialized with multibase base58btc encoding. Each manifest has a corresponding CID (the manifest CID).
Manifest Attributes
syntax = "proto3";
message Manifest {
optional bytes treeCid = 1; // CID (root) of the tree
optional uint32 blockSize = 2; // Size of a single block
optional uint64 datasetSize = 3; // Size of the dataset
optional uint32 codec = 4; // Dataset codec
optional uint32 hcodec = 5; // Multihash codec
optional uint32 version = 6; // CID version
optional string filename = 7; // Original filename
optional string mimetype = 8; // Original mimetype
}
| Attribute | Type | Description |
|---|---|---|
treeCid | bytes | A hash based on CIDv1 of the root of the Codex Tree, which is a form of a Merkle tree corresponding to the dataset described by the manifest. Its multicodec is codex-root (0xCD03). |
blockSize | uint32 | The size of each block for the given dataset. The default block size used in Codex is 64 KiB. |
datasetSize | uint64 | The total size of all blocks for the original dataset. |
codec | uint32 | The Multicodec used for the CIDs of the dataset blocks. Codex uses codex-block (0xCD02). |
hcodec | uint32 | The Multicodec used for computing the multihash used in block CIDs. Codex uses sha2-256 (0x12). |
version | uint32 | The version of CID used for the dataset blocks. |
filename | string | When provided, it MAY be used by the client as a file name while downloading the content. |
mimetype | string | When provided, it MAY be used by the client to set a content type of the downloaded content. |
DHT Announcement
The manifest CID SHOULD be announced on the CODEX-DHT, so that nodes storing the corresponding manifest block can be found by other clients requesting to download the corresponding dataset.
From the manifest,
providers storing relevant blocks SHOULD be identified using the treeCid attribute.
The manifest CID in Codex is similar to the info_hash from BitTorrent.
Security Considerations
Content Integrity
The treeCid attribute provides cryptographic binding between the manifest and the dataset content.
Implementations MUST verify that retrieved blocks match the expected hashes
derived from the Merkle tree root.
Manifest Authenticity
The manifest CID provides content addressing, ensuring that any modification to the manifest will result in a different CID. Implementations SHOULD verify manifest integrity by recomputing the CID from the received manifest data.
References
Normative
- CODEX-MERKLE-TREE - Codex Merkle tree specification
- CODEX-DHT - Codex DHT specification
Informative
- BEP3 - The BitTorrent Protocol Specification
- CIDv1 - Content Identifier version 1 specification
- Multicodec - Self-describing protocol identifiers
- Codex Manifest Spec - Original specification
Copyright
Copyright and related rights waived via CC0.
CODEX-MERKLE-TREE
| Field | Value |
|---|---|
| Name | Codex Merkle Tree |
| Slug | 82 |
| Status | raw |
| Category | Standards Track |
| Editor | Codex Team |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Merkle tree implementation for Codex. The purpose of this component is to deal with Merkle trees (and Merkle trees only; except that certain arithmetic hashes constructed via the sponge construction use the same encoding standards).
Background / Rationale / Motivation
Merkle trees and Merkle tree roots are used for:
- content addressing (via the Merkle root hash)
- data authenticity (via a Merkle path from a block to the root)
- remote auditing (via Merkle proofs of pieces)
Merkle trees can be implemented in quite a few different ways, and if naively implemented, can be also attacked in several ways.
Some possible attacks:
- Data encoding attacks: occur when different byte sequences can be encoded to the same target type, potentially creating collisions
- Padding attacks: occur when different data can be padded to produce identical hashes
- Layer abusing attacks: occur when nodes from different layers can be substituted or confused with each other
These all can create root hash collisions for example.
Hence, a concrete implementation is specified which should be safe from these attacks through:
- Injective encoding: The
10*padding strategy ensures that different byte sequences always encode to different target types - Keyed compression: Using distinct keys for different node types (even/odd, bottom/other layers) prevents node substitution attacks
- Deterministic construction: The layer-by-layer construction with explicit handling of singleton nodes ensures consistent tree building
The specification supports multiple hash function types (SHA256, Poseidon2, Goldilocks) while maintaining these security properties across all implementations.
Storing Merkle trees on disc is out of scope here (but should be straightforward, as serialization of trees should be included in the component).
Theory / Semantics
Vocabulary
A Merkle tree, built on a hash function H,
produces a Merkle root of type T ("Target type").
This is usually the same type as the output of the hash function
(this is assumed below).
Some examples:
- SHA1:
Tis 160 bits - SHA256:
Tis 256 bits - Keccak (SHA3):
Tcan be one of 224, 256, 384 or 512 bits - Poseidon:
Tis one or more finite field element(s) (based on the field size) - Monolith:
Tis 4 Goldilocks field elements
The hash function H can also have different types S ("Source type") of inputs.
For example:
- SHA1 / SHA256 / SHA3:
Sis an arbitrary sequence of bits - some less-conforming implementation of these could take a sequence of bytes instead (but that's often enough in practice)
- binary compression function:
Sis a pair ofT-s - Poseidon:
Sis a sequence of finite field elements - Poseidon2 compression function:
Sis at mostt-kfield elements, wherekfield elements should be approximately 256 bits (in our caset=3,k=1for BN254 field; ort=12,k=4for the Goldilocks field; ort=24,k=8for a ~32 bit field) - as an alternative,
the "Jive compression mode" for binary compression
can eliminate the "minus
k" requirement (you can compresstintot/2) - A naive Merkle tree implementation could for example
accept only a power-of-two sized sequence of
T
Notation: Let's denote a sequence of T-s by [T];
and an array of T-s of length l by T[l].
Data Models
H, the set of supported hash functions, is an enumerationS := Source[H]andT := Target[H]MerklePath[H]is a record, consisting ofpath: a sequence ofT-sindex: a linear index (int)leaf: the leaf being proved (aT)size: the number of elements from which the tree was created
MerkleTree[H]: a binary tree ofT-s; alternatively a sequence of sequences ofT-s
Tree Construction
We want to avoid the following kind of attacks:
- padding attacks
- layer abusing attacks
Hence, instead of using a single compression functions, a keyed compression function is used, which is keyed by two bits:
- whether the new parent node is an even or odd node (that is, has 2 or 1 children; alternatively, whether compressing 2 or 1 nodes)
- whether it's the bottom (the widest, initial) layer or not
This information is converted to a number 0 <= key < 4
by the following algorithm:
data LayerFlag
= BottomLayer -- ^ it's the bottom (initial, widest) layer
| OtherLayer -- ^ it's not the bottom layer
data NodeParity
= EvenNode -- ^ it has 2 children
| OddNode -- ^ it has 1 child
-- | Key based on the node type:
--
-- > bit0 := 1 if bottom layer, 0 otherwise
-- > bit1 := 1 if odd, 0 if even
--
nodeKey :: LayerFlag -> NodeParity -> Int
nodeKey OtherLayer EvenNode = 0x00
nodeKey BottomLayer EvenNode = 0x01
nodeKey OtherLayer OddNode = 0x02
nodeKey BottomLayer OddNode = 0x03
This number is used to key the compression function (essentially, 4 completely different compression functions).
When the hash function is a finite field sponge based,
like Poseidon2 or Monolith,
the following construction is used:
The permutation function is applied to (x,y,key),
and the first component of the result is taken.
When the hash function is something like SHA256,
the following is done: SHA256(key|x|y) (here key is encoded as a byte).
SHA256 implementation uses bearssl.
Remark: Since standard SHA256 includes padding, adding a key at the beginning doesn't result in extra computation (it's always two internal hash calls). However, a faster (twice as fast) alternative would be to choose 4 different random-looking initialization vectors, and not do padding. This would be a non-standard SHA256 invocation.
Finally, the process proceeds from the initial sequence in layers:
Take the previous sequence,
apply the keyed compression function for each consecutive pairs (x,y) : (T,T)
with the correct key:
based on whether this was the initial (bottom) layer,
and whether it's a singleton "pair" x : T,
in which case it's also padded with a zero to (x,0).
Note: If the input was a singleton list [x],
one layer is still applied,
so in that case the root will be compress[key=3](x,0).
Encoding and (De)serialization from/to Bytes
This has to be done very carefully to avoid potential attacks.
Note: This is a rather special situation,
in that encoding and serialization are NOT THE INVERSE OF EACH OTHER.
The reason for this is that they have different purposes:
In case of encoding from bytes to T-s,
it MUST BE injective to avoid trivial collision attacks;
while when serializing from T-s to bytes,
it needs to be invertible
(so that what is stored on disk can be loaded back;
in this sense this is really 1+2 = 3 algorithms).
The two can coincide when T is just a byte sequence like in SHA256,
but not when T consists of prime field elements.
Remark: The same encoding of sequence of bytes to sequence of T-s
is used for the sponge hash construction, when applicable.
Encoding into a Single T
For any T = Target[H],
fix a size M and an injective encoding byte[M] -> T.
For SHA256 etc, this will be standard encoding (big-endian; M=32).
For the BN254 field (T is 1 field element),
M=31, and the 31 bytes interpreted as a little-endian integer modulo p.
For the Goldilocks field,
there are some choices:
M=4*7=28 can be used, as a single field element can encode 7 bytes but not 8.
Or, for more efficiency,
M=31 can still be achieved by storing 62 bits in each field element.
For this some convention needs to be chosen;
the implementation is the following:
#define MASK 0x3fffffffffffffffULL
// NOTE: we assume a little-endian architecture here
void goldilocks_convert_31_bytes_to_4_field_elements(const uint8_t *ptr, uint64_t *felts) {
const uint64_t *q0 = (const uint64_t*)(ptr );
const uint64_t *q7 = (const uint64_t*)(ptr+ 7);
const uint64_t *q15 = (const uint64_t*)(ptr+15);
const uint64_t *q23 = (const uint64_t*)(ptr+23);
felts[0] = (q0 [0]) & MASK;
felts[1] = ((q7 [0]) >> 6) | ((uint64_t)(ptr[15] & 0x0f) << 58);
felts[2] = ((q15[0]) >> 4) | ((uint64_t)(ptr[23] & 0x03) << 60);
felts[3] = ((q23[0]) >> 2);
}
This simply chunks the 31 bytes = 248 bits into 62 bits chunks, and interprets them as little endian 62 bit integers.
Encoding from a Sequence of Bytes
First, the byte sequence is padded with the 10* padding strategy
to a multiple of M bytes.
This means that a 0x01 byte is always added,
and then as many 0x00 bytes as required for the length to be divisible by M.
If the input was l bytes,
then the padded sequence will have M*(floor(l/M)+1) bytes.
Note: the 10* padding strategy is an invertible operation,
which will ensure that there is no collision
between sequences of different length.
This padded byte sequence is then chunked to pieces of M bytes
(so there will be floor(l/M)+1 chunks),
and the above fixed byte[M] -> T is applied for each chunk,
resulting in the same number of T-s.
Remark: The sequence of T-s is not padded
when constructing the Merkle tree
(as the tree construction ensures
that different lengths will result in different root hashes).
However, when using the sponge construction,
the sequence of T-s needs to be further padded
to be a multiple of the sponge rate;
there again the 10* strategy is applied,
but there the 1 and 0 are finite field elements.
Serializing / Deserializing
When using SHA256 or similar, this is trivial (use the standard, big-endian encoding).
When T consists of prime field elements,
simply take the smallest number of bytes the field fits in
(usually 256, 64 or 32 bits, that is 32, 8 or 4 bytes),
and encode as a little-endian integer (mod the prime).
This is obvious to invert.
Tree Serialization
Just add enough metadata that the size of each layer is known, then the layers can simply be concatenated, and serialized as above. This metadata can be as small as the size of the initial layer, that is, a single integer.
Wire Format Specification / Syntax
Interfaces
At least two types of Merkle tree APIs are usually needed:
- one which takes a sequence
S = [T]of lengthnas input, and produces an output (Merkle root) of typeT - and one which takes a sequence of bytes
(or even bits, but in practice only bytes are probably needed):
S = [byte]
The latter can be decomposed into the composition
of an encodeBytes function and the former
(it's safer this way, because there are a lot of subtle details here).
| Interface | Description | Input | Output |
|---|---|---|---|
| computeTree() | computes the full Merkle tree | sequence of T-s | a MerkleTree[T] data structure (a binary tree) |
| computeRoot() | computes the Merkle root of a sequence of T-s | sequence of T-s | a single T |
| extractPath() | computes a Merkle path | MerkleTree[T] and a leaf index | MerklePath[T] |
| checkMerkleProof() | checks the validity of a Merkle path proof | root hash (a T) and MerklePath[T] | a bool (ok or not) |
| encodeBytesInjective() | converts a sequence of bytes into a sequence of T-s, injectively | seqences of bytes | sequence of T-s |
| serializeToBytes() | serializes a sequence of T-s into bytes | sequence of T-s | sequence of bytes |
| deserializeFromBytes() | deserializes a sequence of T-s from bytes | sequence of bytes | sequence of T-s, or error |
| serializeTree() | serializes the Merkle tree data structure (to be stored on disk) | MerkleTree[T] | sequence of bytes |
| deserializeTree() | deserializes the Merkle tree data structure (to be load from disk) | sequence of bytes | error or MerkleTree[T] |
Dependencies
Hash function implementations, for example:
- bearssl (for SHA256)
- nim-poseidon2 (which should be renamed to
nim-poseidon2-bn254) - nim-goldilocks-hash
Security/Privacy Considerations
Attack Mitigation
The specification addresses three major attack vectors:
-
Data encoding attacks: Prevented by using injective encoding from bytes to target types with the
10*padding strategy. The strategy always adds a0x01byte followed by0x00bytes to reach a multiple ofMbytes, ensuring that different byte sequences of different lengths cannot encode to the same target type. -
Padding attacks: Prevented by the keyed compression function that distinguishes between even and odd nodes. When a node has only one child (odd node), it is padded with zero to form a pair
(x,0), but the compression function uses a different key (0x02 or 0x03) than for even nodes (0x00 or 0x01), preventing confusion between padded and non-padded nodes. -
Layer abusing attacks: Prevented by the keyed compression function that distinguishes between bottom layer and other layers. The bottom layer uses keys with bit0 set to 1 (0x01 or 0x03), while other layers use keys with bit0 set to 0 (0x00 or 0x02), preventing nodes from different layers from producing identical hashes.
Keyed Compression Function
The keyed compression function uses four distinct keys (0x00, 0x01, 0x02, 0x03) based on two bits:
- bit0: Set to 1 if bottom layer, 0 otherwise
- bit1: Set to 1 if odd node (1 child), 0 if even node (2 children)
This ensures that different structural positions in the tree cannot produce hash collisions, as:
- For finite field sponge hash functions (Poseidon2, Monolith):
The permutation is applied to
(x,y,key)and the first component is extracted - For standard hash functions (SHA256):
The hash is computed as
SHA256(key|x|y)wherekeyis encoded as a byte
Hash Function Flexibility
The specification is parametrized by the hash function, allowing different implementations to use appropriate hash functions for their context while maintaining consistent security properties. All supported hash functions must maintain the injective encoding property and support the keyed compression function pattern.
Rationale
Keyed Compression Design
The use of a keyed compression function with four distinct keys is the primary defense mechanism against theoretical attacks. By encoding both the layer position (bottom vs. other) and node parity (even vs. odd) into the compression function, the design ensures that:
- Nodes at different layers cannot be confused
- Even and odd nodes produce different hashes even with the same input data
- Padding attacks are prevented by distinguishing singleton nodes from pair nodes
Encoding vs. Serialization Separation
The specification explicitly separates encoding and serialization as distinct operations:
- Encoding (bytes to
T-s) must be injective to prevent collision attacks - Serialization (
T-s to bytes) must be invertible to enable storage and retrieval
This separation is necessary because they serve different purposes.
The two operations only coincide when T is a byte sequence (as in SHA256),
but differ when T consists of prime field elements.
10* Padding Strategy
The 10* padding strategy
(always adding 0x01 followed by 0x00 bytes)
is an invertible operation
that ensures no collision between sequences of different lengths.
This is applied at the byte level before chunking into M-byte pieces.
Parametrized Hash Function Design
The design is parametrized by the hash function, supporting:
- Standard hash functions (SHA256, SHA3)
- Finite field-based sponge constructions (Poseidon2, Monolith)
- Binary compression functions
This flexibility allows easy addition of new hash functions while maintaining security guarantees.
Copyright
Copyright and related rights waived via CC0.
References
normative
- Codex Merkle Tree Implementation: GitHub - codex-storage/nim-codex
informative
- Merkle Tree Conventions: GitHub - codex-storage-proofs-circuits - Merkle tree specification in codex-storage-proofs-circuits
- nim-poseidon2: GitHub - codex-storage/nim-poseidon2 - Poseidon2 hash function for BN254
- nim-goldilocks-hash: GitHub - codex-storage/nim-goldilocks-hash - Goldilocks field hash functions
- BearSSL: bearssl.org - BearSSL cryptographic library
- Jive Compression Mode: Anemoi Permutations and Jive Compression Mode - Efficient arithmetization-oriented hash functions
CODEX-STORE
| Field | Value |
|---|---|
| Name | Codex Store Module |
| Slug | 80 |
| Status | raw |
| Category | Standards Track |
| Editor | Codex Team |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification describes the Store Module, the core storage abstraction in Codex, providing a unified interface for storing and retrieving content-addressed blocks and associated metadata.
The Store Module decouples storage operations from underlying datastore semantics
by introducing the BlockStore interface,
which standardizes methods for storing and retrieving both ephemeral
and persistent blocks across different storage backends.
The module integrates a maintenance engine responsible for cleaning up
expired ephemeral data according to configured policies.
The Store Module is built on top of the generic DataStore (DS) interface, which is implemented by multiple backends such as SQLite, LevelDB, and the filesystem.
Background / Rationale / Motivation
The primary design goal is to decouple storage operations from the underlying
datastore semantics by introducing the BlockStore interface.
This interface standardizes methods for storing and retrieving both ephemeral
and persistent blocks,
ensuring a consistent API across different storage backends.
The DataStore provides a KV-store abstraction with Get, Put, Delete,
and Query operations, with backend-dependent guarantees.
At a minimum, row-level consistency and basic batching are expected.
The DataStore supports:
- Namespace mounting for isolating backend usage
- Layering backends (e.g., caching in front of persistent stores)
- Flexible stacking and composition of storage proxies
The current implementation has several limitations:
- No dataset-level operations or advanced batching support
- Lack of consistent locking and concurrency control, which may lead to inconsistencies during crashes or long-running operations on block groups (e.g., reference count updates, expiration updates)
Theory / Semantics
BlockStore Interface
The BlockStore interface provides the following methods:
| Method | Description | Input | Output |
|---|---|---|---|
getBlock(cid: Cid) | Retrieve block by CID | CID | Future[?!Block] |
getBlock(treeCid: Cid, index: Natural) | Retrieve block from a Merkle tree by leaf index | Tree CID, index | Future[?!Block] |
getBlock(address: BlockAddress) | Retrieve block via unified address | BlockAddress | Future[?!Block] |
getBlockAndProof(treeCid: Cid, index: Natural) | Retrieve block with Merkle proof | Tree CID, index | Future[?!(Block, CodexProof)] |
getCid(treeCid: Cid, index: Natural) | Retrieve leaf CID from tree metadata | Tree CID, index | Future[?!Cid] |
getCidAndProof(treeCid: Cid, index: Natural) | Retrieve leaf CID with inclusion proof | Tree CID, index | Future[?!(Cid, CodexProof)] |
putBlock(blk: Block, ttl: Duration) | Store block with quota enforcement | Block, optional TTL | Future[?!void] |
putCidAndProof(treeCid: Cid, index: Natural, blkCid: Cid, proof: CodexProof) | Store leaf metadata with ref counting | Tree CID, index, block CID, proof | Future[?!void] |
hasBlock(...) | Check block existence (CID or tree leaf) | CID / Tree CID + index | Future[?!bool] |
delBlock(...) | Delete block/tree leaf (with ref count checks) | CID / Tree CID + index | Future[?!void] |
ensureExpiry(...) | Update expiry for block/tree leaf | CID / Tree CID + index, expiry timestamp | Future[?!void] |
listBlocks(blockType: BlockType) | Iterate over stored blocks | Block type | Future[?!SafeAsyncIter[Cid]] |
getBlockExpirations(maxNumber, offset) | Retrieve block expiry metadata | Pagination params | Future[?!SafeAsyncIter[BlockExpiration]] |
blockRefCount(cid: Cid) | Get block reference count | CID | Future[?!Natural] |
reserve(bytes: NBytes) | Reserve storage quota | Bytes | Future[?!void] |
release(bytes: NBytes) | Release reserved quota | Bytes | Future[?!void] |
start() | Initialize store | — | Future[void] |
stop() | Gracefully shut down store | — | Future[void] |
close() | Close underlying datastores | — | Future[void] |
Store Implementations
The Store module provides three concrete implementations of the BlockStore
interface,
each optimized for a specific role in the Codex architecture:
RepoStore, NetworkStore, and CacheStore.
RepoStore
The RepoStore is a persistent BlockStore implementation
that interfaces directly with low-level storage backends,
such as hard drives and databases.
It uses two distinct DataStore backends:
- FileSystem — for storing raw block data
- LevelDB — for storing associated metadata
This separation ensures optimal performance, allowing block data operations to run efficiently while metadata updates benefit from a fast key-value database.
Characteristics:
- Persistent storage via datastore backends
- Quota management with precise usage tracking
- TTL (time-to-live) support with automated expiration
- Metadata storage for block size, reference count, and expiry
- Transaction-like operations implemented through reference counting
Configuration:
quotaMaxBytes: Maximum storage quotablockTtl: Default TTL for stored blockspostFixLen: CID key postfix length for sharding
┌─────────────────────────────────────────────────────────────┐
│ RepoStore │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ repoDs │ │ metaDs │ │
│ │ (Datastore) │ │ (TypedDatastore) │ │
│ │ │ │ │ │
│ │ Block Data: │ │ Metadata: │ │
│ │ - Raw bytes │ │ - BlockMetadata │ │
│ │ - CID-keyed │ │ - LeafMetadata │ │
│ │ │ │ - QuotaUsage │ │
│ │ │ │ - Block counts │ │
│ └─────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
NetworkStore
The NetworkStore is a composite BlockStore that combines local persistence
with network-based retrieval for distributed content access.
It follows a local-first strategy — attempting to retrieve or store blocks locally first, and falling back to network retrieval via the Block Exchange Engine if the block is not available locally.
Characteristics:
- Integrates local storage with network retrieval
- Works seamlessly with the block exchange engine for peer-to-peer access
- Transparent block fetching from remote sources
- Local caching of blocks retrieved from the network for future access
┌────────────────────────────────────────────────────────────┐
│ NetworkStore │
├────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ LocalStore - RS │ │ BlockExcEngine │ │
│ │ • Store blocks │ │ • Request blocks │ │
│ │ • Get blocks │ │ • Resolve blocks │ │
│ └─────────────────┘ └──────────────────────┘ │
│ │ │ │
│ └──────────────┬───────────────┘ │
│ │ │
│ ┌─────────────┐ │
│ │BS Interface │ │
│ │ │ │
│ │ • getBlock │ │
│ │ • putBlock │ │
│ │ • hasBlock │ │
│ │ • delBlock │ │
│ └─────────────┘ │
└────────────────────────────────────────────────────────────┘
CacheStore
The CacheStore is an in-memory BlockStore implementation
designed for fast access to frequently used blocks.
This store maintains two separate LRU caches:
- Block Cache —
LruCache[Cid, Block]- Stores actual block data indexed by CID
- Acts as the primary cache for block content
- CID/Proof Cache —
LruCache[(Cid, Natural), (Cid, CodexProof)]- Maps
(treeCid, index)to(blockCid, proof) - Supports direct access to block proofs keyed by
treeCidand index
- Maps
Characteristics:
- O(1) access times for cached data
- LRU eviction policy for memory management
- Configurable maximum cache size
- No persistence — cache contents are lost on restart
- No TTL — blocks remain in cache until evicted
Configuration:
cacheSize: Maximum total cache size (bytes)chunkSize: Minimum block size unit
Storage Layout
| Key Pattern | Data Type | Description | Example |
|---|---|---|---|
repo/manifests/{XX}/{full-cid} | Raw bytes | Manifest block data | repo/manifests/Cd/bafy...Cd → [data] |
repo/blocks/{XX}/{full-cid} | Raw bytes | Block data | repo/blocks/Ab/bafy...Ab → [data] |
meta/ttl/{cid} | BlockMetadata | Expiry, size, refCount | meta/ttl/bafy... → {...} |
meta/proof/{treeCid}/{index} | LeafMetadata | Merkle proof for leaf | meta/proof/bafy.../42 → {...} |
meta/total | Natural | Total stored blocks | meta/total → 12039 |
meta/quota/used | NBytes | Used quota | meta/quota/used → 52428800 |
meta/quota/reserved | NBytes | Reserved quota | meta/quota/reserved → 104857600 |
Workflows
The following flow charts summarize how put, get, and delete operations interact with the shared block storage, metadata store, and quota management systems.
PutBlock
The following flow chart shows how a block is stored with metadata and quota management:
putBlock: blk, ttl
│
├─> Calculate expiry = now + ttl
│
├─> storeBlock: blk, expiry
│
├─> Block empty?
│ ├─> Yes: Return AlreadyInStore
│ └─> No: Create metadata & block keys
│
├─> Block metadata exists?
│ ├─> Yes: Size matches?
│ │ ├─> Yes: Return AlreadyInStore
│ │ └─> No: Return Error
│ └─> No: Create new metadata
│
├─> Store block data
│
├─> Store successful?
│ ├─> No: Return Error
│ └─> Yes: Update quota usage
│
├─> Quota update OK?
│ ├─> No: Rollback: Delete block → Return Error
│ └─> Yes: Update total blocks count
│
├─> Trigger onBlockStored callback
│
└─> Return Success
GetBlock
The following flow chart explains how a block is retrieved by CID or tree reference, resolving metadata if necessary, and returning the block or an error:
getBlock: cid/address
│
├─> Input type?
│ ├─> BlockAddress with leaf
│ │ └─> getLeafMetadata: treeCid, index
│ │ ├─> Leaf metadata found?
│ │ │ ├─> No: Return BlockNotFoundError
│ │ │ └─> Yes: Extract block CID from metadata
│ └─> CID: Direct CID access
│
├─> CID empty?
│ ├─> Yes: Return empty block
│ └─> No: Create prefix key
│
├─> Query datastore: repoDs.get
│
├─> Block found?
│ ├─> No: Error type?
│ │ ├─> DatastoreKeyNotFound: Return BlockNotFoundError
│ │ └─> Other: Return Error
│ └─> Yes: Create Block with verification
│
└─> Return Block
DelBlock
The following flow chart shows how a block is deleted when it is unused or expired, including metadata cleanup and quota/counter updates:
delBlock: cid
│
├─> delBlockInternal: cid
│
├─> CID empty?
│ ├─> Yes: Return Deleted
│ └─> No: tryDeleteBlock: cid, now
│
├─> Metadata exists?
│ ├─> No: Check if block exists in repo
│ │ ├─> Block exists?
│ │ │ ├─> Yes: Warn & remove orphaned block
│ │ │ └─> No: Return NotFound
│ │ └─> Return NotFound
│ └─> Yes: refCount = 0 OR expired?
│ ├─> No: Return InUse
│ └─> Yes: Delete block & metadata → Return Deleted
│
├─> Handle result
│
├─> Result type?
│ ├─> InUse: Return Error: Cannot delete dataset block
│ ├─> NotFound: Return Success: Ignore
│ └─> Deleted: Update total blocks count
│ └─> Update quota usage
│ └─> Return Success
│
└─> Return Success
Data Models
Stores
RepoStore* = ref object of BlockStore
postFixLen*: int
repoDs*: Datastore
metaDs*: TypedDatastore
clock*: Clock
quotaMaxBytes*: NBytes
quotaUsage*: QuotaUsage
totalBlocks*: Natural
blockTtl*: Duration
started*: bool
NetworkStore* = ref object of BlockStore
engine*: BlockExcEngine
localStore*: BlockStore
CacheStore* = ref object of BlockStore
currentSize*: NBytes
size*: NBytes
cache: LruCache[Cid, Block]
cidAndProofCache: LruCache[(Cid, Natural), (Cid, CodexProof)]
Metadata Types
BlockMetadata* {.serialize.} = object
expiry*: SecondsSince1970
size*: NBytes
refCount*: Natural
LeafMetadata* {.serialize.} = object
blkCid*: Cid
proof*: CodexProof
BlockExpiration* {.serialize.} = object
cid*: Cid
expiry*: SecondsSince1970
QuotaUsage* {.serialize.} = object
used*: NBytes
reserved*: NBytes
Functional Requirements
Available Today
-
Atomic Block Operations
- Store, retrieve, and delete operations must be atomic.
- Support retrieval via:
- Direct CID
- Tree-based addressing (
treeCid + index) - Unified block address
-
Metadata Management
- Store protocol-level metadata (e.g., storage proofs, quota usage).
- Store block-level metadata (e.g., reference counts, total block count).
-
Multi-Datastore Support
- Pluggable datastore interface supporting various backends.
- Typed datastore operations for metadata type safety.
-
Lifecycle & Maintenance
- BlockMaintainer service for removing expired data.
- Configurable maintenance intervals (default: 10 min).
- Batch processing (default: 1000 blocks/cycle).
Future Requirements
-
Transaction Rollback & Error Recovery
- Rollback support for failed multi-step operations.
- Consistent state restoration after failures.
-
Dataset-Level Operations
- Handle Dataset level meta data.
- Batch operations for dataset block groups.
-
Concurrency Control
- Consistent locking and coordination mechanisms to prevent inconsistencies during crashes or long-running operations.
-
Lifecycle & Maintenance
- Cooperative scheduling to avoid blocking.
- State tracking for large datasets.
Non-Functional Requirements
Currently Implemented
-
Security
- Verify block content integrity upon retrieval.
- Enforce quotas to prevent disk exhaustion.
- Safe orphaned data cleanup.
-
Scalability
- Configurable storage quotas (default: 20 GiB).
- Pagination for metadata queries.
- Reference counting–based garbage collection.
-
Reliability
- Metrics collection (
codex_repostore_*). - Graceful shutdown with resource cleanup.
- Metrics collection (
Planned Enhancements
-
Performance
- Batch metadata updates.
- Efficient key lookups with configurable prefix lengths.
- Support for both fast and slower storage tiers.
- Streaming APIs optimized for extremely large datasets.
-
Security
- Finer-grained quota enforcement across tenants/namespaces.
-
Reliability
- Stronger rollback semantics for multi-node consistency.
- Auto-recovery from inconsistent states.
Wire Format Specification / Syntax
The Store Module does not define a wire format specification. It provides an internal storage abstraction for Codex and relies on underlying datastore implementations for serialization and persistence.
Security/Privacy Considerations
-
Block Integrity: The Store Module verifies block content integrity upon retrieval to ensure data has not been corrupted or tampered with.
-
Quota Enforcement: Storage quotas are enforced to prevent disk exhaustion attacks. The default quota is 20 GiB, but this is configurable.
-
Safe Data Cleanup: The maintenance engine safely removes expired ephemeral data and orphaned blocks without compromising data integrity.
-
Reference Counting: Reference counting–based garbage collection ensures that blocks are not deleted while they are still in use by other components.
Future security enhancements include finer-grained quota enforcement across tenants/namespaces and stronger rollback semantics for multi-node consistency.
Rationale
The Store Module design prioritizes:
-
Decoupling: By introducing the
BlockStoreinterface, the Store Module decouples storage operations from underlying datastore semantics, allowing for flexible backend implementations. -
Performance: The separation of block data (filesystem) and metadata (LevelDB) in RepoStore ensures optimal performance for both types of operations.
-
Flexibility: The three store implementations (RepoStore, NetworkStore, CacheStore) provide different trade-offs between persistence, network access, and performance, allowing Codex to optimize for different use cases.
-
Scalability: Reference counting, quota management, and pagination enable the Store Module to scale to large datasets while preventing resource exhaustion.
The current limitations (lack of dataset-level operations, inconsistent locking) are acknowledged and will be addressed in future versions.
Copyright
Copyright and related rights waived via CC0.
References
normative
informative
- nim-datastore
- DataStore Interface
- chronos - Async runtime
- libp2p - P2P networking and CID types
- questionable - Error handling
- lrucache - LRU cache implementation
Storage Deprecated Specifications
Deprecated Storage specifications kept for archival and reference purposes.
CODEX-ERASUE-CODING
| Field | Value |
|---|---|
| Name | Codex Erasue Coding |
| Slug | 79 |
| Status | deprecated |
Timeline
- 2026-01-22 —
e356a07— Chore/add makefile (#271) - 2026-01-22 —
af45aae— chore: deprecate Marketplace-related specs (#268) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification describes the erasure coding technique used by Codex clients. A Codex client will encode a dataset before it is stored on the network.
Background
The Codex protocol uses storage proofs to verify whether a storage provider (SP) is storing a certain dataset. Before a dataset is retrieved on the network, SPs must agree to store the dataset for a certain period of time. When a storage request is active, erasure coding helps ensure the dataset is retrievable from the network. This is achieved by the dataset that is chunked, which is restored in retrieval by erasure coding. When data blocks are abandoned by storage providers, the requester can be assured of data retrievability.
Specification
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
A client SHOULD perform the erasure encoding locally before providing a dataset to the network. During validation, nodes will conduct error correction and decoding based on the erasure coding technique known to the network. Datasets using encodings not recognized by the network MAY be ignored during decoding and validation by other nodes in the network.
The dataset SHOULD be split into data chunks represented by k, e.g. $(k_1, k_2, k_3, \ldots, k_{n})$.
Each chunk k MUST be encoded into n blocks, using an erasure encoding technique like the Reed Solomon algorithm.
Including a set of parity blocks that MUST be generated,
represented by m.
All node roles on the Codex network use the Leopard Codec.
Below is the encoding process:
- Prepare the dataset for the marketplace using erasure encoding.
- Derive a manifest CID from the root encoded blocks
- Error correction by validator nodes once the storage contract begins
- Decode data back to the original data.
Encoding
A client MAY prepare a dataset locally before making the request to the network.
The data chunks, k, MUST be the same size, if not,
the smaller chunk MAY be padded with empty data.
The data blocks are encoded based on the following parameters:
struct encodingParms {
ecK: int, // Number of data blocks (K)
ecM: int, // Number of parity blocks (M)
rounded: int, // Dataset rounded to multiple of (K)
steps: int, // Number of encoding iterations (steps)
blocksCount: int, // Total blocks after encoding
strategy: enum, // Indexing strategy used
}
After the erasure coding process, a protected manifest SHOULD be generated for the dataset, which would store the CID of the root Merkle tree. The content of the protected manifest below, see CODEX-MANIFEST for more information:
syntax = "proto3";
message verifiable {
string verifyRoot = 1 // Root of verification tree with CID
repeated string slot_roots = 2 // List Individual slot roots with CID
uint32 cellSize = 3 // Size of verification cells
string verifiableStrategy = 4 // Strategy for verification
}
message ErasureInfo {
optional uint32 ecK = 1; // number of encoded blocks
optional uint32 ecM = 2; // number of parity blocks
optional bytes originalTreeCid = 3; // cid of the original dataset
optional uint32 originalDatasetSize = 4; // size of the original dataset
optional VerificationInformation verification = 5; // verification information
}
message Manifest {
optional bytes treeCid = 1; // cid (root) of the tree
optional uint32 blockSize = 2; // size of a single block
optional uint64 datasetSize = 3; // size of the dataset
optional codec: MultiCodec = 4; // Dataset codec
optional hcodec: MultiCodec = 5 // Multihash codec
optional version: CidVersion = 6; // Cid version
optional ErasureInfo erasure = 7; // erasure coding info
}
After the encoding process, is ready to be stored on the network via the CODEX-MARKETPLACE. The Merkle tree root SHOULD be included in the manifest so other nodes are able to locate and reconstruct a dataset from the erasure encoded blocks.
Data Repair
Storage providers may have periods during a storage contract where they are not storing the data.
A validator node MAY store the treeCid from the Manifest to locate all the data blocks and
reconstruct the merkle tree.
When a missing branch of the tree is not retrievable from an SP, data repair will be REQUIRED.
The validator will open a request for a new SP to reconstruct the Merkle tree and
store the missing data blocks.
The validator role is described in the CODEX-MARKETPLACE specification.
Decode Data
During dataset retrieval, a node will use the treeCid to locate the data blocks.
The number of retrieved blocks by the node MUST be greater than k.
If less than k, the node MAY not be able to reconstruct the dataset.
The node SHOULD request missing data chunks from the network and
wait until the threshold is reached.
Security Considerations
Adversarial Attack
An adversarial storage provider can remove only the first element from more than half of the block, and the slot data can no longer be recovered from the data that the host stores. For example, with data blocks of size 1TB, erasure coded into 256 data and parity shards. An adversary could strategically remove 129 bytes, and the data can no longer be fully recovered with the erasure-coded data that is present on the host.
The RECOMMENDED solution should perform checks on entire shards to protect against adversarial erasure. In the Merkle storage proofs, the entire shard SHOULD be hashed, then that hash is checked against the Merkle proof. Effectively, the block size for Merkle proofs should equal the shard size of the erasure coding interleaving. Hashing large amounts of data will be expensive to perform in an SNARK, which is used to compress proofs in size in Codex.
Data Encryption
If data is not encrypted before entering the encoding process, nodes, including storage providers, MAY be able to access the data. This may lead to privacy concerns and the misuse of data.
Copyright
Copyright and related rights waived via CC0.
References
- Leapard Codec
- CODEX-MANIFEST
- CODEX-MARKETPLACE
CODEX-MARKETPLACE
| Field | Value |
|---|---|
| Name | Codex Storage Marketplace |
| Slug | 77 |
| Status | deprecated |
| Category | Standards Track |
| Editor | Codex Team and Dmitriy Ryajov [email protected] |
| Contributors | Mark Spanbroek [email protected], Adam Uhlíř [email protected], Eric Mastro [email protected], Jimmy Debe [email protected], Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-22 —
e356a07— Chore/add makefile (#271) - 2026-01-22 —
af45aae— chore: deprecate Marketplace-related specs (#268) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-11-19 —
d2df7e0— Created codex/raw/codex-marketplace.md file, without integration of Sales a… (#208)
Abstract
Codex Marketplace and its interactions are defined by a smart contract deployed on an EVM-compatible blockchain. This specification describes these interactions for the various roles within the network.
The document is intended for implementors of Codex nodes.
Semantics
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in 2119.
Definitions
| Terminology | Description |
|---|---|
| Storage Provider (SP) | A node in the Codex network that provides storage services to the marketplace. |
| Validator | A node that assists in identifying missing storage proofs. |
| Client | A node that interacts with other nodes in the Codex network to store, locate, and retrieve data. |
| Storage Request or Request | A request created by a client node to persist data on the Codex network. |
| Slot or Storage Slot | A space allocated by the storage request to store a piece of the request's dataset. |
| Smart Contract | A smart contract implementing the marketplace functionality. |
| Token | The ERC20-based token used within the Codex network. |
Motivation
The Codex network aims to create a peer-to-peer storage engine with robust data durability, data persistence guarantees, and a comprehensive incentive structure.
The marketplace is a critical component of the Codex network, serving as a platform where all involved parties interact to ensure data persistence. It provides mechanisms to enforce agreements and facilitate data repair when SPs fail to fulfill their duties.
Implemented as a smart contract on an EVM-compatible blockchain, the marketplace enables various scenarios where nodes assume one or more roles to maintain a reliable persistence layer for users. This specification details these interactions.
The marketplace contract manages storage requests, maintains the state of allocated storage slots, and orchestrates SP rewards, collaterals, and storage proofs.
A node that wishes to participate in the Codex persistence layer MUST implement one or more roles described in this document.
Roles
A node can assume one of the three main roles in the network: the client, SP, and validator.
A client is a potentially short-lived node in the network with the purpose of persisting its data in the Codex persistence layer.
An SP is a long-lived node providing storage for clients in exchange for profit. To ensure a reliable, robust service for clients, SPs are required to periodically provide proofs that they are persisting the data.
A validator ensures that SPs have submitted valid proofs each period where the smart contract required a proof to be submitted for slots filled by the SP.
Part I: Protocol Specification
This part defines the normative requirements for the Codex Marketplace protocol. All implementations MUST comply with these requirements to participate in the Codex network. The protocol is defined by smart contract interactions on an EVM-compatible blockchain.
Storage Request Lifecycle
The diagram below depicts the lifecycle of a storage request:
┌───────────┐
│ Cancelled │
└───────────┘
▲
│ Not all
│ Slots filled
│
┌───────────┐ ┌──────┴─────────────┐ ┌─────────┐
│ Submitted ├───►│ Slots Being Filled ├──────────►│ Started │
└───────────┘ └────────────────────┘ All Slots └────┬────┘
Filled │
│
┌───────────────────────┘
Proving ▼
┌────────────────────────────────────────────────────────────┐
│ │
│ Proof submitted │
│ ┌─────────────────────────► All good │
│ │ │
│ Proof required │
│ │ │
│ │ Proof missed │
│ └─────────────────────────► After some time slashed │
│ eventually Slot freed │
│ │
└────────┬─┬─────────────────────────────────────────────────┘
│ │ ▲
│ │ │
│ │ SP kicked out and Slot freed ┌───────┴────────┐
All good │ ├─────────────────────────────►│ Repair process │
Time ran out │ │ └────────────────┘
│ │
│ │ Too many Slots freed ┌────────┐
│ └─────────────────────────────►│ Failed │
▼ └────────┘
┌──────────┐
│ Finished │
└──────────┘
Client Role
A node implementing the client role mediates the persistence of data within the Codex network.
A client has two primary responsibilities:
- Requesting storage from the network by sending a storage request to the smart contract.
- Withdrawing funds from the storage requests previously created by the client.
Creating Storage Requests
When a user prompts the client node to create a storage request, the client node SHOULD receive the input parameters for the storage request from the user.
To create a request to persist a dataset on the Codex network, client nodes MUST split the dataset into data chunks, $(c_1, c_2, c_3, \ldots, c_{n})$. Using the erasure coding method and the provided input parameters, the data chunks are encoded and distributed over a number of slots. The applied erasure coding method MUST use the Reed-Solomon algorithm. The final slot roots and other metadata MUST be placed into a Manifest (TODO: Manifest RFC). The CID for the Manifest MUST then be used as the cid for the stored dataset.
After the dataset is prepared, a client node MUST call the smart contract function requestStorage(request), providing the desired request parameters in the request parameter. The request parameter is of type Request:
struct Request {
address client;
Ask ask;
Content content;
uint64 expiry;
bytes32 nonce;
}
struct Ask {
uint256 proofProbability;
uint256 pricePerBytePerSecond;
uint256 collateralPerByte;
uint64 slots;
uint64 slotSize;
uint64 duration;
uint64 maxSlotLoss;
}
struct Content {
bytes cid;
bytes32 merkleRoot;
}
The table below provides the description of the Request and the associated types attributes:
| attribute | type | description |
|---|---|---|
client | address | The Codex node requesting storage. |
ask | Ask | Parameters of Request. |
content | Content | The dataset that will be hosted with the storage request. |
expiry | uint64 | Timeout in seconds during which all the slots have to be filled, otherwise Request will get cancelled. The final deadline timestamp is calculated at the moment the transaction is mined. |
nonce | bytes32 | Random value to differentiate from other requests of same parameters. It SHOULD be a random byte array. |
pricePerBytePerSecond | uint256 | Amount of tokens that will be awarded to SPs for finishing the storage request. It MUST be an amount of tokens offered per slot per second per byte. The Ethereum address that submits the requestStorage() transaction MUST have approval for the transfer of at least an equivalent amount of full reward (pricePerBytePerSecond * duration * slots * slotSize) in tokens. |
collateralPerByte | uint256 | The amount of tokens per byte of slot's size that SPs submit when they fill slots. Collateral is then slashed or forfeited if SPs fail to provide the service requested by the storage request (more information in the [Slashing](#### Slashing) section). |
proofProbability | uint256 | Determines the average frequency that a proof is required within a period: $\frac{1}{proofProbability}$. SPs are required to provide proofs of storage to the marketplace contract when challenged. To prevent hosts from only coming online when proofs are required, the frequency at which proofs are requested from SPs is stochastic and is influenced by the proofProbability parameter. |
duration | uint64 | Total duration of the storage request in seconds. It MUST NOT exceed the limit specified in the configuration config.requestDurationLimit. |
slots | uint64 | The number of requested slots. The slots will all have the same size. |
slotSize | uint64 | Amount of storage per slot in bytes. |
maxSlotLoss | uint64 | Max slots that can be lost without data considered to be lost. |
cid | bytes | An identifier used to locate the Manifest representing the dataset. It MUST be a CIDv1, SHA-256 multihash and the data it represents SHOULD be discoverable in the network, otherwise the request will be eventually canceled. |
merkleRoot | bytes32 | Merkle root of the dataset, used to verify storage proofs |
Renewal of Storage Requests
It should be noted that the marketplace does not support extending requests. It is REQUIRED that if the user wants to extend the duration of a request, a new request with the same CID must be [created](### Creating Storage Requests) before the original request completes.
This ensures that the data will continue to persist in the network at the time when the new (or existing) SPs need to retrieve the complete dataset to fill the slots of the new request.
Monitoring and State Management
Client nodes MUST implement the following smart contract interactions for monitoring and state management:
-
getRequest(requestId): Retrieve the full
StorageRequestdata from the marketplace. This function is used for recovery and state verification after restarts or failures. -
requestState(requestId): Query the current state of a storage request. Used for monitoring request progress and determining the appropriate client actions.
-
requestExpiresAt(requestId): Query when the request will expire if not fulfilled.
-
getRequestEnd(requestId): Query when a fulfilled request will end (used to determine when to call
freeSlotorwithdrawFunds).
Client nodes MUST subscribe to the following marketplace events:
-
RequestFulfilled(requestId): Emitted when a storage request has enough filled slots to start. Clients monitor this event to determine when their request becomes active and transitions from the submission phase to the active phase.
-
RequestFailed(requestId): Emitted when a storage request fails due to proof failures or other reasons. Clients observe this event to detect failed requests and initiate fund withdrawal.
Withdrawing Funds
The client node MUST monitor the status of the requests it created. When a storage request enters the Cancelled, Failed, or Finished state, the client node MUST initiate the withdrawal of the remaining or refunded funds from the smart contract using the withdrawFunds(requestId) function.
Request states are determined as follows:
- The request is considered
Cancelledif noRequestFulfilled(requestId)event is observed during the timeout specified by the value returned from therequestExpiresAt(requestId)function. - The request is considered
Failedwhen theRequestFailed(requestId)event is observed. - The request is considered
Finishedafter the interval specified by the value returned from thegetRequestEnd(requestId)function has elapsed.
Storage Provider Role
A Codex node acting as an SP persists data across the network by hosting slots requested by clients in their storage requests.
The following tasks need to be considered when hosting a slot:
- Filling a slot
- Proving
- Repairing a slot
- Collecting request reward and collateral
Filling Slots
When a new request is created, the StorageRequested(requestId, ask, expiry) event is emitted with the following properties:
requestId- the ID of the request.ask- the specification of the request parameters. For details, see the definition of theRequesttype in the [Creating Storage Requests](### Creating Storage Requests) section above.expiry- a Unix timestamp specifying when the request will be canceled if all slots are not filled by then.
It is then up to the SP node to decide, based on the emitted parameters and node's operator configuration, whether it wants to participate in the request and attempt to fill its slot(s) (note that one SP can fill more than one slot). If the SP node decides to ignore the request, no further action is required. However, if the SP decides to fill a slot, it MUST follow the remaining steps described below.
The node acting as an SP MUST decide which slot, specified by the slot index, it wants to fill. The SP MAY attempt to fill more than one slot. To fill a slot, the SP MUST first reserve the slot in the smart contract using reserveSlot(requestId, slotIndex). If reservations for this slot are full, or if the SP has already reserved the slot, the transaction will revert. If the reservation was unsuccessful, then the SP is not allowed to fill the slot. If the reservation was successful, the node MUST then download the slot data using the CID of the manifest (TODO: Manifest RFC) and the slot index. The CID is specified in request.content.cid, which can be retrieved from the smart contract using getRequest(requestId). Then, the node MUST generate a proof over the downloaded data (TODO: Proving RFC).
When the proof is ready, the SP MUST call fillSlot() on the smart contract with the following REQUIRED parameters:
requestId- the ID of the request.slotIndex- the slot index that the node wants to fill.proof- theGroth16Proofproof structure, generated over the slot data.
The Ethereum address of the SP node from which the transaction originates MUST have approval for the transfer of at least the amount of tokens required as collateral for the slot (collateralPerByte * slotSize).
If the proof delivered by the SP is invalid or the slot was already filled by another SP, then the transaction will revert. Otherwise, a SlotFilled(requestId, slotIndex) event is emitted. If the transaction is successful, the SP SHOULD transition into the proving state, where it will need to submit proof of data possession when challenged by the smart contract.
It should be noted that if the SP node observes a SlotFilled event for the slot it is currently downloading the dataset for or generating the proof for, it means that the slot has been filled by another node in the meantime. In response, the SP SHOULD stop its current operation and attempt to fill a different, unfilled slot.
Proving
Once an SP fills a slot, it MUST submit proofs to the marketplace contract when a challenge is issued by the contract. SPs SHOULD detect that a proof is required for the current period using the isProofRequired(slotId) function, or that it will be required using the willProofBeRequired(slotId) function in the case that the proving clock pointer is in downtime.
Once an SP knows it has to provide a proof it MUST get the proof challenge using getChallenge(slotId), which then
MUST be incorporated into the proof generation as described in Proving RFC (TODO: Proving RFC).
When the proof is generated, it MUST be submitted by calling the submitProof(slotId, proof) smart contract function.
Slashing
There is a slashing scheme orchestrated by the smart contract to incentivize correct behavior and proper proof submissions by SPs. This scheme is configured at the smart contract level and applies uniformly to all participants in the network. The configuration of the slashing scheme can be obtained via the configuration() contract call.
The slashing works as follows:
- When SP misses a proof and a validator trigger detection of this event using the
markProofAsMissing()call, the SP is slashed byconfig.collateral.slashPercentageof the originally required collateral (hence the slashing amount is always the same for a given request). - If the number of slashes exceeds
config.collateral.maxNumberOfSlashes, the slot is freed, the remaining collateral is burned, and the slot is offered to other nodes for repair. The smart contract also emits theSlotFreed(requestId, slotIndex)event.
If, at any time, the number of freed slots exceeds the value specified by the request.ask.maxSlotLoss parameter, the dataset is considered lost, and the request is deemed failed. The collateral of all SPs that hosted the slots associated with the storage request is burned, and the RequestFailed(requestId) event is emitted.
Repair
When a slot is freed due to too many missed proofs, which SHOULD be detected by listening to the SlotFreed(requestId, slotIndex) event, an SP node can decide whether to participate in repairing the slot. Similar to filling a slot, the node SHOULD consider the operator's configuration when making this decision. The SP that originally hosted the slot but failed to comply with proving requirements MAY also participate in the repair. However, by refilling the slot, the SP will not recover its original collateral and must submit new collateral using the fillSlot() call.
The repair process is similar to filling slots. If the original slot dataset is no longer present in the network, the SP MAY use erasure coding to reconstruct the dataset. Reconstructing the original slot dataset requires retrieving other pieces of the dataset stored in other slots belonging to the request. For this reason, the node that successfully repairs a slot is entitled to an additional reward. (TODO: Implementation)
The repair process proceeds as follows:
- The SP observes the
SlotFreedevent and decides to repair the slot. - The SP MUST reserve the slot with the
reserveSlot(requestId, slotIndex)call. For more information see the [Filling Slots](###filling slots) section. - The SP MUST download the chunks of data required to reconstruct the freed slot's data. The node MUST use the Reed-Solomon algorithm to reconstruct the missing data.
- The SP MUST generate proof over the reconstructed data.
- The SP MUST call the
fillSlot()smart contract function with the same parameters and collateral allowance as described in the [Filling Slots](###filling slots) section.
Collecting Funds
An SP node SHOULD monitor the requests and the associated slots it hosts.
When a storage request enters the Cancelled, Finished, or Failed state, the SP node SHOULD call the freeSlot(slotId) smart contract function.
The aforementioned storage request states (Cancelled, Finished, and Failed) can be detected as follows:
- A storage request is considered
Cancelledif noRequestFulfilled(requestId)event is observed within the time indicated by theexpiryrequest parameter. Note that aRequestCancelledevent may also be emitted, but the node SHOULD NOT rely on this event to assert the request expiration, as theRequestCancelledevent is not guaranteed to be emitted at the time of expiry. - A storage request is considered
Finishedwhen the time indicated by the value returned from thegetRequestEnd(requestId)function has elapsed. - A node concludes that a storage request has
Failedupon observing theRequestFailed(requestId)event.
For each of the states listed above, different funds are handled as follows:
- In the
Cancelledstate, the collateral is returned along with a proportional payout based on the time the node actually hosted the dataset before the expiry was reached. - In the
Finishedstate, the full reward for hosting the slot, along with the collateral, is collected. - In the
Failedstate, no funds are collected. The reward is returned to the client, and the collateral is burned. The slot is removed from the list of slots and is no longer included in the list of slots returned by themySlots()function.
Validator Role
In a blockchain, a contract cannot change its state without a transaction and gas initiating the state change. Therefore, our smart contract requires an external trigger to periodically check and confirm that a storage proof has been delivered by the SP. This is where the validator role is essential.
The validator role is fulfilled by nodes that help to verify that SPs have submitted the required storage proofs.
It is the smart contract that checks if the proof requested from an SP has been delivered. The validator only triggers the decision-making function in the smart contract. To incentivize validators, they receive a reward each time they correctly mark a proof as missing corresponding to the percentage of the slashed collateral defined by config.collateral.validatorRewardPercentage.
Each time a validator observes the SlotFilled event, it SHOULD add the slot reported in the SlotFilled event to the validator's list of watched slots. Then, after the end of each period, a validator has up to config.proofs.timeout seconds (a configuration parameter retrievable with configuration()) to validate all the slots. If a slot lacks the required proof, the validator SHOULD call the markProofAsMissing(slotId, period) function on the smart contract. This function validates the correctness of the claim, and if right, will send a reward to the validator.
If validating all the slots observed by the validator is not feasible within the specified timeout, the validator MAY choose to validate only a subset of the observed slots.
Part II: Implementation Suggestions
IMPORTANT: The sections above (Abstract through Validator Role) define the normative Codex Marketplace protocol requirements. All implementations MUST comply with those protocol requirements to participate in the Codex network.
The sections below are non-normative. They document implementation approaches used in the nim-codex reference implementation. These are suggestions to guide implementors but are NOT required by the protocol. Alternative implementations MAY use different approaches as long as they satisfy the protocol requirements defined in Part I.
Implementation Suggestions
This section describes implementation approaches used in reference implementations. These are suggestions and not normative requirements. Implementations are free to use different internal architectures, state machines, and data structures as long as they correctly implement the protocol requirements defined above.
Storage Provider Implementation
The nim-codex reference implementation provides a complete Storage Provider implementation with state machine management, slot queueing, and resource management. This section documents the nim-codex approach.
State Machine
The Sales module implements a deterministic state machine for each slot, progressing through the following states:
- SalePreparing - Find a matching availability and create a reservation
- SaleSlotReserving - Reserve the slot on the marketplace
- SaleDownloading - Stream and persist the slot's data
- SaleInitialProving - Wait for stable challenge and generate initial proof
- SaleFilling - Compute collateral and fill the slot
- SaleFilled - Post-filling operations and expiry updates
- SaleProving - Generate and submit proofs periodically
- SalePayout - Free slot and calculate collateral
- SaleFinished - Terminal success state
- SaleFailed - Free slot on market and transition to error
- SaleCancelled - Cancellation path
- SaleIgnored - Sale ignored (no matching availability or other conditions)
- SaleErrored - Terminal error state
- SaleUnknown - Recovery state for crash recovery
- SaleProvingSimulated - Proving with injected failures for testing
All states move to SaleErrored if an error is raised.
SalePreparing
- Find a matching availability based on the following criteria:
freeSize,duration,collateralPerByte,minPricePerBytePerSecondanduntil - Create a reservation
- Move to
SaleSlotReservingif successful - Move to
SaleIgnoredif no availability is found or ifBytesOutOfBoundsErroris raised because of no space available. - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleSlotReserving
- Check if the slot can be reserved
- Move to
SaleDownloadingif successful - Move to
SaleIgnoredifSlotReservationNotAllowedErroris raised or the slot cannot be reserved. The collateral is returned. - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleDownloading
- Select the correct data expiry:
- When the request is started, the request end date is used
- Otherwise the expiry date is used
- Stream and persist data via
onStore - For each written batch, release bytes from the reservation
- Move to
SaleInitialProvingif successful - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry - Move to
SaleFilledonSlotFilledevent from themarketplace
SaleInitialProving
- Wait for a stable initial challenge
- Produce the initial proof via
onProve - Move to
SaleFillingif successful - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleFilling
- Get the slot collateral
- Fill the slot
- Move to
SaleFilledif successful - Move to
SaleIgnoredonSlotStateMismatchError. The collateral is returned. - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleFilled
- Ensure that the current host has filled the slot by checking the signer address
- Notify by calling
onFilledhook - Call
onExpiryUpdateto change the data expiry from expiry date to request end date - Move to
SaleProving(orSaleProvingSimulatedfor simulated mode) - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleProving
- For each period: fetch challenge, call
onProve, and submit proof - Move to
SalePayoutwhen the slot request ends - Re-raise
SlotFreedErrorwhen the slot is freed - Raise
SlotNotFilledErrorwhen the slot is not filled - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleProvingSimulated
- Submit invalid proofs every
Nperiods (failEveryNProofsin configuration) to test failure scenarios
SalePayout
- Get the current collateral and try to free the slot to ensure that the slot is freed after payout.
- Forward the returned collateral to cleanup
- Move to
SaleFinishedif successful - Move to
SaleFailedonRequestFailedevent from themarketplace - Move to
SaleCancelledon cancelled timer elapsed, set to storage contract expiry
SaleFinished
- Call
onClearhook - Call
onCleanUphook
SaleFailed
- Free the slot
- Move to
SaleErroredwith the failure message
SaleCancelled
- Ensure that the node hosting the slot frees the slot
- Call
onClearhook - Call
onCleanUphook with the current collateral
SaleIgnored
- Call
onCleanUphook with the current collateral
SaleErrored
- Call
onClearhook - Call
onCleanUphook
SaleUnknown
- Recovery entry: get the
on-chainstate and jump to the appropriate state
Slot Queue
Slot queue schedules slot work and instantiates one SalesAgent per item with bounded concurrency.
- Accepts
(requestId, slotIndex, …)items and orders them by priority - Spawns one
SalesAgentfor each dequeued item, in other words, one item for one agent - Caps concurrent agents to
maxWorkers - Supports pause/resume
- Allows controlled requeue when an agent finishes with
reprocessSlot
Slot Ordering
The criteria are in the following order:
- Unseen before seen - Items that have not been seen are dequeued first.
- More profitable first - Higher
profitabilitywins.profitabilityisduration * pricePerSlotPerSecond. - Less collateral first - The item with the smaller
collateralwins. - Later expiry first - If both items carry an
expiry, the one with the greater timestamp wins.
Within a single request, per-slot items are shuffled before enqueuing so the default slot-index order does not influence priority.
Pause / Resume
When the Slot queue processes an item with seen = true, it means that the item was already evaluated against the current availabilities and did not match.
To avoid draining the queue with untenable requests (due to insufficient availability), the queue pauses itself.
The queue resumes when:
OnAvailabilitySavedfires after an availability update that increases one of:freeSize,duration,minPricePerBytePerSecond, ortotalRemainingCollateral.- A new unseen item (
seen = false) is pushed. unpause()is called explicitly.
Reprocess
Availability matching occurs in SalePreparing.
If no availability fits at that time, the sale is ignored with reprocessSlot to true, meaning that the slot is added back to the queue with the flag seen to true.
Startup
On SlotQueue.start(), the sales module first deletes reservations associated with inactive storage requests, then starts a new SalesAgent for each active storage request:
- Fetch the active
on-chainactive slots. - Delete the local reservations for slots that are not in the active list.
- Create a new agent for each slot and assign the
onCleanUpcallback. - Start the agent in the
SaleUnknownstate.
Main Behaviour
When a new slot request is received, the sales module extracts the pair (requestId, slotIndex, …) from the request.
A SlotQueueItem is then created with metadata such as profitability, collateral, expiry, and the seen flag set to false.
This item is pushed into the SlotQueue, where it will be prioritised according to the ordering rules.
SalesAgent
SalesAgent is the instance that executes the state machine for a single slot.
- Executes the sale state machine across the slot lifecycle
- Holds a
SalesContextwith dependencies and host hooks - Supports crash recovery via the
SaleUnknownstate - Handles errors by entering
SaleErrored, which runs cleanup routines
SalesContext
SalesContext is a container for dependencies used by all sales.
- Provides external interfaces:
Market(marketplace) andClock - Provides access to
Reservations - Provides host hooks:
onStore,onProve,onExpiryUpdate,onClear,onSale - Shares the
SlotQueuehandle for scheduling work - Provides configuration such as
simulateProofFailures - Passed to each
SalesAgent
Marketplace Subscriptions
The sales module subscribes to on-chain events to keep the queue and agents consistent.
StorageRequested
When the marketplace signals a new request, the sales module:
- Computes collateral for free slots.
- Creates per-slot
SlotQueueItementries (one perslotIndex) withseen = false. - Pushes the items into the
SlotQueue.
SlotFreed
When the marketplace signals a freed slot (needs repair), the sales module:
- Retrieves the request data for the
requestId. - Computes collateral for repair.
- Creates a
SlotQueueItem. - Pushes the item into the
SlotQueue.
RequestCancelled
When a request is cancelled, the sales module removes all queue items for that requestId.
RequestFulfilled
When a request is fulfilled, the sales module removes all queue items for that requestId and notifies active agents bound to the request.
RequestFailed
When a request fails, the sales module removes all queue items for that requestId and notifies active agents bound to the request.
SlotFilled
When a slot is filled, the sales module removes the queue item for that specific (requestId, slotIndex) and notifies the active agent for that slot.
SlotReservationsFull
When the marketplace signals that reservations are full, the sales module removes the queue item for that specific (requestId, slotIndex).
Reservations
The Reservations module manages both Availabilities and Reservations. When an Availability is created, it reserves bytes in the storage module so no other modules can use those bytes. Before a dataset for a slot is downloaded, a Reservation is created, and the freeSize of the Availability is reduced. When bytes are downloaded, the reservation of those bytes in the storage module is released. Accounting of both reserved bytes in the storage module and freeSize in the Availability are cleaned up upon completion of the state machine.
graph TD
A[Availability] -->|creates| R[Reservation]
A -->|reserves bytes in| SM[Storage Module]
R -->|reduces| AF[Availability.freeSize]
R -->|downloads data| D[Dataset]
D -->|releases bytes to| SM
TC[Terminal State] -->|triggers cleanup| C[Cleanup]
C -->|returns bytes to| AF
C -->|deletes| R
C -->|returns collateral to| A
Hooks
- onStore: streams data into the node's storage
- onProve: produces proofs for initial and periodic proving
- onExpiryUpdate: notifies the client node of a change in the expiry data
- onSale: notifies that the host is now responsible for the slot
- onClear: notification emitted once the state machine has concluded; used to reconcile Availability bytes and reserved bytes in the storage module
- onCleanUp: cleanup hook called in terminal states to release resources, delete reservations, and return collateral to availabilities
Error Handling
- Always catch
CancelledErrorfromnim-chronosand log a trace, exiting gracefully - Catch
CatchableError, log it, and route toSaleErrored
Cleanup
Cleanup releases resources held by a sales agent and optionally requeues the slot.
- Return reserved bytes to the availability if a reservation exists
- Delete the reservation and return any remaining collateral
- If
reprocessSlotis true, push the slot back into the queue marked as seen - Remove the agent from the sales set and track the removal future
Resource Management Approach
The nim-codex implementation uses Availabilities and Reservations to manage local storage resources:
Reservation Management
- Maintain
AvailabilityandReservationrecords locally - Match incoming slot requests to available capacity using prioritisation rules
- Lock capacity and collateral when creating a reservation
- Release reserved bytes progressively during download and free all remaining resources in terminal states
Note: Availabilities and Reservations are completely local to the Storage Provider implementation and are not visible at the protocol level. They provide one approach to managing storage capacity, but other implementations may use different resource management strategies.
Protocol Compliance Note: The Storage Provider implementation described above is specific to nim-codex. The only normative requirements for Storage Providers are defined in the Storage Provider Role section of Part I. Implementations must satisfy those protocol requirements but may use completely different internal designs.
Client Implementation
The nim-codex reference implementation provides a complete Client implementation with state machine management for storage request lifecycles. This section documents the nim-codex approach.
The nim-codex implementation uses a state machine pattern to manage purchase lifecycles, providing deterministic state transitions, explicit terminal states, and recovery support. The state machine definitions (state identifiers, transitions, state descriptions, requirements, data models, and interfaces) are documented in the subsections below.
Note: The Purchase module terminology and state machine design are specific to the nim-codex implementation. The protocol only requires that clients interact with the marketplace smart contract as specified in the Client Role section.
State Identifiers
- PurchasePending:
pending - PurchaseSubmitted:
submitted - PurchaseStarted:
started - PurchaseFinished:
finished - PurchaseErrored:
errored - PurchaseCancelled:
cancelled - PurchaseFailed:
failed - PurchaseUnknown:
unknown
General Rules for All States
- If a
CancelledErroris raised, the state machine logs the cancellation message and takes no further action. - If a
CatchableErroris raised, the state machine moves toerroredwith the error message.
State Transitions
|
v
------------------------- unknown
| / /
v v /
pending ----> submitted ----> started ---------> finished <----/
\ \ /
\ ------------> failed <----/
\ /
--> cancelled <-----------------------
Note:
Any state can transition to errored upon a CatchableError.
failed is an intermediate state before errored.
finished, cancelled, and errored are terminal states.
State Descriptions
Pending State (pending)
A storage request is being created by making a call on-chain. If the storage request creation fails, the state machine moves to the errored state with the corresponding error.
Submitted State (submitted)
The storage request has been created and the purchase waits for the request to start. When it starts, an on-chain event RequestFulfilled is emitted, triggering the subscription callback, and the state machine moves to the started state. If the expiry is reached before the callback is called, the state machine moves to the cancelled state.
Started State (started)
The purchase is active and waits until the end of the request, defined by the storage request parameters, before moving to the finished state. A subscription is made to the marketplace to be notified about request failure. If a request failure is notified, the state machine moves to failed.
Marketplace subscription signature:
method subscribeRequestFailed*(market: Market, requestId: RequestId, callback: OnRequestFailed): Future[Subscription] {.base, async.}
Finished State (finished)
The purchase is considered successful and cleanup routines are called. The purchase module calls marketplace.withdrawFunds to release the funds locked by the marketplace:
method withdrawFunds*(market: Market, requestId: RequestId) {.base, async: (raises: [CancelledError, MarketError]).}
After that, the purchase is done; no more states are called and the state machine stops successfully.
Failed State (failed)
If the marketplace emits a RequestFailed event, the state machine moves to the failed state and the purchase module calls marketplace.withdrawFunds (same signature as above) to release the funds locked by the marketplace. After that, the state machine moves to errored.
Cancelled State (cancelled)
The purchase is cancelled and the purchase module calls marketplace.withdrawFunds to release the funds locked by the marketplace (same signature as above). After that, the purchase is terminated; no more states are called and the state machine stops with the reason of failure as error.
Errored State (errored)
The purchase is terminated; no more states are called and the state machine stops with the reason of failure as error.
Unknown State (unknown)
The purchase is in recovery mode, meaning that the state has to be determined. The purchase module calls the marketplace to get the request data (getRequest) and the request state (requestState):
method getRequest*(market: Market, id: RequestId): Future[?StorageRequest] {.base, async: (raises: [CancelledError]).}
method requestState*(market: Market, requestId: RequestId): Future[?RequestState] {.base, async.}
Based on this information, it moves to the corresponding next state.
Note: Functional and non-functional requirements for the client role are summarized in the Codex Marketplace Specification. The requirements listed below are specific to the nim-codex Purchase module implementation.
Functional Requirements
Purchase Definition
- Every purchase MUST represent exactly one
StorageRequest - The purchase MUST have a unique, deterministic identifier
PurchaseIdderived fromrequestId - It MUST be possible to restore any purchase from its
requestIdafter a restart - A purchase is considered expired when the expiry timestamp in its
StorageRequestis reached before the request start, i.e, an eventRequestFulfilledis emitted by themarketplace
State Machine Progression
- New purchases MUST start in the
pendingstate (submission flow) - Recovered purchases MUST start in the
unknownstate (recovery flow) - The state machine MUST progress step-by-step until a deterministic terminal state is reached
- The choice of terminal state MUST be based on the
RequestStatereturned by themarketplace
Failure Handling
- On marketplace failure events, the purchase MUST immediately transition to
erroredwithout retries - If a
CancelledErroris raised, the state machine MUST log the cancellation and stop further processing - If a
CatchableErroris raised, the state machine MUST transition toerroredand record the error
Non-Functional Requirements
Execution Model
A purchase MUST be handled by a single thread; only one worker SHOULD process a given purchase instance at a time.
Reliability
load supports recovery after process restarts.
Performance
State transitions should be non-blocking; all I/O is async.
Logging
All state transitions and errors should be clearly logged for traceability.
Safety
- Avoid side effects during
newother than initialising internal fields;on-chaininteractions are delegated to states usingmarketplacedependency. - Retry policy for external calls.
Testing
- Unit tests check that each state handles success and error properly.
- Integration tests check that a full purchase flows correctly through states.
Protocol Compliance Note: The Client implementation described above is specific to nim-codex. The only normative requirements for Clients are defined in the Client Role section of Part I. Implementations must satisfy those protocol requirements but may use completely different internal designs.
Copyright
Copyright and related rights waived via CC0.
References
Normative
- RFC 2119 - Key words for use in LIPs to Indicate Requirement Levels
- Reed-Solomon algorithm - Erasure coding algorithm used for data encoding
- CIDv1 - Content Identifier specification
- multihash - Self-describing hashes
- Proof-of-Data-Possession - Zero-knowledge proof system for storage verification
- Original Codex Marketplace Spec - Source specification for this document
Informative
- Codex Implementation - Reference implementation in Nim
- Codex market implementation - Marketplace module implementation
- Codex Sales Component Spec - Storage Provider implementation details
- Codex Purchase Component Spec - Client implementation details
- Nim Chronos - Async/await framework for Nim
- Storage proof timing design - Proof timing mechanism
CODEX-PROVER
| Field | Value |
|---|---|
| Name | Codex Prover Module |
| Slug | 81 |
| Status | deprecated |
| Category | Standards Track |
| Editor | Codex Team |
| Contributors | Filip Dimitrijevic [email protected] |
Timeline
- 2026-01-22 —
e356a07— Chore/add makefile (#271) - 2026-01-22 —
af45aae— chore: deprecate Marketplace-related specs (#268) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This specification defines the Proving module for Codex, which provides a succinct, publicly verifiable way to check that storage providers still hold the data they committed to. The proving module samples cells from stored slots, and generates zero-knowledge proofs that tie those samples and Merkle paths back to the published dataset root commitment. The marketplace contract verifies these proofs on-chain and uses the result to manage incentives such as payments and slashing.
Background / Rationale / Motivation
In decentralized storage networks such as Codex, one of the main challenges is ensuring durability and availability of data stored by storage providers. To achieve durability, random sampling combined with erasure coding is used to provide probabilistic guarantees while touching only a tiny fraction of the stored data per challenge.
The proving module addresses this challenge by:
- Checking for storage proof requests from the marketplace
- Sampling cells from slots in the stored dataset and constructing Merkle proofs
- Generating zero-knowledge proofs for randomly selected cells in stored slots
- Submitting proofs to the on-chain marketplace smart contract for verification
The proving module consists of three main sub-components:
- Sampler: Derives random sample indices from public entropy and slot commitments, then generates the proof input
- Prover: Produces succinct ZK proofs for valid proof inputs and verifies such proofs
- ZK Circuit: Defines the logic for sampling, cell hashing, and Merkle tree membership checks
The proving module relies on BlockStore for block-level storage access
and SlotsBuilder to build initial commitments to the stored data.
BlockStore is Codex's local storage abstraction that provides
block retrieval by CID.
SlotsBuilder constructs the Merkle tree commitments for slots and datasets
(see CODEX-SLOT-BUILDER for details).
The incentives involved, including collateral and slashing,
are handled by the marketplace logic.
Theory / Semantics
Terminology
| Term | Description |
|---|---|
| Storage Client (SC) | A node that participates in Codex to buy storage. |
| Storage Provider (SP) | A node that participates in Codex by selling disk space to other nodes. |
| Dataset | A set of fixed-size slots provided by possibly different storage clients. |
| Cell | Smallest circuit sampling unit (e.g., 2 KiB), its bytes are packed into field elements and hashed. |
| Block | Network transfer unit (e.g., 64 KiB) consists of multiple cells, used for transport. |
| Slot | The erasure-coded fragment of a dataset stored by a single storage provider. Proof requests are related to slots. |
| Commitment | Cryptographic binding (and hiding if needed) to specific data. In Codex this is a Poseidon2 Merkle root (e.g., Dataset Root or Slot Root). It allows anyone to verify proofs against the committed content. |
| Dataset Root / Slot Root | Poseidon2 Merkle roots used as public commitments in the circuit. Not the SHA-256 content tree used for CIDs. |
| Entropy | Public randomness (e.g., blockhash) used to derive random sample indices. |
| Witness | Private zk circuit inputs. |
| Public Inputs | Values known to or shared with the verifier. |
| Groth16 | Succinct zk-SNARK proof system used by Codex. |
| Proof Window | The time/deadline within which the storage provider must submit a valid proof. |
Data Commitment
In Codex, a dataset is split into numSlots slots which are the ones sampled. Each slot is split into nCells fixed-size cells. Since networking operates on blocks, cells are combined to form blocks where each block contains BLOCKSIZE/CELLSIZE cells. The following describes how raw bytes become commitments in Codex (cells -> blocks -> slots -> dataset):
Cell hashing:
- Split each cell's bytes into chunks (31-byte for BN254), map to field elements (little-endian). Pad the last chunk with
10*. - Hash the resulting field-element stream with a Poseidon2 sponge.
Block tree (cell -> block):
- A network block in Codex is 64 KiB and contains 32 cells of 2 KiB each.
- Build a Merkle tree of depth 5 over the 32 cell hashes. The root is the block hash.
Slot tree (block -> slot):
- For all blocks in a slot, build a Merkle tree over their block hashes
(root of block trees).
The number of leaves is expected to be a power of two
(in Codex, the
SlotsBuilderpads the slots). The Slot tree root is the public commitment that is sampled. See CODEX-SLOT-BUILDER for the detailed slot building process.
Dataset tree (slot -> dataset):
- Build a Merkle tree over the slot trees roots to obtain the dataset root (this is different from SHA-256 CID used for content addressing). The dataset root is the public commitment to all slots hosted by a single storage provider.
Codex Merkle Tree Conventions
Codex extends the standard Merkle tree with a keyed compression that depends on
(a) whether a node is on the bottom (i.e. leaf layer) and
(b) whether a node is odd (has a single child) or even (two children).
These two bits are encoded as {0,1,2,3} and fed into the hash so tree shape
cannot be manipulated.
Steps in building the Merkle tree (bytes/leaves -> root):
Cell bytes are split into 31-byte chunks to fit in BN254,
each mapped little-endian into a BN254 field element.
10* padding is used.
Leaves (cells) are hashed with a Poseidon2 sponge with state size t=3
and rate r=2.
The sponge is initialized with IV (0,0, domSep) where:
domSep := 2^64 + 256*t + rate
This is a domain-separation constant.
When combining two child nodes x and y, Codex uses the keyed compression:
compress(x, y, key) = Poseidon2_permutation(x, y, key)[0]
where key encodes the two bits:
- bit 0: 1 if we're at the bottom layer, else 0
- bit 1: 1 if this is an odd node (only one child present), else 0
Special cases:
- Odd node with single child
x:compress(x, 0, key)(i.e., the missing sibling is zero). - Singleton tree (only one element in the tree): still apply one compression round.
- Merkle Paths need only sibling hashes: left/right direction is inferred from the binary decomposition of the leaf index, so you don't transmit direction flags.
Sampling
Sampling request:
Sampling begins when a proof is requested containing the entropy
(also called ProofChallenge).
A DataSampler instance is created for a specific slot and then used to
produce Sample records.
The sampler needs:
slotIndex: the index of the slot being proven. This is fixed when theDataSampleris constructed.entropy: public randomness (e.g., blockhash).nSamples: the number of cells to sample.
Derive indices:
The sampler derives deterministic cell indices from the challenge entropy and the slot commitment:
idx = H(entropy || slotRoot || counter) mod nCells
where counter = 1..nSamples and H is the Poseidon2 sponge (rate = 2)
with 10* padding.
The result is a sequence of indices in [0, nCells),
identical for any honest party given the same (entropy, slotRoot, nSamples).
Note that there is a chance however small that you would have multiple of
the same cell index samples purely by chance.
The chance of that depends on the slot and cell sizes;
the larger the slot and smaller the cell,
the lower the chance of landing on the same cell index.
Generate per-sample data:
- Fetch the
cellDatavia theBlockStoreandbuilder, and fetch the storedcell -> block,block -> slot,slot -> datasetMerkle paths. Note thatcell -> blockcan be built on the fly andslot -> datasetcan be reused for all samples in that slot.
Collect Proof Inputs:
The DataSampler collects the ProofInputs required for the zk proof system
which contains the following:
entropy: the challenge randomness.datasetRoot: the root of the dataset Merkle tree.slotIndex: the index of the proven slot.slotRoot: the Merkle root of the slot tree.nCellsPerSlot: total number of cells in the slot.nSlotsPerDataSet: total number of slots in the dataset.slotProof: theslot -> datasetMerkle path.samples: a list where each element is:cellData: the sampled cell encoded as field elements.merklePaths: the concatenation(cell -> block) || (block -> slot).
These ProofInputs are then passed to the prover to generate the succinct
ZK proof.
Proof Generation
To produce a zk storage proof, Codex uses a pluggable proving backend.
In practice, Groth16 over BN254 (altbn128) is used with circuits written
in Circom.
The Prover with ProofInputs calls the backend to create a succinct proof,
and optionally verifies it locally before submission.
ZK Circuit Specification
Circuit parameters (compile-time constants):
MAXDEPTH # max depth of slot tree (block -> slot)
MAXSLOTS # max number of slots in dataset (slot -> dataset)
CELLSIZE # cell size in bytes (e.g., 2048)
BLOCKSIZE # block size in bytes (e.g., 65536)
NSAMPLES # number of sampled cells per challenge (e.g. 100)
Public inputs:
datasetRoot # root of the dataset (slot -> dataset)
slotIndex # index of the slot being proven
entropy # public randomness used to derive sample indices
Witness (private inputs):
slotRoot # root of the slot (block -> slot) tree
slotProof # Merkle path for slot -> dataset
samples[]: # one entry per sampled cell:
cellData # the sampled cell encoded as field elements
merklePaths # (cell -> block) || (block -> slot) Merkle path
Constraints (informal):
For each sampled cell, the circuit enforces:
- Cell hashing: recompute the cell digest from
cellDatausing the Poseidon2 sponge (rate=2,10*padding). - Cell -> Block: verify inclusion of the cell digest in the block tree using the provided
cell -> blockpath. - Block -> Slot: verify inclusion of the block digest in the slot tree using the
block -> slotpath. - Slot -> Dataset: verify inclusion of
slotRootin dataset tree usingslotProof. - Sampling indices: recompute the required sample indices from
(entropy, slotRoot, NSAMPLES)and check that the supplied samples correspond exactly to those indices.
Output (proof):
- Groth16 proof over BN254: the tuple (A ∈ G₁, B ∈ G₂, C ∈ G₁), referred to in code as
CircomProof.
Verification:
- The verifier (on-chain or off-chain) checks the
proofagainst thepublic inputsusing the circuit'sverifying key(derived from theCRSgenerated at setup). - On EVM chains, verification leverages BN254 precompiles.
Functional Requirements
Data Commitment:
- Fetch existing slot commitments using
BlockStoreandSlotsBuilder:cell -> block -> slotMerkle trees for each slot in the locally stored dataset. - Fetch dataset commitment:
slot -> datasetverification tree root. - Proof material: retrieve cell data (as field elements).
Sampling:
- Checks for marketplace challenges per slot.
- Random sampling: Derive
nSamplescell indices for theslotIndexfrom(entropy, slotRoot). - For each sampled cell, fetch:
cellData(as field elements) and Merkle paths (allcell -> block -> slot -> dataset) - Generate
ProofInputscontaining the public inputs (datasetRoot,slotIndex,entropy) and private witness (cellData,slotRoot,MerklePaths).
Proof Generation:
- Given
ProofInputs, use the configured backend (Groth16 over BN254) to create a succinct Groth16 proof. - The circuit enforces the same Merkle layout and Poseidon2 hashing used for commitments.
Non-Functional Requirements
Performance / Latency:
- End-to-end (sample -> prove -> submit) completes within the on-chain proof window with some safety margin.
- Small Proof size, e.g. Groth16/BN254 plus public inputs.
- On-chain verification cost is minimal.
- Support concurrent proving for multiple slots.
Security & Correctness:
- Soundness and completeness: only accept valid proofs, invalid inputs must not yield accepted proofs.
- Commitment integrity: proofs are checked against the publicly available Merkle commitments to the stored data.
- Entropy binding: sample indices must be derived from the on-chain entropy and the slot commitment. This binds the proofs to specific cell indices and time period, and makes the challenge unpredictable until the period begins. This prevents storage providers from precomputing the proofs or selectively retaining a set of cells.
Wire Format Specification / Syntax
Data Models
SlotsBuilder
SlotsBuilder*[T, H] = ref object of RootObj
store: BlockStore
manifest: Manifest # current manifest
strategy: IndexingStrategy # indexing strategy
cellSize: NBytes # cell size
numSlotBlocks: Natural
# number of blocks per slot (should yield a power of two number of cells)
slotRoots: seq[H] # roots of the slots
emptyBlock: seq[byte] # empty block
verifiableTree: ?T # verification tree (dataset tree)
emptyDigestTree: T # empty digest tree for empty blocks
Contains references to the BlockStore, current Manifest, indexing strategy, cell size, number of blocks per slot, slot roots, empty block data, the verification tree (dataset tree), and empty digest tree for empty blocks.
DataSampler
DataSampler*[T, H] = ref object of RootObj
index: Natural
blockStore: BlockStore
builder: SlotsBuilder[T, H]
Contains the slot index, reference to BlockStore, and reference to SlotsBuilder.
Prover
Prover* = ref object of RootObj
backend: AnyBackend
store: BlockStore
nSamples: int
Contains the proving backend, reference to BlockStore, and number of samples.
Sample
Sample*[H] = object
cellData*: seq[H]
merklePaths*: seq[H]
Contains the sampled cell data as a sequence of hash elements and the Merkle paths as a sequence of hash elements.
PublicInputs
PublicInputs*[H] = object
slotIndex*: int
datasetRoot*: H
entropy*: H
Contains the slot index, dataset root hash, and entropy hash.
ProofInputs
ProofInputs*[H] = object
entropy*: H
datasetRoot*: H
slotIndex*: Natural
slotRoot*: H
nCellsPerSlot*: Natural
nSlotsPerDataSet*: Natural
slotProof*: seq[H]
samples*: seq[Sample[H]]
Contains entropy, dataset root, slot index, slot root, number of cells per slot, number of slots per dataset, slot proof as a sequence of hashes, and samples as a sequence of Sample objects.
CircomCompat
CircomCompat* = object
slotDepth: int # max depth of the slot tree
datasetDepth: int # max depth of dataset tree
blkDepth: int # depth of the block merkle tree (pow2 for now)
cellElms: int # number of field elements per cell
numSamples: int # number of samples per slot
r1csPath: string # path to the r1cs file
wasmPath: string # path to the wasm file
zkeyPath: string # path to the zkey file
backendCfg: ptr CircomBn254Cfg
vkp*: ptr CircomKey
Contains configuration for Circom compatibility including tree depths, paths to circuit artifacts (r1cs, wasm, zkey), backend configuration pointer, and verifying key pointer.
Proof
#![allow(unused)] fn main() { pub struct Proof { pub a: G1, pub b: G2, pub c: G1, } }
Groth16 proof structure containing three elements: a and c in G₁, and b in G₂.
G1
#![allow(unused)] fn main() { pub struct G1 { pub x: [u8; 32], pub y: [u8; 32], } }
Elliptic curve point in G₁ with x and y coordinates as 32-byte arrays.
G2
#![allow(unused)] fn main() { pub struct G2 { pub x: [[u8; 32]; 2], pub y: [[u8; 32]; 2], } }
Elliptic curve point in G₂ with x and y coordinates, each consisting of two 32-byte arrays.
VerifyingKey
#![allow(unused)] fn main() { pub struct VerifyingKey { pub alpha1: G1, pub beta2: G2, pub gamma2: G2, pub delta2: G2, pub ic: *const G1, pub ic_len: usize, } }
Groth16 verifying key structure containing alpha1 in G₁, beta2, gamma2, and delta2 in G₂, and an array of G₁ points (ic) with its length.
Interfaces
Sampler Interfaces
| Interface | Description | Input | Output |
|---|---|---|---|
new[T,H] | Construct a DataSampler for a specific slot index. | index: Natural, blockStore: BlockStore, builder: SlotsBuilder[T,H] | DataSampler[T,H] |
getSample | Retrieve one sampled cell and its Merkle path(s) for a given slot. | cellIdx: int, slotTreeCid: Cid, slotRoot: H | Sample[H] |
getProofInput | Generate the full proof inputs for the proving circuit (calls getSample internally). | entropy: ProofChallenge, nSamples: Natural | ProofInputs[H] |
Prover Interfaces
| Interface | Description | Input | Output |
|---|---|---|---|
new | Construct a Prover with a block store and the backend proof system. | blockStore: BlockStore, backend: AnyBackend, nSamples: int | Prover |
prove | Produce a succinct proof for the given slot and entropy. | slotIdx: int, manifest: Manifest, entropy: ProofChallenge | (proofInputs, proof) |
verify | Verify a proof against its public inputs. | proof: AnyProof, proofInputs: AnyProofInputs | bool |
Circuit Interfaces
| Template | Description | Parameters | Inputs (signals) | Outputs (signals) |
|---|---|---|---|---|
SampleAndProve | Main component in the circuit. Verifies nSamples cells (cell->block->slot) and the slot->dataset path, binding the proof to dataSetRoot, slotIndex, and entropy. | maxDepth, maxLog2NSlots, blockTreeDepth, nFieldElemsPerCell, nSamples | entropy, dataSetRoot, slotIndex, slotRoot, nCellsPerSlot, nSlotsPerDataSet, slotProof[maxLog2NSlots], cellData[nSamples][nFieldElemsPerCell], merklePaths[nSamples][maxDepth] | - |
ProveSingleCell | Verifies one sampled cell: hashes cellData with Poseidon2 and checks the concatenated Merkle path up to slotRoot. | nFieldElemsPerCell, botDepth, maxDepth | slotRoot, data[nFieldElemsPerCell], lastBits[maxDepth], indexBits[maxDepth], maskBits[maxDepth+1], merklePath[maxDepth] | - |
RootFromMerklePath | Reconstructs a Merkle root from a leaf and path using KeyedCompression. | maxDepth | leaf, pathBits[maxDepth], lastBits[maxDepth], maskBits[maxDepth+1], merklePath[maxDepth] | recRoot |
CalculateCellIndexBits | Derives the index bits for a sampled cell from (entropy, slotRoot, counter), masked by cellIndexBitMask. | maxLog2N | entropy, slotRoot, counter, cellIndexBitMask[maxLog2N] | indexBits[maxLog2N] |
All parameters are compile-time constants that are defined when building the circuit.
Circuit Utility Templates
| Template | Description | Parameters | Inputs (signals) | Outputs (signals) |
|---|---|---|---|---|
Poseidon2_hash_rate2 | Poseidon2 fixed-length hash (rate = 2). Used for hashing cell field-elements. | n: number of field elements to hash. | inp[n]: array of field elements to hash. | out: Poseidon2 hash digest. |
PoseidonSponge | Generic Poseidon2 sponge (absorb/squeeze). | t: sponge state width, capacity: capacity part of the state, input_len: number of elements to absorb, output_len: number of elements to squeeze | inp[input_len]: field elements to absorb. | out[output_len]: field elements squeezed. |
KeyedCompression | Keyed 2->1 compression where key ∈ {0,1,2,3}. | - | key, inp[2]: left and right child node digests. | out: parent node digest |
ExtractLowerBits | Extracts the lower n bits of inp (LSB-first). | n: number of low bits to extract | inp: field elements to extract. | out[n]: extracted bits. |
Log2 | Checks inp == 2^out with 0 < out <= n. Also emits a mask vector with ones for indices < out. | n: max allowed bit width. | inp: field element. | out: exponent, mask[n+1]: prefix mask. |
CeilingLog2 | Computes ceil(log2(inp)) and returns the bit-decomposition and a mask. | n: bit width of input and output. | inp: field element. | out: ceil(log2(inp)), bits[n]: bit decomposition, mask[n+1]: prefix mask |
BinaryCompare | Compares two n-bit numbers A and B (LSB-first); outputs -1 if A<B, 0 if equal, +1 if A>B. | n: bit width of A and B | A[n], B[n] | out: comparison result. |
Security/Privacy Considerations
Entropy Binding
Sample indices must be derived from the on-chain entropy and the slot commitment. This binds the proofs to specific cell indices and time period, and makes the challenge unpredictable until the period begins. This prevents storage providers from precomputing the proofs or selectively retaining a set of cells.
Commitment Integrity
Proofs are checked against the publicly available Merkle commitments to the stored data. The proving module uses Poseidon2 Merkle roots as public commitments which allow anyone to verify proofs against the committed content.
Soundness and Completeness
The zero-knowledge proof system must only accept valid proofs. Invalid inputs must not yield accepted proofs. The circuit enforces:
- Cell hashing using Poseidon2 sponge
- Merkle tree membership checks at all levels (cell -> block, block -> slot, slot -> dataset)
- Correct sampling index derivation from entropy and slot root
Keyed Merkle Tree Compression
Codex extends the standard Merkle tree with a keyed compression that encodes whether a node is on the bottom layer and whether a node is odd or even. These bits are fed into the hash so tree shape cannot be manipulated.
Proof Window
Storage providers must submit valid proofs within the proof window deadline. This time constraint ensures timely verification and allows the marketplace to manage incentives appropriately.
Rationale
This specification is based on the Proving module component specification from the Codex project.
Probabilistic Verification
The proving module uses random sampling combined with erasure coding to provide probabilistic guarantees of data availability while touching only a tiny fraction of the stored data per challenge. This approach balances security with efficiency, as verifying the entire dataset for every challenge would be prohibitively expensive.
Groth16 over BN254
The specification uses Groth16 zero-knowledge proofs over the BN254 elliptic curve. This choice provides:
- Succinct proofs: Small proof size enables efficient on-chain verification
- EVM compatibility: BN254 precompiles on EVM chains minimize verification costs
- Strong security: Groth16 provides computational zero-knowledge and soundness
Poseidon2 Hash Function
Poseidon2 is used for all cryptographic commitments (cell hashing, Merkle tree construction). Poseidon2 is optimized for zero-knowledge circuits, resulting in significantly fewer constraints compared to traditional hash functions like SHA-256.
Keyed Compression Design
The keyed compression scheme that depends on node position (bottom layer vs. internal) and child count (odd vs. even) prevents tree shape manipulation attacks. By feeding these bits into the hash function, the commitment binds to the exact tree structure.
Pluggable Backend Architecture
The proving module uses a pluggable proving backend abstraction. While the current implementation uses Groth16 over BN254 with Circom circuits, this architecture allows for future flexibility to adopt different proof systems or curves without changing the core proving module logic.
Entropy-Based Sampling
Deriving sample indices deterministically from public entropy (e.g., blockhash) and the slot commitment ensures that:
- Challenges are unpredictable until the period begins
- Storage providers cannot precompute proofs
- Storage providers cannot selectively retain only a subset of cells
- Any honest party can verify the sampling was done correctly
Copyright
Copyright and related rights waived via CC0.
References
Normative
- Codex: GitHub - codex-storage/nim-codex
- Codex Prover Specification: Codex Docs - Component Specification - Prover
- CODEX-SLOT-BUILDER: Codex Slot Builder specification (defines
SlotsBuilderand slot commitment construction)
Informative
- Codex Storage Proofs Circuits: GitHub - codex-storage/codex-storage-proofs-circuits - Circom implementations of Codex's proof circuits targeting Groth16 over BN254
- Nim Circom Compat: GitHub - codex-storage/nim-circom-compat - Nim bindings that load compiled Circom artifacts
- Circom Compat FFI: GitHub - codex-storage/circom-compat-ffi - Rust library with C ABI for running Arkworks Groth16 proving and verification on BN254
- Groth16: Jens Groth. "On the Size of Pairing-based Non-interactive Arguments." EUROCRYPT 2016
- Poseidon2: Lorenzo Grassi, Dmitry Khovratovich, Markus Schofnegger. "Poseidon2: A Faster Version of the Poseidon Hash Function." IACR ePrint 2023/323
CODEX-SLOT-BUILDER
| Field | Value |
|---|---|
| Name | Codex Slot Builder |
| Slug | 78 |
| Status | deprecated |
| Contributors | Jimmy Debe [email protected] |
Timeline
- 2026-01-22 —
e356a07— Chore/add makefile (#271) - 2026-01-22 —
af45aae— chore: deprecate Marketplace-related specs (#268) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This document describes the Codex slot builder mechanism. Slots used in the Codex protocol are an important component of node collaboration in the network.
Background
The Codex protocol places a dataset into blocks before sending a storage request to the network. Slots control and facilitate the distribution of the data blocks to participating storage providers. The mechanism builds individual Merkle trees for each slot, enabling cell-level proof generation, and constructs a root verification tree over all slot roots.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
A Codex client wanting to present a dataset to the network will present a set of erasure encoded data blocks, as described in the CODEX-ERASURE-CODING specification. These data blocks will be placed into slots for storage providers to access. The slot building process MUST construct a block digest Merkle tree from the data blocks. The root hashes from this tree are used as the leaves in a slot merkle tree.
The prepared dataset is presented to storage providers in the form of slots. A slot represents the location of a data block cell with an open storage contract. Storage providers SHOULD be able to locate a specific data block and all the details of the storage contract. See, the CODEX-MARKETPLACE specification.
Construct a Slot Tree
Block Digest Tree
A slot stores a list of root hashes that help with the retrieval of a dataset. The block digest tree SHOULD be constructed before building any slots. A data block is divided into cells that are hashed. The block size MUST be divisible by the cell size for the block digest tree construction.
$$ \text{Cell size} \mid \text{Block size (in bytes)} $$
A block digest tree SHOULD contain the unique root hashes of blocks of the entire dataset, which MAY be based on the Poseidon2 algorithm. The result of one digest tree will be represented by the root hash of the tree.
Slot Tree
A slot tree represents one slot, which includes the list of digest root hashes. If a block is empty, the slot branch SHOULD be a hash of an empty block. Some slots MAY be empty, depending on the size of the dataset.
$$ \text{Blocks per slot} = \frac{\text{Total blocks}}{\text{Number of slots}} $$
The cells per slot tree branch MUST be padded to a power of two. This will ensure a balanced slot Merkle tree.
$$ \text{Cells per slot} = \text{Blocks per slot} \times \text{Cells per block} $$
Below are the REQUIRED values to build a slot.
type SlotsBuilder*[T, H] = ref object of RootObj
store: BlockStore # Storage backend for blocks
manifest: Manifest # Current dataset manifest
strategy: IndexingStrategy # Block indexing strategy
cellSize: NBytes # Size of each cell in bytes
numSlotBlocks: Natural # Blocks per slot (including padding)
slotRoots: seq[H] # Computed slot root hashes
emptyBlock: seq[byte] # Pre-allocated empty block data
verifiableTree: ?T # Optional verification tree
emptyDigestTree: T # Pre-computed empty block tree
Verification Tree
Nodes within the network are REQUIRED to verify a dataset before retrieving it.
A verification tree is a Merkle proof derived from the slotRoot.
The entire dataset is not REQUIRED to construct the tree.
The following are the inputs to verify a proof:
type
H = array[32, byte]
Natural = uint64
type ProofInputs*[H] = object
entropy*: H # Randomness value
datasetRoot*: H # Dataset root hash
slotIndex*: Natural # Slot identifier
slotRoot*: H # Root hash of slot
nCellsPerSlot*: Natural # Cell count per slot
nSlotsPerDataSet*: Natural # Total slot count
slotProof*: seq[H] # Inclusion proof for slot in dataset
samples*: seq[Sample[H]] # Cell inclusion proofs
To verify, a node MUST recompute the root hash,
based on slotProof and the hash of the slotIndex,
to confirm that the slotIndex is a member of the dataset represented by datasetRoot.
Copyright
Copyright and related rights waived via CC0.
References
IFT-TS LIPs
IFT-TS builds public good protocols for the decentralised web. IFT-TS acts as a custodian for the protocols that live in the RFC-Index repository. With the goal of widespread adoption, IFT-TS will make sure the protocols adhere to a set of principles, including but not limited to liberty, security, privacy, decentralisation and inclusivity.
To learn more, visit IFT-TS Research
IFT-TS Raw Specifications
All IFT-TS specifications that have not reached draft status will live in this repository. To learn more about raw specifications, take a look at 1/COSS.
1/COSS
| Field | Value |
|---|---|
| Name | Consensus-Oriented Specification System |
| Slug | 1 |
| Status | draft |
| Category | Best Current Practice |
| Editor | Daniel Kaiser [email protected] |
| Contributors | Oskar Thoren [email protected], Pieter Hintjens [email protected], André Rebentisch [email protected], Alberto Barrionuevo [email protected], Chris Puttick [email protected], Yurii Rashkovskii [email protected], Jimmy Debe [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-11-04 —
dd397ad— Update Coss Date (#206) - 2024-10-09 —
d5e0072— cosmetic: fix external links in 1/COSS (#100) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-09 —
ed2c68f— 1/COSS: New RFC Process (#4) - 2024-02-01 —
3eaccf9— Update and rename COSS.md to coss.md - 2024-01-30 —
990d940— Rename COSS.md to COSS.md - 2024-01-27 —
6495074— Rename vac/rfcs/01/README.md to vac/01/COSS.md - 2024-01-25 —
bab16a8— Rename README.md to README.md - 2024-01-25 —
a9162f2— Create README.md
This document describes a consensus-oriented specification system (COSS) for building interoperable technical specifications. COSS is based on a lightweight editorial process that seeks to engage the widest possible range of interested parties and move rapidly to consensus through working code.
This specification is based on Unprotocols 2/COSS, used by the ZeromMQ project. It is equivalent except for some areas:
- recommending the use of a permissive licenses, such as CC0 (with the exception of this document);
- miscellaneous metadata, editor, and format/link updates;
- more inheritance from the IETF Standards Process, e.g. using RFC categories: Standards Track, Informational, and Best Common Practice;
- standards track specifications SHOULD follow a specific structure that both streamlines editing, and helps implementers to quickly comprehend the specification
- specifications MUST feature a header providing specific meta information
- raw specifications will not be assigned numbers
- section explaining the IFT Request For Comments specification process managed by the IFT-TS service department
License
Copyright (c) 2008-26 the Editor and Contributors.
This Specification is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This specification is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program; if not, see gnu.org.
Change Process
This document is governed by the 1/COSS (COSS).
Language
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Goals
The primary goal of COSS is to facilitate the process of writing, proving, and improving new technical specifications. A "technical specification" defines a protocol, a process, an API, a use of language, a methodology, or any other aspect of a technical environment that can usefully be documented for the purposes of technical or social interoperability.
COSS is intended to above all be economical and rapid, so that it is useful to small teams with little time to spend on more formal processes.
Principles:
- We aim for rough consensus and running code; inspired by the IETF Tao.
- Specifications are small pieces, made by small teams.
- Specifications should have a clearly responsible editor.
- The process should be visible, objective, and accessible to anyone.
- The process should clearly separate experiments from solutions.
- The process should allow deprecation of old specifications.
Specifications should take minutes to explain, hours to design, days to write, weeks to prove, months to become mature, and years to replace. Specifications have no special status except that accorded by the community.
Architecture
COSS is designed around fast, easy to use communications tools. Primarily, COSS uses a wiki model for editing and publishing specifications texts.
- The domain is the conservancy for a set of specifications.
- The domain is implemented as an Internet domain.
- Each specification is a document together with references and attached resources.
- A sub-domain is a initiative under a specific domain.
Individuals can become members of the domain by completing the necessary legal clearance. The copyright, patent, and trademark policies of the domain must be clarified in an Intellectual Property policy that applies to the domain.
Specifications exist as multiple pages, one page per version, (discussed below in "Branching and Merging"), which should be assigned URIs that MAY include an number identifier.
Thus, we refer to new specifications by specifying its domain, its sub-domain and short name. The syntax for a new specification reference is:
<domain>/<sub-domain>/<shortname>
For example, this specification should be rfc.vac.dev/vac/COSS, if the status were raw.
A number will be assigned to the specification when obtaining draft status. New versions of the same specification will be assigned a new number. The syntax for a specification reference is:
<domain>/<sub-domain>/<number>/<shortname>
For example, this specification is rfc.vac.dev/vac/1/COSS. The short form 1/COSS may be used when referring to the specification from other specifications in the same domain.
Specifications (excluding raw specifications) carries a different number including branches.
COSS Lifecycle
Every specification has an independent lifecycle that documents clearly its current status. For a specification to receive a lifecycle status, a new specification SHOULD be presented by the team of the sub-domain. After discussion amongst the contributors has reached a rough consensus, as described in RFC7282, the specification MAY begin the process to upgrade it's status.
A specification has five possible states that reflect its maturity and contractual weight:

Raw Specifications
All new specifications are raw specifications. Changes to raw specifications can be unilateral and arbitrary. A sub-domain MAY use the raw status for new specifications that live under their domain. Raw specifications have no contractual weight.
Draft Specifications
When raw specifications can be demonstrated, they become draft specifications and are assigned numbers. Changes to draft specifications should be done in consultation with users. Draft specifications are contracts between the editors and implementers.
Stable Specifications
When draft specifications are used by third parties, they become stable specifications. Changes to stable specifications should be restricted to cosmetic ones, errata and clarifications. Stable specifications are contracts between editors, implementers, and end-users.
Deprecated Specifications
When stable specifications are replaced by newer draft specifications, they become deprecated specifications. Deprecated specifications should not be changed except to indicate their replacements, if any. Deprecated specifications are contracts between editors, implementers and end-users.
Retired Specifications
When deprecated specifications are no longer used in products, they become retired specifications. Retired specifications are part of the historical record. They should not be changed except to indicate their replacements, if any. Retired specifications have no contractual weight.
Deleted Specifications
Deleted specifications are those that have not reached maturity (stable) and were discarded. They should not be used and are only kept for their historical value. Only Raw and Draft specifications can be deleted.
Editorial control
A specification MUST have a single responsible editor, the only person who SHALL change the status of the specification through the lifecycle stages.
A specification MAY also have additional contributors who contribute changes to it. It is RECOMMENDED to use a process similar to C4 process to maximize the scale and diversity of contributions.
Unlike the original C4 process however, it is RECOMMENDED to use CC0 as a more permissive license alternative. We SHOULD NOT use GPL or GPL-like license. One exception is this specification, as this was the original license for this specification.
The editor is responsible for accurately maintaining the state of specifications, for retiring different versions that may live in other places and for handling all comments on the specification.
Branching and Merging
Any member of the domain MAY branch a specification at any point. This is done by copying the existing text, and creating a new specification with the same name and content, but a new number. Since raw specifications are not assigned a number, branching by any member of a sub-domain MAY differentiate specifications based on date, contributors, or version number within the document. The ability to branch a specification is necessary in these circumstances:
- To change the responsible editor for a specification, with or without the cooperation of the current responsible editor.
- To rejuvenate a specification that is stable but needs functional changes. This is the proper way to make a new version of a specification that is in stable or deprecated status.
- To resolve disputes between different technical opinions.
The responsible editor of a branched specification is the person who makes the branch.
Branches, including added contributions, are derived works and thus licensed under the same terms as the original specification. This means that contributors are guaranteed the right to merge changes made in branches back into their original specifications.
Technically speaking, a branch is a different specification, even if it carries the same name. Branches have no special status except that accorded by the community.
Conflict resolution
COSS resolves natural conflicts between teams and vendors by allowing anyone to define a new specification. There is no editorial control process except that practised by the editor of a new specification. The administrators of a domain (moderators) may choose to interfere in editorial conflicts, and may suspend or ban individuals for behaviour they consider inappropriate.
Specification Structure
Meta Information
Specifications MUST contain the following metadata. It is RECOMMENDED that specification metadata is specified as a YAML header (where possible). This will enable programmatic access to specification metadata.
| Key | Value | Type | Example |
|---|---|---|---|
| shortname | short name | string | 1/COSS |
| title | full name | string | Consensus-Oriented Specification System |
| status | status | string | draft |
| category | category | string | Best Current Practice |
| tags | 0 or several tags | list | waku-application, waku-core-protocol |
| editor | editor name/email | string | Oskar Thoren [email protected] |
| contributors | contributors | list | - Pieter Hintjens [email protected] - André Rebentisch [email protected] - Alberto Barrionuevo [email protected] - Chris Puttick [email protected] - Yurii Rashkovskii [email protected] |
IFT/Logos LIP Process
[!Note] This section is introduced to allow contributors to understand the IFT (Institute of Free Technology) Logos LIP specification process. Other organizations may make changes to this section according to their needs.
IFT-TS is a department under the IFT organization that provides RFC (Request For Comments) specification services. This service works to help facilitate the RFC process, assuring standards are followed. Contributors within the service SHOULD assist a sub-domain in creating a new specification, editing a specification, and promoting the status of a specification along with other tasks. Once a specification reaches some level of maturity by rough consensus, the specification SHOULD enter the Logos LIP process. Similar to the IETF working group adoption described in RFC6174, the Logos LIP process SHOULD facilitate all updates to the specification.
Specifications are introduced by projects, under a specific domain, with the intention of becoming technically mature documents. The IFT domain currently houses the following projects:
When a specification is promoted to draft status, the number that is assigned MAY be incremental or by the sub-domain and the Logos LIP process. Standards track specifications MUST be based on the Logos LIP template before obtaining a new status. All changes, comments, and contributions SHOULD be documented.
Conventions
Where possible editors and contributors are encouraged to:
- Refer to and build on existing work when possible, especially IETF specifications.
- Contribute to existing specifications rather than reinvent their own.
- Use collaborative branching and merging as a tool for experimentation.
- Use Semantic Line Breaks: sembr.
Appendix A. Color Coding
It is RECOMMENDED to use color coding to indicate specification's status. Color coded specifications SHOULD use the following color scheme:
2/MVDS
| Field | Value |
|---|---|
| Name | Minimum Viable Data Synchronization |
| Slug | 2 |
| Status | stable |
| Editor | Sanaz Taheri [email protected] |
| Contributors | Dean Eigenmann [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-06-28 —
a5b24ac— fix_: broken image links (#81) - 2024-02-01 —
0253d53— Rename MVDS.md to mvds.md - 2024-01-30 —
70326d1— Rename MVDS.md to MVDS.md - 2024-01-27 —
472a7fd— Rename vac/rfcs/02/README.md to vac/02/MVDS.md - 2024-01-25 —
4362a7b— Create README.md
In this specification, we describe a minimum viable protocol for data synchronization inspired by the Bramble Synchronization Protocol (BSP). This protocol is designed to ensure reliable messaging between peers across an unreliable peer-to-peer (P2P) network where they may be unreachable or unresponsive.
We present a reference implementation1 including a simulation to demonstrate its performance.
Definitions
| Term | Description |
|---|---|
| Peer | The other nodes that a node is connected to. |
| Record | Defines a payload element of either the type OFFER, REQUEST, MESSAGE or ACK |
| Node | Some process that is able to store data, do processing and communicate for MVDS. |
Wire Protocol
Secure Transport
This specification does not define anything related to the transport of packets. It is assumed that this is abstracted in such a way that any secure transport protocol could be easily implemented. Likewise, properties such as confidentiality, integrity, authenticity and forward secrecy are assumed to be provided by a layer below.
Payloads
Payloads are implemented using protocol buffers v3.
syntax = "proto3";
package vac.mvds;
message Payload {
repeated bytes acks = 5001;
repeated bytes offers = 5002;
repeated bytes requests = 5003;
repeated Message messages = 5004;
}
message Message {
bytes group_id = 6001;
int64 timestamp = 6002;
bytes body = 6003;
}
The payload field numbers are kept more "unique" to ensure no overlap with other protocol buffers.
Each payload contains the following fields:
- Acks: This field contains a list (can be empty)
of
message identifiersinforming the recipient that sender holds a specific message. - Offers: This field contains a list (can be empty)
of
message identifiersthat the sender would like to give to the recipient. - Requests: This field contains a list (can be empty)
of
message identifiersthat the sender would like to receive from the recipient. - Messages: This field contains a list of messages (can be empty).
Message Identifiers: Each message has a message identifier calculated by
hashing the group_id, timestamp and body fields as follows:
HASH("MESSAGE_ID", group_id, timestamp, body);
Group Identifiers: Each message is assigned into a group
using the group_id field,
groups are independent synchronization contexts between peers.
The current HASH function used is sha256.
Synchronization
State
We refer to state as set of records for the types OFFER, REQUEST and
MESSAGE that every node SHOULD store per peer.
state MUST NOT contain ACK records as we do not retransmit those periodically.
The following information is stored for records:
- Type - Either
OFFER,REQUESTorMESSAGE - Send Count - The amount of times a record has been sent to a peer.
- Send Epoch - The next epoch at which a record can be sent to a peer.
Flow
A maximum of one payload SHOULD be sent to peers per epoch,
this payload contains all ACK, OFFER, REQUEST and
MESSAGE records for the specific peer.
Payloads are created every epoch,
containing reactions to previously received records by peers or
new records being sent out by nodes.
Nodes MAY have two modes with which they can send records:
BATCH and INTERACTIVE mode.
The following rules dictate how nodes construct payloads
every epoch for any given peer for both modes.
NOTE: A node may send messages both in interactive and in batch mode.
Interactive Mode
- A node initially offers a
MESSAGEwhen attempting to send it to a peer. This means anOFFERis added to the next payload and state for the given peer. - When a node receives an
OFFER, aREQUESTis added to the next payload and state for the given peer. - When a node receives a
REQUESTfor a previously sentOFFER, theOFFERis removed from the state and the correspondingMESSAGEis added to the next payload and state for the given peer. - When a node receives a
MESSAGE, theREQUESTis removed from the state and anACKis added to the next payload for the given peer. - When a node receives an
ACK, theMESSAGEis removed from the state for the given peer. - All records that require retransmission are added to the payload,
given
Send Epochhas been reached.

Figure 1: Delivery without retransmissions in interactive mode.
Batch Mode
- When a node sends a
MESSAGE, it is added to the next payload and the state for the given peer. - When a node receives a
MESSAGE, anACKis added to the next payload for the corresponding peer. - When a node receives an
ACK, theMESSAGEis removed from the state for the given peer. - All records that require retransmission are added to the payload,
given
Send Epochhas been reached.

Figure 2: Delivery without retransmissions in batch mode.
NOTE: Batch mode is higher bandwidth whereas interactive mode is higher latency.
Retransmission
The record of the type Type SHOULD be retransmitted
every time Send Epoch is smaller than or equal to the current epoch.
Send Epoch and Send Count MUST be increased every time a record is retransmitted.
Although no function is defined on how to increase Send Epoch,
it SHOULD be exponentially increased until reaching an upper bound
where it then goes back to a lower epoch in order to
prevent a record's Send Epoch's from becoming too large.
NOTE: We do not retransmission
ACKs as we do not know when they have arrived, therefore we simply resend them every time we receive aMESSAGE.
Formal Specification
MVDS has been formally specified using TLA+: https://github.com/vacp2p/formalities/tree/master/MVDS.
Acknowledgments
- Preston van Loon
- Greg Markou
- Rene Nayman
- Jacek Sieka
Copyright
Copyright and related rights waived via CC0.
Footnotes
3/REMOTE-LOG
| Field | Value |
|---|---|
| Name | Remote log specification |
| Slug | 3 |
| Status | draft |
| Editor | Oskar Thorén [email protected] |
| Contributors | Dean Eigenmann [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-01 —
3fd8b5a— Update and rename README.md to remote-log.md - 2024-01-30 —
dce61fe— Create README.md
A remote log is a replication of a local log. This means a node can read data that originally came from a node that is offline.
This specification is complemented by a proof of concept implementation1.
Definitions
| Term | Definition |
|---|---|
| CAS | Content-addressed storage. Stores data that can be addressed by its hash. |
| NS | Name system. Associates mutable data to a name. |
| Remote log | Replication of a local log at a different location. |
Wire Protocol
Secure Transport, storage, and name system
This specification does not define anything related to: secure transport, content addressed storage, or the name system. It is assumed these capabilities are abstracted away in such a way that any such protocol can easily be implemented.
Payloads
Payloads are implemented using protocol buffers v3.
CAS service:
syntax = "proto3";
package vac.cas;
service CAS {
rpc Add(Content) returns (Address) {}
rpc Get(Address) returns (Content) {}
}
message Address {
bytes id = 1;
}
message Content {
bytes data = 1;
}
NS service:
syntax = "proto3";
package vac.cas;
service NS {
rpc Update(NameUpdate) returns (Response) {}
rpc Fetch(Query) returns (Content) {}
}
message NameUpdate {
string name = 1;
bytes content = 2;
}
message Query {
string name = 1;
}
message Content {
bytes data = 1;
}
message Response {
bytes data = 1;
}
Remote log:
syntax = "proto3";
package vac.cas;
message RemoteLog {
repeated Pair pair = 1;
bytes tail = 2;
message Pair {
bytes remoteHash = 1;
bytes localHash = 2;
bytes data = 3;
}
}
Synchronization
Roles
There are four fundamental roles:
- Alice
- Bob
- Name system (NS)
- Content-addressed storage (CAS)
The remote log protobuf is what is stored in the name system.
"Bob" can represent anything from 0 to N participants. Unlike Alice, Bob only needs read-only access to NS and CAS.
Flow

Remote log
The remote log lets receiving nodes know what data they are missing. Depending on the specific requirements and capabilities of the nodes and name system, the information can be referred to differently. We distinguish between three rough modes:
- Fully replicated log
- Normal sized page with CAS mapping
- "Linked list" mode - minimally sized page with CAS mapping
Data format:
| H1_3 | H2_3 |
| H1_2 | H2_2 |
| H1_1 | H2_1 |
| ------------|
| next_page |
Here the upper section indicates a list of ordered pairs, and the lower section
contains the address for the next page chunk. H1 is the native hash function,
and H2 is the one used by the CAS. The numbers corresponds to the messages.
To indicate which CAS is used, a remote log SHOULD use a multiaddr.
Embedded data:
A remote log MAY also choose to embed the wire payloads that corresponds to the native hash. This bypasses the need for a dedicated CAS and additional round-trips, with a trade-off in bandwidth usage.
| H1_3 | | C_3 |
| H1_2 | | C_2 |
| H1_1 | | C_1 |
| -------------|
| next_page |
Here C stands for the content that would be stored at the CAS.
Both patterns can be used in parallel, e,g. by storing the last k messages
directly and use CAS pointers for the rest. Together with the next_page page
semantics, this gives users flexibility in terms of bandwidth and
latency/indirection, all the way from a simple linked list to a fully replicated
log. The latter is useful for things like backups on durable storage.
Next page semantics
The pointer to the 'next page' is another remote log entry, at a previous point in time.
Interaction with MVDS
vac.mvds.Message payloads are the only payloads that MUST be uploaded. Other messages types MAY be uploaded, depending on the implementation.
Acknowledgments
TBD.
Copyright
Copyright and related rights waived via CC0.
Footnotes
4/MVDS-META
| Field | Value |
|---|---|
| Name | MVDS Metadata Field |
| Slug | 4 |
| Status | draft |
| Editor | Sanaz Taheri [email protected] |
| Contributors | Dean Eigenmann [email protected], Andrea Maria Piana [email protected], Oskar Thorén [email protected] |
Timeline
- 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-02-01 —
3a396b5— Update and rename README.md to mvds-meta.md - 2024-01-30 —
2e80c3b— Create README.md
In this specification, we describe a method to construct message history that will aid the consistency guarantees of 2/MVDS. Additionally, we explain how data sync can be used for more lightweight messages that do not require full synchronization.
Motivation
In order for more efficient synchronization of conversational messages, information should be provided allowing a node to more effectively synchronize the dependencies for any given message.
Format
We introduce the metadata message which is used to convey information about a message and how it SHOULD be handled.
package vac.mvds;
message Metadata {
repeated bytes parents = 1;
bool ephemeral = 2;
}
Nodes MAY transmit a Metadata message by extending the MVDS message
with a metadata field.
message Message {
bytes group_id = 6001;
int64 timestamp = 6002;
bytes body = 6003;
+ Metadata metadata = 6004;
}
Fields
| Name | Description |
|---|---|
parents | list of parent message identifiers for the specific message. |
ephemeral | indicates whether a message is ephemeral or not. |
Usage
parents
This field contains a list of parent message identifiers
for the specific message.
It MUST NOT contain any messages as parent whose ack flag was set to false.
This establishes a directed acyclic graph (DAG)[^2] of persistent messages.
Nodes MAY buffer messages until dependencies are satisfied for causal consistency[^3], they MAY also pass the messages straight away for eventual consistency[^4].
A parent is any message before a new message that a node is aware of that has no children.
The number of parents for a given message is bound by [0, N],
where N is the number of nodes participating in the conversation,
therefore the space requirements for the parents field is O(N).
If a message has no parents it is considered a root. There can be multiple roots, which might be disconnected, giving rise to multiple DAGs.
ephemeral
When the ephemeral flag is set to false,
a node MUST send an acknowledgment when they have received and processed a message.
If it is set to true, it SHOULD NOT send any acknowledgment.
The flag is false by default.
Nodes MAY decide to not persist ephemeral messages, however they MUST NOT be shared as part of the message history.
Nodes SHOULD send ephemeral messages in batch mode. As their delivery is not needed to be guaranteed.
Copyright
Copyright and related rights waived via CC0.
Footnotes
1: 2/MVDS 2: directed_acyclic_graph 3: Jepsen. Causal Consistency Jepsen, LLC. 4: https://en.wikipedia.org/wiki/Eventual_consistency
25/LIBP2P-DNS-DISCOVERY
| Field | Value |
|---|---|
| Name | Libp2p Peer Discovery via DNS |
| Slug | 25 |
| Status | deleted |
| Editor | Hanno Cornelius [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-08 —
a3ad14e— Create libp2p-dns-discovery.md
25/LIBP2P-DNS-DISCOVERY specifies a scheme to implement libp2p
peer discovery via DNS for Waku v2.
The generalised purpose is to retrieve an arbitrarily long, authenticated,
updateable list of libp2p peers
to bootstrap connection to a libp2p network.
Since 10/WAKU2
currently specifies use of libp2p peer identities,
this method is suitable for a new Waku v2 node
to discover other Waku v2 nodes to connect to.
This specification is largely based on EIP-1459,
with the only deviation being the type of address being encoded (multiaddr vs enr).
Also see this earlier explainer
for more background on the suitability of DNS based discovery for Waku v2.
List encoding
The peer list MUST be encoded as a Merkle tree.
EIP-1459 specifies the URL scheme
to refer to such a DNS node list.
This specification uses the same approach, but with a matree scheme:
matree://<key>@<fqdn>
where
matreeis the selectedmultiaddrMerkle tree scheme<fqdn>is the fully qualified domain name on which the list can be found<key>is the base32 encoding of the compressed 32-byte binary public key that signed the list.
The example URL from EIP-1459, adapted to the above scheme becomes:
matree://AM5FCQLWIZX2QFPNJAP7VUERCCRNGRHWZG3YYHIUV7BVDQ5FDPRT2@peers.example.org
Each entry within the Merkle tree MUST be contained within a DNS TXT record
and stored in a subdomain (except for the base URL matree entry).
The content of any TXT record
MUST be small enough to fit into the 512-byte limit imposed on UDP DNS packets,
which limits the number of hashes that can be contained within a branch entry.
The subdomain name for each entry
is the base32 encoding of the abbreviated keccak256 hash of its text content.
See this example
of a fully populated tree for more information.
Entry types
The following entry types are derived from EIP-1459
and adapted for use with multiaddrs:
Root entry
The tree root entry MUST use the following format:
matree-root:v1 m=<ma-root> l=<link-root> seq=<sequence number> sig=<signature>
where
ma-rootandlink-rootrefer to the root hashes of subtrees containingmultiaddrsand links to other subtrees, respectivelysequence-numberis the tree's update sequence number. This number SHOULD increase with each update to the tree.signatureis a 65-byte secp256k1 EC signature over the keccak256 hash of the root record content, excluding thesig=part, encoded as URL-safe base64
Branch entry
Branch entries MUST take the format:
matree-branch:<h₁>,<h₂>,...,<hₙ>
where
<h₁>,<h₂>,...,<hₙ>are the hashes of other subtree entries
Leaf entries
There are two types of leaf entries:
Link entries
For the subtree pointed to by link-root,
leaf entries MUST take the format:
matree://<key>@<fqdn>
which links to a different list located in another domain.
multiaddr entries
For the subtree pointed to by ma-root,
leaf entries MUST take the format:
ma:<multiaddr>
which contains the multiaddr of a libp2p peer.
Client protocol
A client MUST adhere to the client protocol
as specified in EIP-1459,
and adapted for usage with multiaddr entry types below:
To find nodes at a given DNS name a client MUST perform the following steps:
- Resolve the TXT record of the DNS name and
check whether it contains a valid
matree-root:v1entry. - Verify the signature on the root against the known public key and check whether the sequence number is larger than or equal to any previous number seen for that name.
- Resolve the TXT record of a hash subdomain indicated in the record and verify that the content matches the hash.
- If the resolved entry is of type:
matree-branch: parse the list of hashes and continue resolving them (step 3).ma: import themultiaddrand add it to a local list of discovered nodes.
Copyright
Copyright and related rights waived via CC0.
References
10/WAKU2- EIP-1459: Client Protocol
- EIP-1459: Node Discovery via DNS
libp2plibp2ppeer identity- Merkle trees
32/RLN-V1
| Field | Value |
|---|---|
| Name | Rate Limit Nullifier |
| Slug | 32 |
| Status | draft |
| Editor | Aaryamann Challani [email protected] |
| Contributors | Barry Whitehat [email protected], Sanaz Taheri [email protected], Oskar Thorén [email protected], Onur Kilic [email protected], Blagoj Dimovski [email protected], Rasul Ibragimov [email protected] |
Timeline
- 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-06-06 —
cbefa48— 32/RLN-V1: Move to Draft (#40) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-01 —
94db406— Update rln-v1.md - 2024-02-01 —
a23299f— Update and rename RLN-V1.md to rln-v1.md - 2024-01-27 —
539575b— Create RLN-V1.md
Abstract
The following specification covers the RLN construct as well as some auxiliary libraries useful for interacting with it. Rate limiting nullifier (RLN) is a construct based on zero-knowledge proofs that provides an anonymous rate-limited signaling/messaging framework suitable for decentralized (and centralized) environments. Anonymity refers to the unlinkability of messages to their owner.
Motivation
RLN guarantees a messaging rate is enforced cryptographically while preserving the anonymity of the message owners. A wide range of applications can benefit from RLN and provide desirable security features. For example, an e-voting system can integrate RLN to contain the voting rate while protecting the voters-vote unlinkability. Another use case is to protect an anonymous messaging system against DDoS and spam attacks by constraining messaging rate of users. This latter use case is explained in 17/WAKU2-RLN-RELAY RFC.
Wire Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Flow
The users participate in the protocol by first registering to an application-defined group referred by the membership group. Registration to the group is mandatory for signaling in the application. After registration, group members can generate a zero-knowledge proof of membership for their signals and can participate in the application. Usually, the membership requires a financial or social stake which is beneficial for the prevention of inclusion of Sybils within the membership group. Group members are allowed to send one signal per external nullifier (an identifier that groups signals and can be thought of as a voting booth). If a user generates more signals than allowed, the user risks being slashed - by revealing his membership secret credentials. If the financial stake is put in place, the user also risks his stake being taken.
Generally the flow can be described by the following steps:
- Registration
- Signaling
- Verification and slashing
Registration
Depending on the application requirements, the registration can be implemented in different ways, for example:
- centralized registrations, by using a central server
- decentralized registrations, by using a smart contract
The users' identity commitments (explained in section User Identity) are stored in a Merkle tree, and the users can obtain a Merkle proof proving that they are part of the group.
Also depending on the application requirements, usually a financial or social stake is introduced. An example for financial stake is:
For each registration a certain amount of ETH is required. An example for social stake is using Interep as a registry, users need to prove that they have a highly reputable social media account.
Implementation notes
User identity
The user's identity is composed of:
{
identity_secret: [identity_nullifier, identity_trapdoor],
identity_secret_hash: poseidonHash(identity_secret),
identity_commitment: poseidonHash([identity_secret_hash])
}
For registration, the user MUST submit their identity_commitment
(along with any additional registration requirements) to the registry.
Upon registration, they SHOULD receive leaf_index value
which represents their position in the Merkle tree.
Receiving a leaf_index is not a hard requirement and is application specific.
The other way around is
the users calculating the leaf_index themselves upon successful registration.
Signaling
After registration, the users can participate in the application by sending signals to the other participants in a decentralised manner or to a centralised server. Along with their signal, they MUST generate a zero-knowledge proof by using the circuit with the specification described above.
For generating a proof, the users need to obtain the required parameters or compute them themselves, depending on the application implementation and client libraries supported by the application. For example, the users MAY store the membership Merkle tree on their end and generate a Merkle proof whenever they want to generate a signal.
Implementation Notes
Signal hash
The signal hash can be generated by hashing the raw signal (or content)
using the keccak256 hash function.
External nullifier
The external nullifier MUST be computed as the Poseidon hash of the current epoch (e.g. a value equal to or derived from the current UNIX timestamp divided by the epoch length) and the RLN identifier.
external_nullifier = poseidonHash([epoch, rln_identifier]);
Obtaining Merkle proof
The Merkle proof SHOULD be obtained locally or from a trusted third party.
By using the incremental Merkle tree algorithm,
the Merkle can be obtained by providing the leaf_index of the identity_commitment.
The proof (Merkle_proof) is composed of the following fields:
{
root: bigint,
indices: number[],
path_elements: bigint[][]
}
- root - The root of membership group Merkle tree at the time of publishing the message
- indices - The index fields of the leafs in the Merkle tree - used by the Merkle tree algorithm for verification
- path_elements - Auxiliary data structure used for storing the path to the leaf - used by the Merkle proof algorithm for verificaton
Generating proof
For proof generation, the user MUST submit the following fields to the circuit:
{
identity_secret: identity_secret_hash,
path_elements: Merkle_proof.path_elements,
identity_path_index: Merkle_proof.indices,
x: signal_hash,
external_nullifier: external_nullifier
}
Calculating output
The proof output is calculated locally,
in order for the required fields for proof verification
to be sent along with the proof.
The proof output is composed of the y share of the secret equation and the internal_nullifier.
The internal_nullifier represents a unique fingerprint of a user
for a given epoch and app.
The following fields are needed for proof output calculation:
{
identity_secret_hash: bigint,
external_nullifier: bigint,
x: bigint
}
The output [y, internal_nullifier] is calculated in the following way:
a_0 = identity_secret_hash;
a_1 = poseidonHash([a0, external_nullifier]);
y = a_0 + x * a_1;
internal_nullifier = poseidonHash([a_1]);
It relies on the properties of the Shamir's Secret sharing scheme.
Sending the output message
The user's output message (output_message),
containing the signal SHOULD contain the following fields at minimum:
{
signal: signal, # non-hashed signal,
proof: zk_proof,
internal_nullifier: internal_nullifier,
x: x, # signal_hash,
y: y,
rln_identifier: rln_identifier
}
Additionally depending on the application, the following fields MAY be required:
{
root: Merkle_proof.root,
epoch: epoch
}
Verification and slashing
The slashing implementation is dependent on the type of application. If the application is implemented in a centralised manner, and everything is stored on a single server, the slashing will be implemented only on the server. Otherwise if the application is distributed, the slashing will be implemented on each user's client.
Notes from Implementation
Each user of the protocol
(server or otherwise) MUST store metadata for each message received by each user,
for the given epoch.
The data can be deleted when the epoch passes.
Storing metadata is REQUIRED,
so that if a user sends more than one unique signal per epoch,
they can be slashed and removed from the protocol.
The metadata stored contains the x, y shares and
the internal_nullifier for the user for each message.
If enough such shares are present, the user's secret can be retreived.
One way of storing received metadata (messaging_metadata) is the following format:
{
[external_nullifier]: {
[internal_nullifier]: {
x_shares: [],
y_shares: []
}
}
}
Verification
The output message verification consists of the following steps:
external_nullifiercorrectness- non-duplicate message check
zk_proofzero-knowledge proof verification- spam verification
1. external_nullifier correctness
Upon received output_message,
first the epoch and rln_identifier fields are checked,
to ensure that the message matches the current external_nullifier.
If the external_nullifier is correct the verification continues, otherwise,
the message is discarded.
2. non-duplicate message check
The received message is checked to ensure it is not duplicate.
The duplicate message check is performed by verifying that the x and y
fields do not exist in the messaging_metadata object.
If the x and y fields exist in the x_shares and
y_shares array for the external_nullifier and
the internal_nullifier the message can be considered as a duplicate.
Duplicate messages are discarded.
3. zk_proof verification
The zk_proof SHOULD be verified by providing the zk_proof field
to the circuit verifier along with the public_signal:
[
y,
Merkle_proof.root,
internal_nullifier,
x, # signal_hash
external_nullifier
]
If the proof verification is correct, the verification continues, otherwise the message is discarded.
4. Double signaling verification
After the proof is verified the x, and
y fields are added to the x_shares and y_shares
arrays of the messaging_metadata external_nullifier and
internal_nullifier object.
If the length of the arrays is equal to the signaling threshold (limit),
the user can be slashed.
Slashing
After the verification,
the user SHOULD be slashed if two different shares are present
to reconstruct their identity_secret_hash from x_shares and
y_shares fields, for their internal_nullifier.
The secret can be retreived by the properties of the Shamir's secret sharing scheme.
In particular the secret (a_0) can be retrieved by computing Lagrange polynomials.
After the secret is retreived,
the user's identity_commitment SHOULD be generated from the secret and
it can be used for removing the user from the membership Merkle tree
(zeroing out the leaf that contains the user's identity_commitment).
Additionally, depending on the application the identity_secret_hash
MAY be used for taking the user's provided stake.
Technical overview
The main RLN construct is implemented using a ZK-SNARK circuit. However, it is helpful to describe the other necessary outside components for interaction with the circuit, which together with the ZK-SNARK circuit enable the above mentioned features.
Terminology
| Term | Description |
|---|---|
| ZK-SNARK | zksnarks |
| Stake | Financial or social stake required for registering in the RLN applications. Common stake examples are: locking cryptocurrency (financial), linking reputable social identity. |
| Identity secret | An array of two unique random components (identity nullifier and identity trapdoor), which must be kept private by the user. Secret hash and identity commitment are derived from this array. |
| Identity nullifier | Random 32 byte value used as component for identity secret generation. |
| Identity trapdoor | Random 32 byte value used as component for identity secret generation. |
| Identity secret hash | The hash of the identity secret, obtained using the Poseidon hash function. It is used for deriving the identity commitment of the user, and as a private input for zero-knowledge proof generation. The secret hash should be kept private by the user. |
| Identity commitment | Hash obtained from the Identity secret hash by using the poseidon hash function. It is used by the users for registering in the protocol. |
| Signal | The message generated by a user. It is an arbitrary bit string that may represent a chat message, a URL request, protobuf message, etc. |
| Signal hash | Keccak256 hash of the signal modulo circuit's field characteristic, used as an input in the RLN circuit. |
| RLN Identifier | Random finite field value unique per RLN app. It is used for additional cross-application security. The role of the RLN identifier is protection of the user secrets from being compromised when signals are being generated with the same credentials in different apps. |
| RLN membership tree | Merkle tree data structure, filled with identity commitments of the users. Serves as a data structure that ensures user registrations. |
| Merkle proof | Proof that a user is member of the RLN membership tree. |
RLN Zero-Knowledge Circuit specific terms
| Term | Description |
|---|---|
| x | Keccak hash of the signal, same as signal hash (Defined above). |
| A0 | The identity secret hash. |
| A1 | Poseidon hash of [A0, External nullifier] (see about External nullifier below). |
| y | The result of the polynomial equation (y = a0 + a1*x). The public output of the circuit. |
| External nullifier | Poseidon hash of [Epoch, RLN Identifier]. An identifier that groups signals and can be thought of as a voting booth. |
| Internal nullifier | Poseidon hash of [A1]. This field ensures that a user can send only one valid signal per external nullifier without risking being slashed. Public input of the circuit. |
Zero-Knowledge Circuits specification
Anonymous signaling with a controlled rate limit is enabled by proving that the user is part of a group which has high barriers to entry (form of stake) and enabling secret reveal if more than 1 unique signal is produced per external nullifier. The membership part is implemented using membership Merkle trees and Merkle proofs, while the secret reveal part is enabled by using the Shamir's Secret Sharing scheme. Essentially the protocol requires the users to generate zero-knowledge proof to be able to send signals and participate in the application. The zero knowledge proof proves that the user is member of a group, but also enforces the user to share part of their secret for each signal in an external nullifier. The external nullifier is usually represented by timestamp or a time interval. It can also be thought of as a voting booth in voting applications.
The zero-knowledge Circuit is implemented using a Groth-16 ZK-SNARK, using the circomlib library.
System parameters
DEPTH- Merkle tree depth
Circuit parameters
Public Inputs
xexternal_nullifier
Private Inputs
identity_secret_hashpath_elements- rln membership proof componentidentity_path_index- rln membership proof component
Outputs
yroot- the rln membership tree rootinternal_nullifier
Hash function
Canonical Poseidon hash implementation is used, as implemented in the circomlib library, according to the Poseidon paper. This Poseidon hash version (canonical implementation) uses the following parameters:
| Hash inputs | t | RF | RP |
|---|---|---|---|
| 1 | 2 | 8 | 56 |
| 2 | 3 | 8 | 57 |
| 3 | 4 | 8 | 56 |
| 4 | 5 | 8 | 60 |
| 5 | 6 | 8 | 60 |
| 6 | 7 | 8 | 63 |
| 7 | 8 | 8 | 64 |
| 8 | 9 | 8 | 63 |
Membership implementation
For a valid signal, a user's identity_commitment
(more on identity commitments below) must exist in identity membership tree.
Membership is proven by providing a membership proof (witness).
The fields from the membership proof REQUIRED for the verification are:
path_elements and identity_path_index.
IncrementalQuinTree algorithm is used for constructing the Membership Merkle tree. The circuits are reused from this repository. You can find out more details about the IncrementalQuinTree algorithm here.
Slashing and Shamir's Secret Sharing
Slashing is enabled by using polynomials and Shamir's Secret sharing.
In order to produce a valid proof,
identity_secret_hash as a private input to the circuit.
Then a secret equation is created in the form of:
y = a_0 + x * a_1;
where a_0 is the identity_secret_hash and a_1 = hash(a_0, external nullifier).
Along with the generated proof,
the users MUST provide a (x, y) share which satisfies the line equation,
in order for their proof to be verified.
x is the hashed signal, while the y is the circuit output.
With more than one pair of unique shares, anyone can derive a_0, i.e. the identity_secret_hash.
The hash of a signal will be the evaluation point x.
In this way,
a member who sends more than one unique signal per external_nullifier
risks their identity secret being revealed.
Note that shares used in different epochs and
different RLN apps cannot be used to derive the identity_secret_hash.
Thanks to the external_nullifier definition,
also shares computed from same secret within same epoch but
in different RLN apps cannot be used to derive the identity secret hash.
The rln_identifier is a random value from a finite field, unique per RLN app,
and is used for additional cross-application security -
to protect the user secrets being compromised if they use
the same credentials accross different RLN apps.
If rln_identifier is not present,
the user uses the same credentials and
sends a different message for two different RLN apps using the same external_nullifier,
then their user signals can be grouped by the internal_nullifier
which could lead the user's secret revealed.
This is because two separate signals under the same internal_nullifier
can be treated as rate limiting violation.
With adding the rln_identifier field we obscure the internal_nullifier,
so this kind of attack can be hardened because
we don't have the same internal_nullifier anymore.
Identity credentials generation
In order to be able to generate valid proofs,
the users MUST be part of the identity membership Merkle tree.
They are part of the identity membership Merkle tree if
their identity_commitment is placed in a leaf in the tree.
The identity credentials of a user are composed of:
identity_secretidentity_secret_hashidentity_commitment
identity_secret
The identity_secret is generated in the following way:
identity_nullifier = random_32_byte_buffer;
identity_trapdoor = random_32_byte_buffer;
identity_secret = [identity_nullifier, identity_trapdoor];
The same secret SHOULD NOT be used accross different protocols, because revealing the secret at one protocol could break privacy for the user in the other protocols.
identity_secret_hash
The identity_secret_hash is generated by obtaining a Poseidon hash
of the identity_secret array:
identity_secret_hash = poseidonHash(identity_secret);
identity_commitment
The identity_commitment is generated by obtaining a Poseidon hash of the identity_secret_hash:
identity_commitment = poseidonHash([identity_secret_hash]);
Appendix A: Security Considerations
RLN is an experimental and still un-audited technology. This means that the circuits have not been yet audited. Another consideration is the security of the underlying primitives. zk-SNARKS require a trusted setup for generating a prover and verifier keys. The standard for this is to use trusted Multi-Party Computation (MPC) ceremony, which requires two phases. Trusted MPC ceremony has not yet been performed for the RLN circuits.
SSS Security Assumptions
Shamir-Secret Sharing requires polynomial coefficients
to be independent of each other.
However, a_1 depends on a_0 through the Poseidon hash algorithm.
Due to the design of Poseidon,
it is possible to
attack
the protocol.
It was decided not to change the circuits design,
since at the moment the attack is infeasible.
Therefore, implementers must be aware that the current version
provides approximately 160-bit security and not 254.
Possible improvements:
- change the circuit to make coefficients independent;
- switch to other hash function (Keccak, SHA);
Appendix B: Identity Scheme Choice
The hashing scheme used is based on the design decisions which also include the Semaphore circuits. Our goal was to ensure compatibility of the secrets for apps that use Semaphore and RLN circuits while also not compromising on security because of using the same secrets.
For example, let's say there is a voting app that uses Semaphore, and also a chat app that uses RLN. The UX would be better if the users would not need to care about complicated identity management (secrets and commitments) they use for each app, and it would be much better if they could use a single id commitment for this. Also in some cases these kind of dependency is required - RLN chat app using Interep as a registry (instead of using financial stake). One potential concern about this interoperability is a slashed user on the RLN app side having their security compromised on the semaphore side apps as well. i.e. obtaining the user's secret, anyone would be able to generate valid semaphore proofs as the slashed user. We don't want that, and we should keep user's app specific security threats in the domain of that app alone.
To achieve the above interoperability UX while preventing the shared app security model (i.e slashing user on an RLN app having impact on Semaphore apps), we had to do the follow in regard the identity secret and identity commitment:
identity_secret = [identity_nullifier, identity_trapdoor];
identity_secret_hash = poseidonHash(identity_secret);
identity_commitment = poseidonHash([identity_secret_hash]);
Secret components for generating Semaphore proof:
identity_nullifieridentity_trapdoor
Secret components for generting RLN proof:
identity_secret_hash
When a user is slashed on the RLN app side, their identity_secret_hash is revealed.
However, a semaphore proof can't be generated because
we do not know the user's identity_nullifier and identity_trapdoor.
With this design we achieve:
identity_commitment (Semaphore) == identity_commitment (RLN)
secret (semaphore) != secret (RLN).
This is the only option we had for the scheme in order to satisfy the properties described above.
Also, for RLN we do a single secret component input for the circuit. Thus we need to hash the secret array (two components) to a secret hash, and we use that as a secret component input.
Appendix C: Auxiliary Tooling
There are few additional tools implemented for easier integrations and usage of the RLN protocol.
zerokit is a set of Zero Knowledge modules,
written in Rust and designed to be used in many different environments.
Among different modules, it supports Semaphore and RLN.
zk-kit
is a typescript library which exposes APIs for identity credentials generation,
as well as proof generation.
It supports various protocols (Semaphore, RLN).
zk-keeper
is a browser plugin which allows for safe credential storing and
proof generation.
You can think of MetaMask for zero-knowledge proofs.
It uses zk-kit under the hood.
Appendix D: Example Usage
The following examples are code snippets using the zerokit RLN module.
The examples are written in rust.
Creating a RLN Object
#![allow(unused)] fn main() { use rln::protocol::*; use rln::public::*; use std::io::Cursor; // We set the RLN parameters: // - the tree height; // - the circuit resource folder (requires a trailing "/"). let tree_height = 20; let resources = Cursor::new("../zerokit/rln/resources/tree_height_20/"); // We create a new RLN instance let mut rln = RLN::new(tree_height, resources); }
Generating Identity Credentials
#![allow(unused)] fn main() { // We generate an identity tuple let mut buffer = Cursor::new(Vec::<u8>::new()); rln.extended_key_gen(&mut buffer).unwrap(); // We deserialize the keygen output to obtain // the identiy_secret and id_commitment let (identity_trapdoor, identity_nullifier, identity_secret_hash, id_commitment) = deserialize_identity_tuple(buffer.into_inner()); }
Adding ID Commitment to the RLN Merkle Tree
#![allow(unused)] fn main() { // We define the tree index where id_commitment will be added let id_index = 10; // We serialize id_commitment and pass it to set_leaf let mut buffer = Cursor::new(serialize_field_element(id_commitment)); rln.set_leaf(id_index, &mut buffer).unwrap(); }
Setting Epoch and Signal
#![allow(unused)] fn main() { // We generate epoch from a date seed and we ensure is // mapped to a field element by hashing-to-field its content let epoch = hash_to_field(b"Today at noon, this year"); // We set our signal let signal = b"RLN is awesome"; }
Generating Proof
#![allow(unused)] fn main() { // We prepare input to the proof generation routine let proof_input = prepare_prove_input(identity_secret, id_index, epoch, signal); // We generate a RLN proof for proof_input let mut in_buffer = Cursor::new(proof_input); let mut out_buffer = Cursor::new(Vec::<u8>::new()); rln.generate_rln_proof(&mut in_buffer, &mut out_buffer) .unwrap(); // We get the public outputs returned by the circuit evaluation let proof_data = out_buffer.into_inner(); }
Verifiying Proof
#![allow(unused)] fn main() { // We prepare input to the proof verification routine let verify_data = prepare_verify_input(proof_data, signal); // We verify the zero-knowledge proof against the provided proof values let mut in_buffer = Cursor::new(verify_data); let verified = rln.verify(&mut in_buffer).unwrap(); // We ensure the proof is valid assert!(verified); }
For more details please visit the
zerokit library.
Copyright
Copyright and related rights waived via CC0
References
- 17/WAKU2-RLN-RELAY RFC
- Interep
- incremental Merkle tree algorithm
- Shamir's Secret sharing scheme
- Lagrange polynomials
- ZK-SNARK
- Merkle trees
- Groth-16 ZK-SNARK
- circomlib
- Poseidon hash implementation
- circomlib library
- IncrementalQuinTree
- IncrementalQuinTree algorithm
- Multi-Party Computation (MPC)
- Poseidon hash attack
- zerokit
- zk-kit
- zk-keeper
- rust
Informative
- [1] privacy-scaling-explorations
- [2] security-considerations-of-zk-snark-parameter-multi-party-computationsecurity-considerations-of-zk-snark-parameter-multi-party-computation/
- [3] rln-circuits
- [4] rln docs
ETH-DCGKA
| Field | Value |
|---|---|
| Name | Decentralized Key and Session Setup for Secure Messaging over Ethereum |
| Slug | 103 |
| Status | raw |
| Category | informational |
| Editor | Ramses Fernandez-Valencia [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-04 —
517b639— Update the RFCs: Vac Raw RFC (#143) - 2024-10-03 —
c655980— Eth secpm splitted (#91) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-05-27 —
7e3a625— ETH-SECPM-DEC (#28)
Abstract
This document introduces a decentralized group messaging protocol using Ethereum adresses as identifiers. It is based in the proposal DCGKA by Weidner et al. It includes also approximations to overcome limitations related to using PKI and the multi-device setting.
Motivation
The need for secure communications has become paramount. Traditional centralized messaging protocols are susceptible to various security threats, including unauthorized access, data breaches, and single points of failure. Therefore a decentralized approach to secure communication becomes increasingly relevant, offering a robust solution to address these challenges.
Secure messaging protocols used should have the following key features:
-
Asynchronous Messaging: Users can send messages even if the recipients are not online at the moment.
-
Resilience to Compromise: If a user's security is compromised, the protocol ensures that previous messages remain secure through forward secrecy (FS). This means that messages sent before the compromise cannot be decrypted by adversaries. Additionally, the protocol maintains post-compromise security (PCS) by regularly updating keys, making it difficult for adversaries to decrypt future communication.
-
Dynamic Group Management: Users can easily add or remove group members at any time, reflecting the flexible nature of communication within the app.
In this field, there exists a trilemma, similar to what one observes in blockchain, involving three key aspects:
- security,
- scalability, and
- decentralization.
For instance, protocols like the MLS perform well in terms of scalability and security. However, they falls short in decentralization.
Newer studies such as CoCoa improve features related to security and scalability, but they still rely on servers, which may not be fully trusted though they are necessary.
On the other hand, older studies like Causal TreeKEM exhibit decent scalability (logarithmic) but lack forward secrecy and have weak post-compromise security (PCS).
The creators of DCGKA introduce a decentralized, asynchronous secure group messaging protocol that supports dynamic groups. This protocol operates effectively on various underlying networks without strict requirements on message ordering or latency. It can be implemented in peer-to-peer or anonymity networks, accommodating network partitions, high latency links, and disconnected operation seamlessly. Notably, the protocol doesn't rely on servers or a consensus protocol for its functionality.
This proposal provides end-to-end encryption with forward secrecy and post-compromise security, even when multiple users concurrently modify the group state.
Theory
Protocol overview
This protocol makes use of ratchets to provide FS by encrypting each message with a different key.
In the figure one can see the ratchet for encrypting a sequence of messages.
The sender requires an initial update secret I_1, which is introduced in a PRG.
The PRG will produce two outputs, namely a symmetric key for AEAD encryption, and
a seed for the next ratchet state.
The associated data needed in the AEAD encryption includes the message index i.
The ciphertext c_i associated to message m_i
is then broadcasted to all group members.
The next step requires deleting I_1, k_i and any old ratchet state.
After a period of time the sender may replace the ratchet state with new update secrets
I_2, I_3, and so on.
To start a post-compromise security update,
a user creates a new random value known as a seed secret and
shares it with every other group member through a secure two-party channel.
Upon receiving the seed secret,
each group member uses it to calculate an update secret for both the sender's ratchet
and their own.
Additionally, the recipient sends an unencrypted acknowledgment to the group
confirming the update.
Every member who receives the acknowledgment updates
not only the ratchet for the original sender but
also the ratchet for the sender of the acknowledgment.
Consequently, after sharing the seed secret through n - 1 two-party messages and
confirming it with n - 1 broadcast acknowledgments,
every group member has derived an update secret and updated their ratchet accordingly.
When removing a group member, the user who initiates the removal conducts a post-compromise security update by sending the update secret to all group members except the one being removed. To add a new group member, each existing group member shares the necessary state with the new user, enabling them to derive their future update secrets.
Since group members may receive messages in various orders, it's important to ensure that each sender's ratchet is updated consistently with the same sequence of update secrets at each group member.
The network protocol used in this scheme ensures that messages from the same sender are processed in the order they were sent.
Components of the protocol
This protocol relies in 3 components: authenticated causal broadcast (ACB), decentralized group membership (DGM) and 2-party secure messaging (2SM).
Authenticated causal broadcast
A causal order is a partial order relation < on messages.
Two messages m_1 and m_2 are causally ordered, or
m_1 causally precedes m_2
(denoted by m_1 < m_2), if one of the following contiditions hold:
m_1andm_2were sent by the same group member, andm_1was sent beforem_2.m_2was sent by a group member U, andm_1was received and processed byUbefore sendingm_2.- There exists
m_3such thatm_1 < m_3andm_3 < m_2.
Causal broadcast requires that before processing m, a group member must
process all preceding messages {m' | m' < m}.
The causal broadcast module used in this protocol authenticates the sender of each message, as well as its causal ordering metadata, using a digital signature under the sender’s identity key. This prevents a passive adversary from impersonating users or affecting causally ordered delivery.
Decentralized group membership
This protocol assumes the existence of a decentralized group membership function (denoted as DGM) that takes a set of membership change messages and their causal order relantionships, and returns the current set of group members’ IDs. It needs to be deterministic and depend only on causal order, and not exact order.
2-party secure messaging (2SM)
This protocol makes use of bidirectional 2-party secure messaging schemes,
which consist of 3 algorithms: 2SM-Init, 2SM-Send and 2SM-Receive.
Function 2SM-Init
This function takes two IDs as inputs:
ID1 representing the local user and ID2 representing the other party.
It returns an initial protocol state sigma.
The 2SM protocol relies on a Public Key Infrastructure (PKI) or
a key server to map these IDs to their corresponding public keys.
In practice, the PKI should incorporate ephemeral prekeys.
This allows users to send messages to a new group member,
even if that member is currently offline.
Function 2SM-Send
This function takes a state sigma and a plaintext m as inputs, and returns
a new state sigma’ and a ciphertext c.
Function 2SM-Receive
This function takes a state sigma and a ciphertext c, and
returns a new state sigma’ and a plaintext m.
This function takes a state sigma and a ciphertext c, and returns a new
state sigma’ and a plaintext m.
Function 2SM Syntax
The variable sigma denotes the state consisting in the variables below:
sigma.mySks[0] = sk
sigma.nextIndex = 1
sigma.receivedSk = empty_string
sigma.otherPk = pk`<br>
sigma.otherPksender = “other”
sigma.otherPkIndex = 0
2SM-Init
On input a key pair (sk, pk), this functions otuputs a state sigma.
2SM-Send
This function encrypts the message m using sigma.otherPk, which represents
the other party’s current public key.
This key is determined based on the last public key generated for the other
party or the last public key received from the other party,
whichever is more recent. sigma.otherPkSender is set to me in the former
case and other in the latter case.
Metadata including otherPkSender and otherPkIndex are included in the
message to indicate which of the recipient’s public keys is being utilized.
Additionally, this function generates a new key pair for the local user,
storing the secret key in sigma.mySks and sending the public key.
Similarly, it generates a new key pair for the other party,
sending the secret key (encrypted) and storing the public key in
sigma.otherPk.
sigma.mySks[sigma.nextIndex], myNewPk) = PKE-Gen()
(otherNewSk, otherNewPk) = PKE-Gen()
plaintext = (m, otherNewSk, sigma`.nextIndex, myNewPk)
msg = (PKE-Enc(sigma.otherPk, plaintext), sigma.otherPkSender, sigma.otherPkIndex)
sigma.nextIndex++
(sigma.otherPk, sigma.otherPkSender, sigma.otherPkIndex) = (otherNewPk, "me", empty_string)
return (sigma`, msg)
2SM-Receive
This function utilizes the metadata of the message c to determine which
secret key to utilize for decryption, assigning it to sk.
If the secret key corresponds to one generated by ourselves,
that secret key along with all keys with lower index are deleted.
This deletion is indicated by sigma.mySks[≤ keyIndex] = empty_string.
Subsequently, the new public and secret keys contained in the message are
stored.
(ciphertext, keySender, keyIndex) = c
if keySender = "other" then
sk = sigma.mySks[keyIndex]
sigma.mySks[≤ keyIndex] = empty_string
else sk = sigma.receivedSk
(m, sigma.receivedSk, sigma.otherPkIndex, sigma.otherPk) = PKE-Dec(sk, ciphertext)
sigma.otherPkSender = "other"
return (sigma, m)
PKE Syntax
The required PKE that MUST be used is ElGamal with a 2048-bit modulus p.
Parameters
The following parameters must be used:
p = 308920927247127345254346920820166145569
g = 2
PKE-KGen
Each user u MUST do the following:
PKE-KGen():
a = randint(2, p-2)
pk = (p, g, g^a)
sk = a
return (pk, sk)
PKE-Enc
A user v encrypting a message m for u MUST follow these steps:
PKE-Enc(pk):
k = randint(2, p-2)
eta = g^k % p
delta = m * (g^a)^k % p
return ((eta, delta))
PKE-Dec
The user u recovers a message m from a ciphertext c
by performing the following operations:
PKE-Dec(sk):
mu = eta^(p-1-sk) % p
return ((mu * delta) % p)
DCGKA Syntax
Auxiliary functions
There exist 6 functions that are auxiliary for the rest of components of the protocol, namely:
init
This function takes an ID as input and returns its associated initial state,
denoted by gamma:
gamma.myId = ID
gamma.mySeq = 0
gamma.history = empty
gamma.nextSeed = empty_string
gamma.2sm[·] = empty_string
gamma.memberSecret[·, ·, ·] = empty_string
gamma.ratchet[·] = empty_string
return (gamma)
encrypt-to
Upon reception of the recipient’s ID and a plaintext, it encrypts a direct
message for another group member.
Should it be the first message for a particular ID,
then the 2SM protocol state is initialized and stored in
gamma.2sm[recipient.ID].
One then uses 2SM_Send to encrypt the message and store the updated protocol
in gamma.
if gamma.2sm[recipient_ID] = empty_string then
gamma.2sm[recipient_ID] = 2SM_Init(gamma.myID, recipient_ID)
(gamma.2sm[recipient_ID], ciphertext) = 2SM_Send(gamma.2sm[recipient_ID], plaintext)
return (gamma, ciphertext)
decrypt-from
After receiving the sender’s ID and a ciphertext, it behaves as the reverse
function of encrypt-to and has a similar initialization:
if gamma.2sm[sender_ID] = empty_string then
gamma.2sm[sender_ID] = 2SM_Init(gamma.myID, sender_ID)
(gamma.2sm[sender_ID], plaintext) = 2SM_Receive(gamma.2sm[sender_ID], ciphertext)
return (gamma, plaintext)
update-ratchet
This function generates the next update secret I_update for the group member
ID.
The ratchet state is stored in gamma.ratchet[ID].
It is required to use a HMAC-based key derivation function HKDF to combine the
ratchet state with an input, returning an update secret and a new ratchet
state.
(updateSecret, gamma.ratchet[ID]) = HKDF(gamma.ratchet[ID], input)
return (gamma, updateSecret)
member-view
This function calculates the set of group members
based on the most recent control message sent by the specified user ID.
It filters the group membership operations
to include only those observed by the specified ID, and
then invokes the DGM function to generate the group membership.
ops = {m in gamma.history st. m was sent or acknowledged by ID}
return DGM(ops)
generate-seed
This functions generates a random bit string and
sends it encrypted to each member of the group using the 2SM mechanism.
It returns the updated protocol state and
the set of direct messages (denoted as dmsgs) to send.
gamma.nextSeed = random.randbytes()
dmsgs = empty
for each ID in recipients:
(gamma, msg) = encrypt-to(gamma, ID, gamma.nextSeed)
dmsgs = dmsgs + (ID, msg)
return (gamma, dmsgs)
Creation of a group
A group is generated in a 3 steps procedure:
- A user calls the
createfunction and broadcasts a control message of type create. - Each receiver of the message processes the message and broadcasts an ack control message.
- Each member processes the ack message received.
create
This function generates a create control message and calls generate-seed to
define the set of direct messages that need to be sent.
Then it calls process-create to process the control message for this user.
The function process-create returns a tuple including an updated state gamma
and an update secret I.
control = (“create”, gamma.mySeq, IDs)
(gamma, dmsgs) = generate-seed(gamma, IDs)
(gamma, _, _, I, _) = process-create(gamma, gamma.myId, gamma.mySeq, IDs, empty_string)
return (gamma, control, dmsgs, I)
process-seed
This function initially employs member-view to identify the users who were
part of the group when the control message was dispatched.
Then, it attempts to acquire the seed secret through the following steps:
- If the control message was dispatched by the local user, it uses the most
recent invocation of
generate-seedstored the seed secret ingamma.nextSeed. - If the
controlmessage was dispatched by another user, and the local user is among its recipients, the function utilizesdecrypt-fromto decrypt the direct message that includes the seed secret. - Otherwise, it returns an
ackmessage without deriving an update secret.
Afterwards, process-seed generates separate member secrets for each group
member from the seed secret by combining the seed secret and
each user ID using HKDF.
The secret for the sender of the message is stored in senderSecret, while
those for the other group members are stored in gamma.memberSecret.
The sender's member secret is immediately utilized to update their KDF ratchet
and compute their update secret I_sender using update-ratchet.
If the local user is the sender of the control message, the process is
completed, and the update secret is returned.
However, if the seed secret is received from another user, an ack control
message is constructed for broadcast, including the sender ID and sequence
number of the message being acknowledged.
The final step computes an update secret I_me for the local user invoking the
process-ack function.
recipients = member-view(gamma, sender) - {sender}
if sender = gamma.myId then seed = gamma.nextSeed; gamma.nextSeed =
empty_string
else if gamma.myId in recipients then (gamma, seed) = decrypt-from(gamma,
sender, dmsg)
else
return (gamma, (ack, ++gamma.mySeq, (sender, seq)), empty_string ,
empty_string , empty_string)
for ID in recipients do gamma.memberSecret[sender, seq, ID] = HKDF(seed, ID)
senderSecret = HKDF(seed, sender)
(gamma, I_sender) = update-ratchet(gamma, sender, senderSecret)
if sender = gamma.myId then return (gamma, empty_string , empty_string ,
I_sender, empty_string)
control = (ack, ++gamma.mySeq, (sender, seq))
members = member-view(gamma, gamma.myId)
forward = empty
for ID in {members - (recipients + {sender})}
s = gamma.memberSecret[sender, seq, gamma.myId]
(gamma, msg) = encrypt-to(gamma, ID, s)
forward = forward + {(ID, msg)}
(gamma, _, _, I_me, _) = process-ack(gamma, gamma.myId, gamma.mySeq,
(sender, seq), empty_string)
return (gamma, control, forward, I_sender, I_me)
process-create
This function is called by the sender and each of the receivers of the create
control message.
First, it records the information from the create message in the
gamma.history+ {op}, which is used to track group membership changes. Then,
it proceeds to call process-seed.
op = (”create”, sender, seq, IDs)
gamma.history = gamma.history + {op}
return (process-seed(gamma, sender, seq, dmsg))
process-ack
This function is called by those group members once they receive an ack
message.
In process-ack, ackID and ackSeq are the sender and sequence number of
the acknowledged message.
Firstly, if the acknowledged message is a group membership operation, it
records the acknowledgement in gamma.history.
Following this, the function retrieves the relevant member secret from
gamma.memberSecret, which was previously obtained from the seed secret
contained in the acknowledged message.
Finally, it updates the ratchet for the sender of the ack and returns the
resulting update secret.
if (ackID, ackSeq) was a create / add / remove then
op = ("ack", sender, seq, ackID, ackSeq)
gamma.history = gamma.history + {op}`
s = gamma.memberSecret[ackID, ackSeq, sender]
gamma.memberSecret[ackID, ackSeq, sender] = empty_string
if (s = empty_string) & (dmsg = empty_string) then return (gamma, empty_string,
empty_string, empty_string, empty_string)
if (s = empty_string) then (gamma, s) = decrypt-from(gamma, sender, dmsg)
(gamma, I) = update-ratchet(gamma, sender, s)
return (gamma, empty_string, empty_string, I, empty_string)
The HKDF function MUST follow RFC 5869 using the hash function SHA256.
Post-compromise security updates and group member removal
The functions update and remove share similarities with create:
they both call the function generate-seed to encrypt a new seed secret for
each group member.
The distinction lies in the determination of the group members using member view.
In the case of remove, the user being removed is excluded from the recipients
of the seed secret.
Additionally, the control message they construct is designated with type
update or remove respectively.
Likewise, process-update and process-remove are akin to process-create.
The function process-update skips the update of gamma.history,
whereas process-remove includes a removal operation in the history.
update
control = ("update", ++gamma.mySeq, empty_string)
recipients = member-view(gamma, gamma.myId) - {gamma.myId}
(gamma, dmsgs) = generate-seed(gamma, recipients)
(gamma, _, _, I , _) = process-update(gamma, gamma.myId, gamma.mySeq,
empty_string, empty_string)
return (gamma, control, dmsgs, I)
remove
control = ("remove", ++gamma.mySeq, empty)
recipients = member-view(gamma, gamma.myId) - {ID, gamma.myId}
(gamma, dmsgs) = generate-seed(gamma, recipients)
(gamma, _, _, I , _) = process-update(gamma, gamma.myId, gamma.mySeq, ID,
empty_string)
return (gamma, control, dmsgs, I)
process-update
return process-seed(gamma, sender, seq, dmsg)
process-remove
op = ("remove", sender, seq, removed)
gamma.history = gamma.history + {op}
return process-seed(gamma, sender, seq, dmsg)
Group member addition
add
When adding a new group member, an existing member initiates the process by
invoking the add function and providing the ID of the user to be added.
This function prepares a control message marked as add for broadcast to the
group. Simultaneously, it creates a welcome message intended for the new member
as a direct message.
This welcome message includes the current state of the sender's KDF ratchet,
encrypted using 2SM, along with the history of group membership operations
conducted so far.
control = ("add", ++gamma.mySeq, ID)
(gamma, c) = encrypt-to(gamma, ID, gamma.ratchet[gamma.myId])
op = ("add", gamma.myId, gamma.mySeq, ID)
welcome = (gamma.history + {op}, c)
(gamma, _, _, I, _) = process-add(gamma, gamma.myId, gamma.mySeq, ID, empty_string)
return (gamma, control, (ID, welcome), I)
process-add
This function is invoked by both the sender and each recipient of an add
message, which includes the new group member. If the local user is the newly
added member, the function proceeds to call process-welcome and then exits.
Otherwise, it extends gamma.history with the add operation.
Line 5 determines whether the local user was already a group member at the time
the add message was sent; this condition is typically true but may be false
if multiple users were added concurrently.
On lines 6 to 8, the ratchet for the sender of the add message is updated
twice. In both calls to update-ratchet, a constant string is used as the
ratchet input instead of a random seed secret.
The value returned by the first ratchet update is stored in
gamma.memberSecret as the added user’s initial member secret. The result of
the second ratchet update becomes I_sender, the update secret for the sender
of the add message. On line 10, if the local user is the sender, the update
secret is returned.
If the local user is not the sender, an acknowledgment for the add message is
required.
Therefore, on line 11, a control message of type add-ack is constructed for
broadcast.
Subsequently, in line 12 the current ratchet state is encrypted using 2SM to
generate a direct message intended for the added user, allowing them to decrypt
subsequent messages sent by the sender.
Finally, in lines 13 to 15, process-add-ack is called to calculate the local
user’s update secret (I_me), which is then returned along with I_sender.
if added = gamma.myId then return process-welcome(gamma, sender, seq, dmsg)
op = ("add", sender, seq, added)
gamma.history = gamma.history + {op}
if gamma.myId in member-view(gamma, sender) then
(gamma, s) = update-ratchet(gamma, sender, "welcome")
gamma.memberSecret[sender, seq, added] = s
(gamma, I_sender) = update-ratchet(gamma, sender, "add")
else I_sender = empty_string
if sender = gamma.myId then return (gamma, empty_string, empty_string,
I_sender, empty_string)
control = ("add-ack", ++gamma.mySeq, (sender, seq))
(gamma, c) = encrypt-to(gamma, added, ratchet[gamma.myId])
(gamma, _, _, I_me, _) = process-add-ack(gamma, gamma.myId, gamma.mySeq,
(sender, seq), empty_string)
return (gamma, control, {(added, c)}, I_sender, I_me)
process-add-ack
This function is invoked by both the sender and each recipient of an add-ack
message, including the new group member. Upon lines 1–2, the acknowledgment is
added to gamma.history, mirroring the process in process-ack.
If the current user is the new group member, the add-ack message includes the
direct message constructed in process-add; this direct message contains the
encrypted ratchet state of the sender of the add-ack, then it is decrypted on
lines 3–5.
Upon line 6, a check is performed to check if the local user was already a
group member at the time the add-ack was sent. If affirmative, a new update
secret I for the sender of the add-ack is computed on line 7 by invoking
update-ratchet with the constant string add.
In the scenario involving the new member, the ratchet state was recently initialized on line 5. This ratchet update facilitates all group members, including the new addition, to derive each member’s update by obtaining any update secret from before their inclusion.
op = ("ack", sender, seq, ackID, ackSeq)
gamma$.history = gamma.history + {op}
if dmsg != empty_string then
(gamma, s) = decrypt-from(gamma, sender, dmsg)
gamma.ratchet[sender] = s
if gamma.myId in member-view(gamma, sender) then
(gamma, I) = update-ratchet(gamma, sender, "add")
return (gamma, empty_string, empty_string, I, empty_string)
else return (gamma, empty_string, empty_string, empty_string, empty_string)
process-welcome
This function serves as the second step called by a newly added group member.
In this context, adderHistory represents the adding user’s copy of
gamma.history sent in their welcome message, which is utilized to initialize
the added user’s history.
Here, c denotes the ciphertext of the adding user’s ratchet state, which is
decrypted on line 2 using decrypt-from.
Once gamma.ratchet[sender] is initialized, update-ratchet is invoked twice
on lines 3 to 5 with the constant strings welcome and add respectively.
These operations mirror the ratchet operations performed by every other group
member in process-add.
The outcome of the first update-ratchet call becomes the first member secret
for the added user,
while the second call returns I_sender, the update secret for the sender of
the add operation.
Subsequently, the new group member constructs an ack control message to
broadcast on line 6 and calls process-ack to compute their initial update
secret I_me. The function process-ack reads from gamma.memberSecret and
passes it to update-ratchet. The previous ratchet state for the new member is
the empty string empty, as established by init, thereby initializing the
new member’s ratchet.
Upon receiving the new member’s ack, every other group member initializes
their copy of the new member’s ratchet in a similar manner.
By the conclusion of process-welcome, the new group member has acquired
update secrets for themselves and the user who added them.
The ratchets for other group members are initialized by process-add-ack.
gamma.history = adderHistory
(gamma, gamma.ratchet[sender]) = decrypt-from(gamma, sender, c)
(gamma, s) = update-ratchet(gamma, sender, "welcome")
gamma.memberSecret[sender, seq, gamma.myId] = s
(gamma, I_sender) = update-ratchet(gamma, sender, "add")
control = ("ack", ++gamma.mySeq, (sender, seq))
(gamma, _, _, I_me, _) = process-ack(gamma, gamma.myId, gamma.mySeq, (sender,
seq), empty_string)
return (gamma, control, empty_string , I_sender, I_me)
Privacy Considerations
Dependency on PKI
The DCGKA proposal presents some limitations highlighted by the authors. Among these limitations one finds the requirement of a PKI (or a key server) mapping IDs to public keys.
One method to overcome this limitation is adapting the protocol SIWE (Sign in
with Ethereum) so a user u_1 who wants to start a communication with a user
u_2 can interact with latter’s wallet to request a public key using an
Ethereum address as ID.
SIWE
The SIWE (Sign In With Ethereum) proposal was a suggested standard for leveraging Ethereum to authenticate and authorize users on web3 applications. Its goal is to establish a standardized method for users to sign in to web3 applications using their Ethereum address and private key, mirroring the process by which users currently sign in to web2 applications using their email and password. Below follows the required steps:
- A server generates a unique Nonce for each user intending to sign in.
- A user initiates a request to connect to a website using their wallet.
- The user is presented with a distinctive message that includes the Nonce and details about the website.
- The user authenticates their identity by signing in with their wallet.
- Upon successful authentication, the user's identity is confirmed or approved.
- The website grants access to data specific to the authenticated user.
Our approach
The idea in the DCGKA setting closely resembles the procedure outlined in SIWE. Here:
- The server corresponds to user D1,who initiates a request (instead of generating a nonce) to obtain the public key of user D2.
- Upon receiving the request, the wallet of D2 send the request to the user,
- User D2 receives the request from the wallet, and decides whether accepts or rejects.
- The wallet and responds with a message containing the requested public key in case of acceptance by D2.
This message may be signed, allowing D1 to verify that the owner of the received public key is indeed D2.
Multi-device setting
One may see the set of devices as a group and create a group key for internal
communications.
One may use treeKEM for instance, since it provides interesting properties like
forward secrecy and post-compromise security.
All devices share the same ID, which is held by one of them, and from other
user’s point of view, they would look as a single user.
Using servers, like in the paper Multi-Device for Signal, should be avoided; but this would imply using a particular device as receiver and broadcaster within the group. There is an obvious drawback which is having a single device working as a “server”. Should this device be attacked or without connection, there should be a mechanism for its revocation and replacement.
Another approach for communications between devices could be using the keypair of each device. This could open the door to use UPKE, since keypairs should be regenerated frequently.
Each time a device sends a message, either an internal message or an external message, it needs to replicate and broadcast it to all devices in the group.
The mechanism for the substitution of misbehaving leader devices follows:
- Each device within a group knows the details of other leader devices. This information may come from metadata in received messages, and is replicated by the leader device.
- To replace a leader, the user should select any other device within its group and use it to send a signed message to all other users.
- To get the ability to sign messages, this new leader should request the keypair associated to the ID to the wallet.
- Once the leader has been changed, it revocates access from DCGKA to the former leader using the DCGKA protocol.
- The new leader starts a key update in DCGKA.
Not all devices in a group should be able to send messages to other users. Only
the leader device should be in charge of sending and receiving messages.
To prevent other devices from sending messages outside their group, a
requirement should be signing each message. The keys associated to the ID
should only be in control of the leader device.
The leader device is in charge of setting the keys involved in the DCGKA. This information must be replicated within the group to make sure it is updated.
To detect missing messages or potential misbehavior, messages must include a counter.
Using UPKE
Managing the group of devices of a user can be done either using a group key protocol such as treeKEM or using the keypair of each device. Setting a common key for a group of devices under the control of the same actor might be excessive, furthermore it may imply some of the problems one can find in the usual setting of a group of different users; for example: one of the devices may not participate in the required updating processes, representing a threat for the group.
The other approach to managing the group of devices is using each device’s keypair, but it would require each device updating these materia frequently, something that may not happens.
UPKE is a form of asymetric cryptography where any user can update any other user’s key pair by running an update algorithm with (high-entropy) private coins. Any sender can initiate a key update by sending a special update ciphertext. This ciphertext updates the receiver’s public key and also, once processed by the receiver, will update their secret key.
To the best of my knowledge, there exists several efficient constructions both UPKE from ElGamal (based in the DH assumption) and UPKE from Lattices (based in lattices). None of them have been implemented in a secure messaging protocol, and this opens the door to some novel research.
Copyright
Copyright and related rights waived via CC0.
References
- DCGKA
- MLS
- CoCoa
- Causal TreeKEM
- SIWE
- Multi-device for Signal
- UPKE
- UPKE from ElGamal
- UPKE from Lattices
ETH-MLS-OFFCHAIN
| Field | Value |
|---|---|
| Name | Secure channel setup using decentralized MLS and Ethereum accounts |
| Slug | 104 |
| Status | raw |
| Category | Standards Track |
| Editor | Ugur Sen [email protected] |
| Contributors | seemenkina [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-11-26 —
e39d288— VAC/RAW/ ETH-MLS-OFFCHAIN RFC multi-steward support (#193) - 2025-08-21 —
3b968cc— VAC/RAW/ ETH-MLS-OFFCHAIN RFC (#166)
Abstract
The following document specifies Ethereum authenticated scalable and decentralized secure group messaging application by integrating Message Layer Security (MLS) backend. Decentralization refers each user is a node in P2P network and each user has voice for any changes in group. This is achieved by integrating a consensus mechanism. Lastly, this RFC can also be referred to as de-MLS, decentralized MLS, to emphasize its deviation from the centralized trust assumptions of traditional MLS deployments.
Motivation
Group messaging is a fundamental part of digital communication, yet most existing systems depend on centralized servers, which introduce risks around privacy, censorship, and unilateral control. In restrictive settings, servers can be blocked or surveilled; in more open environments, users still face opaque moderation policies, data collection, and exclusion from decision-making processes. To address this, we propose a decentralized, scalable peer-to-peer group messaging system where each participant runs a node, contributes to message propagation, and takes part in governance autonomously. Group membership changes are decided collectively through a lightweight partially synchronous, fault-tolerant consensus protocol without a centralized identity. This design enables truly democratic group communication and is well-suited for use cases like activist collectives, research collaborations, DAOs, support groups, and decentralized social platforms.
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Assumptions
- The nodes in the P2P network can discover other nodes or will connect to other nodes when subscribing to same topic in a gossipsub.
- We MAY have non-reliable (silent) nodes.
- We MUST have a consensus that is lightweight, scalable and finalized in a specific time.
Roles
The three roles used in de-MLS is as follows:
node: Nodes are participants in the network that are not currently members of any secure group messaging session but remain available as potential candidates for group membership.member: Members are special nodes in the secure group messaging who obtains current group key of secure group messaging. Each node is assigned a unique identity represented as a 20-byte value namedmember id.steward: Stewards are special and transparent members in the secure group messaging who organize the changes by releasing commit messages upon the voted proposals. There are two special subsets of steward as epoch and backup steward, which are defined in the section de-MLS Objects.
MLS Background
The de-MLS consists of MLS backend, so the MLS services and other MLS components are taken from the original MLS specification, with or without modifications.
MLS Services
MLS is operated in two services authentication service (AS) and delivery service (DS).
Authentication service enables group members to authenticate the credentials presented by other group members.
The delivery service routes MLS messages among the nodes or
members in the protocol in the correct order and
manage the keyPackage of the users where the keyPackage is the objects
that provide some public information about a user.
MLS Objects
Following section presents the MLS objects and components that used in this RFC:
Epoch: Time intervals that changes the state that is defined by members,
section 3.4 in MLS RFC 9420.
MLS proposal message: Members MUST receive the proposal message prior to the
corresponding commit message that initiates a new epoch with key changes,
in order to ensure the intended security properties, section 12.1 in MLS RFC 9420.
Here, the add and remove proposals are used.
Application message: This message type used in arbitrary encrypted communication between group members.
This is restricted by MLS RFC 9420 as if there is pending proposal,
the application message should be cut.
Note that: Since the MLS is based on servers, this delay between proposal and commit messages are very small.
Commit message: After members receive the proposals regarding group changes,
the committer, who may be any member of the group, as specified in MLS RFC 9420,
generates the necessary key material for the next epoch, including the appropriate welcome messages
for new joiners and new entropy for removed members. In this RFC, the committers only MUST be stewards.
de-MLS Objects
This section presents the de-MLS objects:
Voting proposal: Similar to MLS proposals, but processed only if approved through a voting process.
They function as application messages in the MLS group,
allowing the steward to collect them without halting the protocol.
There are three types of voting proposal according to the type of consensus as in shown Consensus Types section,
these are, commit proposal, steward election proposal and emergency criteria proposal.
Epoch steward: The steward assigned to commit in epoch E according to the steward list.
Holds the primary responsibility for creating commit in that epoch.
Backup steward: The steward next in line after the epoch steward on the steward list in epoch E.
Only becomes active if the epoch steward is malicious or fails,
in which case it completes the commitment phase.
If unused in epoch E, it automatically becomes the epoch steward in epoch E+1.
Steward list: It is an ordered list that contains the member ids of authorized stewards.
Each steward in the list becomes main responsible for creating the commit message when its turn arrives,
according to this order for each epoch.
For example, suppose there are two stewards in the list steward A first and steward B last in the list.
steward A is responsible for creating the commit message for first epoch.
Similarly, steward B is for the last epoch.
Since the epoch steward is the primary committer for an epoch,
it holds the main responsibility for producing the commit.
However, other stewards MAY also generate a commit within the same epoch to preserve liveness
in case the epoch steward is inactive or slow.
Duplicate commits are not re-applied and only the single valid commit for the epoch is accepted by the group,
as in described in section filtering proposals against the multiple comitting.
Therefore, if a malicious steward occurred, the backup steward will be charged with committing.
Lastly, the size of the list named as sn, which also shows the epoch interval for steward list determination.
Flow
General flow is as follows:
- A steward initializes a group just once, and then sends out Group Announcements (GA) periodically.
- Meanwhile, each
nodecreates and sends theircredentialincludeskeyPackage. - Each
membercreatesvoting proposalssends them to from MLS group duringepoch E. - Meanwhile, the
stewardcollects finalizedvoting proposalsfrom MLS group and converts them intoMLS proposalsthen sends them with correspondingcommit messages - Evantually, with the commit messages, all members starts the next
epoch E+1.
Creating Voting Proposal
A member MAY initializes the voting with the proposal payload
which is implemented using protocol buffers v3 as follows:
syntax = "proto3";
message Proposal {
string name = 10; // Proposal name
string payload = 11; // Describes the what is voting fore
int32 proposal_id = 12; // Unique identifier of the proposal
bytes proposal_owner = 13; // Public key of the creator
repeated Vote votes = 14; // Vote list in the proposal
int32 expected_voters_count = 15; // Maximum number of distinct voters
int32 round = 16; // Number of Votes
int64 timestamp = 17; // Creation time of proposal
int64 expiration_time = 18; // Time interval that the proposal is active
bool liveness_criteria_yes = 19; // Shows how managing the silent peers vote
}
message Vote {
int32 vote_id = 20; // Unique identifier of the vote
bytes vote_owner = 21; // Voter's public key
int64 timestamp = 22; // Time when the vote was cast
bool vote = 23; // Vote bool value (true/false)
bytes parent_hash = 24; // Hash of previous owner's Vote
bytes received_hash = 25; // Hash of previous received Vote
bytes vote_hash = 26; // Hash of all previously defined fields in Vote
bytes signature = 27; // Signature of vote_hash
}
The voting proposal MAY include adding a node or removing a member.
After the member creates the voting proposal,
it is emitted to the network via the MLS Application message with a lightweight,
epoch based voting such as hashgraphlike consensus.
This consensus result MUST be finalized within the epoch as YES or NO.
If the voting result is YES, this points out the voting proposal will be converted into
the MLS proposal by the steward and following commit message that starts the new epoch.
Creating welcome message
When a MLS MLS proposal message is created by the steward,
a commit message SHOULD follow,
as in section 12.04 MLS RFC 9420 to the members.
In order for the new member joining the group to synchronize with the current members
who received the commit message,
the steward sends a welcome message to the node as the new member,
as in section 12.4.3.1. MLS RFC 9420.
Single steward
To naive way to create a decentralized secure group messaging is having a single transparent steward
who only applies the changes regarding the result of the voting.
This is mostly similar with the general flow and specified in voting proposal and welcome message creation sections.
- Each time a single
stewardinitializes a group with group parameters with parameters as in section 8.1. Group Context in MLS RFC 9420. stewardcreates a group anouncement (GA) according to the previous step and broadcast it to the all network periodically. GA message is visible in network to allnodes.- The each
nodewho wants to be amemberneeds to obtain this anouncement and createcredentialincludeskeyPackagethat is specified in MLS RFC 9420 section 10. - The
nodesend theKeyPackagesin plaintext with its signature with currentstewardpublic key which anounced in welcome topic. This step is crucial for security, ensuring that malicious nodes/stewards cannot use others'KeyPackages. It also provides flexibility for liveness in multi-steward settings, allowing more than one steward to obtainKeyPackagesto commit. - The
stewardaggregates allKeyPackagesutilizes them to provision group additions for new members, based on the outcome of the voting process. - Any
memberstart to createvoting proposalsfor adding or removing users, and present them to the voting in the MLS group as an application message.
However, unlimited use of voting proposals within the group may be misused by
malicious or overly active members.
Therefore, an application-level constraint can be introduced to limit the number
or frequency of proposals initiated by each member to prevent spam or abuse.
7. Meanwhile, the steward collects finalized voting proposals with in epoch E,
that have received affirmative votes from members via application messages.
Otherwise, the steward discards proposals that did not receive a majority of "YES" votes.
Since voting proposals are transmitted as application messages, omitting them does not affect
the protocol’s correctness or consistency.
8. The steward converts all approved voting proposals into
corresponding MLS proposals and commit message, and
transmits both in a single operation as in MLS RFC 9420 section 12.4,
including welcome messages for the new members.
Therefore, the commit message ends the previous epoch and create new ones.
9. The members applied the incoming commit message by checking the signatures and voting proposals
and synchronized with the upcoming epoch.
Multi stewards
Decentralization has already been achieved in the previous section. However, to improve availability and ensure censorship resistance, the single steward protocol is extended to a multi steward architecture. In this design, each epoch is coordinated by a designated steward, operating under the same protocol as the single steward model. Thus, the multi steward approach primarily defines how steward roles rotate across epochs while preserving the underlying structure and logic of the original protocol. Two variants of the multi steward design are introduced to address different system requirements.
Consensus Types
Consensus is agnostic with its payload; therefore, it can be used for various purposes.
Note that each message for the consensus of proposals is an application message in the MLS object section.
It is used in three ways as follows:
Commit proposal: It is the proposal instance that is specified in Creating Voting Proposal section withProposal.payloadMUST show the commit request frommembers. Any member MAY create this proposal in any epoch andepoch stewardMUST collect and commit YES voted proposals. This is the only proposal type common to both single steward and multi steward designs.Steward election proposal: This is the process that finalizes thesteward list, which sets and orders stewards responsible for creating commits over a predefined number of range in (sn_min,sn_max). The validity of the choosensteward listends when the last steward in the list (the one at the final index) completes its commit. At that point, a newsteward election proposalMUST be initiated again by any member during the corresponding epoch. TheProposal.payloadfield MUST represent the ordered identities of the proposed stewards. Each steward election proposal MUST be verified and finalized through the consensus process so that members can identify which steward will be responsible in each epoch and detect any unauthorized steward commits.Emergency criteria proposal: If there is a malicious member or steward, this event MUST be voted on to finalize it. If this returns YES, the next epoch MUST include the removal of the member or steward. In a specific case where a steward is removed from the group, causing the total number of stewards to fall belowsn_min,
it is required to repeat thesteward election proposal.Proposal.payloadMUST consist of the evidence of the dishonesty as described in the Steward violation list, and the identifier of the malicious member or steward. This proposal can be created by any member in any epoch.
The order of consensus proposal messages is important to achieving a consistent result. Therefore, messages MUST be prioritized by type in the following order, from highest to lowest priority:
-
Emergency criteria proposal -
Steward election proposal -
Commit proposal
This means that if a higher-priority consensus proposal is present in the network, lower-priority messages MUST be withheld from transmission until the higher-priority proposals have been finalized.
Steward list creation
The steward list consists of steward nominees who will become actual stewards if the steward election proposal is finalized with YES,
is arbitrarily chosen from member and OPTIONALLY adjusted depending on the needs of the implementation.
The steward list size, defined by the minimum sn_min and maximum sn_max bounds,
is determined at the time of group creation.
The sn_min requirement is applied only when the total number of members exceeds sn_min;
if the number of available members falls below this threshold,
the list size automatically adjusts to include all existing members.
The actual size of the list MAY vary within this range as sn, with the minimum value being at least 1.
The index of the slots shows epoch info and value of index shows member ids.
The next in line steward for the epoch E is named as epoch steward, which has index E.
And the subsequent steward in the epoch E is named as the backup steward.
For example, let's assume steward list is (S3, S2, S1) if in the previous epoch the roles were
(backup steward: S2, epoch steward: S1), then in the next epoch they become
(backup steward: S3, epoch steward: S2) by shifting.
If the epoch steward is honest, the backup steward does not involve the process in epoch,
and the backup steward will be the epoch steward within the epoch E+1.
If the epoch steward is malicious, the backup steward is involved in the commitment phase in epoch E
and the former steward becomes the backup steward in epoch E.
Liveness criteria:
Once the active steward list has completed its assigned epochs,
members MUST proceed to elect the next set of stewards
(which MAY include some or all of the previous members).
This election is conducted through a type 2 consensus procedure, steward election proposal.
A Steward election proposal is considered valid only if the resulting steward list
is produced through a deterministic process that ensures an unbiased distribution of steward assignments,
since allowing bias could enable a malicious participant to manipulate the list
and retain control within a favored group for multiple epochs.
The list MUST consist of at least sn_min members, including retained previous stewards,
sorted according to the ascending value of SHA256(epoch E || member id || group id),
where epoch E is the epoch in which the election proposal is initiated,
and group id for shuffling the list across the different groups.
Any proposal with a list that does not adhere to this generation method MUST be rejected by all members.
We assume that there are no recurring entries in SHA256(epoch E || member id || group id), since the SHA256 outputs are unique
when there is no repetition in the member id values, against the conflicts on sorting issues.
Multi steward with big consensuses
In this model, all group modifications, such as adding or removing members,
must be approved through consensus by all participants,
including the steward assigned for epoch E.
A configuration with multiple stewards operating under a shared consensus protocol offers
increased decentralization and stronger protection against censorship.
However, this benefit comes with reduced operational efficiency.
The model is therefore best suited for small groups that value
decentralization and censorship resistance more than performance.
To create a multi steward with a big consensus, the group is initialized with a single steward as specified as follows:
- The steward initialized the group with the config file.
This config file MUST contain (
sn_min,sn_max) as thesteward listsize range. - The steward adds the members as a centralized way till the number of members reaches the
sn_min. Then, members propose lists by voting proposal with sizesnas a consensus among all members, as mentioned in the consensus section 2, according to the checks: the size of the proposed listsnis in the interval (sn_min,sn_max). Note that if the total number of members is belowsn_min, then the steward list size MUST be equal to the total member count. - After the voting proposal ends up with a
steward list, and group changes are ready to be committed as specified in single steward section with a difference which is members also check the committed steward isepoch stewardorbackup steward, otherwise anyone can createemergency criteria proposal. - If the
epoch stewardviolates the changing process as mentioned in the section Steward violation list, one of the members MUST initialize theemergency criteria proposalto remove the malicious Steward. Thenbackup stewardfulfills the epoch by committing again correctly.
A large consensus group provides better decentralization, but it requires significant coordination, which MAY not be suitable for groups with more than 1000 members.
Multi steward with small consensuses
The small consensus model offers improved efficiency with a trade-off in decentralization.
In this design, group changes require consensus only among the stewards, rather than all members.
Regular members participate by periodically selecting the stewards by steward election proposal
but do not take part in commit decision by commit proposal.
This structure enables faster coordination since consensus is achieved within a smaller group of stewards.
It is particularly suitable for large user groups, where involving every member in each decision would be impractical.
The flow is similar to the big consensus including the steward list finalization with all members consensus
only the difference here, the commit messages requires commit proposal only among the stewards.
Filtering proposals against the multiple comitting
Since stewards are allowed to produce a commit even when they are not the designated epoch steward,
multiple commits may appear within the same epoch, often reflecting recurring versions of the same proposal.
To ensure a consistent outcome, the valid commit for the epoch SHOULD be selected as the one derived
from the longest proposal chain, ordered by the ascending value of each proposal as SHA256(proposal).
All other cases, such as invalid commits or commits based on proposals that were not approved through voting,
can be easily detected and discarded by the members.
Steward violation list
A steward’s activity is called a violation if the action is one or more of the following:
- Broken commit: The steward releases a different commit message from the voted
commit proposal. This activity is identified by thememberssince the MLS RFC 9420 provides the methods that members can use to identify the broken commit messages that are possible in a few situations, such as commit and proposal incompatibility. Specifically, the broken commit can arise as follows:- The commit belongs to the earlier epoch.
- The commit message should equal the latest epoch
- The commit needs to be compatible with the previous epoch’s
MLS proposal.
- Broken MLS proposal: The steward prepares a different
MLS proposalfor the correspondingvoting proposal. This activity is identified by thememberssince bothMLS proposalandvoting proposalare visible and can be identified by checking the hash ofProposal.payloadandMLSProposal.payloadis the same as RFC9240 section 12.1. Proposals. - Censorship and inactivity: The situation where there is a voting proposal that is visible for every member,
and the Steward does not provide an MLS proposal and commit.
This activity is again identified by the
memberssincevoting proposalsare visible to every member in the group, therefore each member can verify that there is noMLS proposalcorresponding tovoting proposal.
Security Considerations
In this section, the security considerations are shown as de-MLS assurance.
- Malicious Steward: A Malicious steward can act maliciously, as in the Steward violation list section. Therefore, de-MLS enforces that any steward only follows the protocol under the consensus order and commits without emergency criteria application.
- Malicious Member: A member is only marked as malicious when the member acts by releasing a commit message.
- Steward list election bias: Although SHA256 is used together with two global variables to shuffle stewards in a deterministic and verifiable manner, this approach only minimizes election bias; it does not completely eliminate it. This design choice is intentional, in order to preserve the efficiency advantages provided by the MLS mechanism.
Copyright
Copyright and related rights waived via CC0
References
ETH-MLS-ONCHAIN
| Field | Value |
|---|---|
| Name | Secure channel setup using decentralized MLS and Ethereum accounts |
| Slug | 101 |
| Status | raw |
| Category | Standards Track |
| Editor | Ramses Fernandez [email protected] |
| Contributors | Aaryamann Challani [email protected], Ekaterina Broslavskaya [email protected], Ugur Sen [email protected], Ksr [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-04 —
517b639— Update the RFCs: Vac Raw RFC (#143) - 2024-10-03 —
c655980— Eth secpm splitted (#91) - 2024-08-29 —
13aaae3— Update eth-secpm.md (#84) - 2024-05-21 —
e234e9d— Update eth-secpm.md (#35) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-28 —
b842725— Update eth-secpm.md - 2024-02-01 —
f2e1b4c— Rename ETH-SECPM.md to eth-secpm.md - 2024-02-01 —
22bb331— Update ETH-SECPM.md - 2024-01-27 —
5b8ce46— Create ETH-SECPM.md
Motivation
The need for secure communications has become paramount.
Traditional centralized messaging protocols are susceptible to various security threats,
including unauthorized access, data breaches, and single points of failure.
Therefore a decentralized approach to secure communication becomes increasingly relevant,
offering a robust solution to address these challenges.
This document specifies a private messaging service using the Ethereum blockchain as authentication service. Rooted in the existing model, this proposal addresses the deficiencies related to forward privacy and authentication inherent in the current framework. The specification is divided into the following sections:
- Private group messaging protocol, based on the MLS protocol.
- Specification of an Ethereum-based authentication protocol, based on SIWE.
Protocol flow
The following steps outline the flow of the protocol.
Account Registration and Key Generation
Each user starts by registering their Ethereum account.
It is used as the authentication service.
Upon registration, the user generates a KeyPackage
that contains a public key
and supporting metadata required for the MLS group.
Group Initialization and Member Management
When a new group is created, the initiating client generates a new GroupContext.
It contains a unique group ID and an initial epoch.
To add members, the initiator sends an Add request,
which includes the new member’s KeyPackage.
Existing members can update their identity in the group using the Update proposal,
which replaces the sender’s LeafNode in the group’s ratchet tree.
Members can be removed from the group via a Remove proposal,
which specifies the index of the member to be removed from the tree.
Upon processing this proposal,
the group generates a new group key to ensure that removed members
no longer have access to future communications.
Commit and Authentication
After receiving a valid list of proposals (Add, Update, Remove),
a client initiates a Commit message,
processing the pending proposals and updates the group’s state.
The Commit message includes the updated GroupContext
and a FramedContentAuthData,
which ensures that all group members are aware of the changes.
Each member verifies the FramedContentAuthData to ensure the changes are consistent
with the current epoch of the GroupContext.
Message Exchange
Once the group is established and all members have processed the latest Commit,
messages can be securely exchanged using
the session keyderived from the group's ratchet tree.
Each message is encapsulated within a FramedContent structure
and authenticated using the FramedContentAuthData,
ensuring message integrity.
Group members use the current GroupContext to validate incoming messages
and ensure they are consistent with the current group state.
Use of smart contracts
This protocol accomplishes decentralization through the use of smart contracts for managing groups. They are used to register users in a group and keep the state of the group updated. Smart contracts MUST include an ACL to keep the state of the group.
Private group messaging protocol
Background
The Messaging Layer Security (MLS) protocol aims at providing a group of users with end-to-end encryption in an authenticated and asynchronous way. The main security characteristics of the protocol are: Message confidentiality and authentication, sender authentication, membership agreement, post-remove and post-update security, and forward secrecy and post-compromise security. The MLS protocol achieves: low-complexity, group integrity, synchronization and extensibility.
This document describes how the structure and methods of the MLS protocol are extended for their application in decentralized environments. The approach described in this document makes use of smart contracts. It makes use of a smart contract to manage each group chat. Furthermore, this document describes how to use the Sign-in With Ethereum protocol as authentication method.
Structure
Each MLS session uses a single cipher suite that specifies the primitives to be used in group key computations. The cipher suite MUST use:
X488as Diffie-Hellman function.SHA256as KDF.AES256-GCMas AEAD algorithm.SHA512as hash function.XEd448for digital signatures.
Formats for public keys, signatures and public-key encryption MUST follow Section 5.1 of RFC9420.
Hash-based identifiers
Some MLS messages refer to other MLS objects by hash. These identifiers MUST be computed according to Section 5.2 of RFC9420.
Credentials
Each member of a group presents a credential that provides one or more identities for the member and associates them with the member's signing key. The identities and signing key are verified by the Authentication Service in use for a group.
Credentials MUST follow the specifications of section 5.3 of RFC9420.
Below follows the flow diagram for the generation of credentials.
Users MUST generate key pairs by themselves.

Message framing
Handshake and application messages use a common framing structure providing encryption to ensure confidentiality within the group, and signing to authenticate the sender.
The structure is:
PublicMessage: represents a message that is only signed, and not encrypted. The definition and the encoding/decoding of aPublicMessageMUST follow the specification in section 6.2 of RFC9420.PrivateMessage: represents a signed and encrypted message, with protections for both the content of the message and related metadata.
The definition, and the encoding/decoding of a PrivateMessage
MUST follow the specification in section 6.3 of
RFC9420.
Applications MUST use PrivateMessage to encrypt application messages.
Applications SHOULD use PrivateMessage to encode handshake messages.
Each encrypted MLS message carries a "generation" number which is a per-sender incrementing counter. If a group member observes a gap in the generation sequence for a sender, then they know that they have missed a message from that sender.
Nodes contents
This section makes use of sections 4 and 7 of RFC9420.
The nodes of a ratchet tree (Section 4 in RFC9420) contain several types of data:
- Leaf nodes describe individual members.
- Parent nodes describe subgroups.
Contents of each kind of node, and its structure MUST follow the indications described in sections 7.1 and 7.2 of RFC9420.
Leaf node validation
KeyPackage objects describe the client's capabilities and
provides keys that can be used to add the client to a group.
The validity of a leaf node needs to be verified at the following stages:
- When a leaf node is downloaded in a
KeyPackage, before it is used to add the client to the group. - When a leaf node is received by a group member in an Add, Update, or Commit message.
- When a client validates a ratchet tree.
A client MUST verify the validity of a leaf node following the instructions of section 7.3 in RFC9420.
Ratchet tree evolution
Whenever a member initiates an epoch change, they MAY need to refresh the key pairs of their leaf and of the nodes on their direct path. This is done to keep forward secrecy and post-compromise security. The member initiating the epoch change MUST follow this procedure. A member updates the nodes along its direct path as follows:
- Blank all the nodes on the direct path from the leaf to the root.
- Generate a fresh HPKE key pair for the leaf.
- Generate a sequence of path secrets, one for each node on the leaf's filtered direct path.
It MUST follow the procedure described in section 7.4 of RFC9420.
- Compute the sequence of HPKE key pairs
(node_priv,node_pub), one for each node on the leaf's direct path.
It MUST follow the procedure described in section 7.4 of RFC9420.
Views of the tree synchronization
After generating fresh key material and applying it to update their local tree state, the generator broadcasts this update to other members of the group. This operation MUST be done according to section 7.5 of RFC9420.
Leaf synchronization
Changes to group memberships MUST be represented by adding and removing leaves of the tree. This corresponds to increasing or decreasing the depth of the tree, resulting in the number of leaves being doubled or halved. These operations MUST be done as described in section 7.7 of RFC9420.
Tree and parent hashing
Group members can agree on the cryptographic state of the group by
generating a hash value that represents the contents of the group
ratchet tree and the member’s credentials.
The hash of the tree is the hash of its root node,
defined recursively from the leaves.
Tree hashes summarize the state of a tree at point in time.
The hash of a leaf is the hash of the LeafNodeHashInput object.
At the same time the hash of a parent node, including the root,
is the hash of a ParentNodeHashInput object.
Parent hashes capture information about
how keys in the tree were populated.
Tree and parent hashing MUST follow the directions in Sections 7.8 and 7.9 of RFC9420.
Key schedule
Group keys are derived using the Extract and Expand functions from
the KDF for the group's cipher suite,
as well as the functions defined below:
ExpandWithLabel(Secret, Label, Context, Length) = KDF.Expand(Secret, KDFLabel, Length)
DeriveSecret(Secret, Label) = ExpandWithLabel(Secret, Label, "", KDF.Nh)
KDFLabel MUST be specified as:
struct {
uint16 length;
opaque label<V>;
opaque context<V>;
} KDFLabel;
The fields of KDFLabel MUST be:
length = Length;
label = "MLS 1.0 " + Label;
context = Context;
Each member of the group MUST maintaint a GroupContext object
summarizing the state of the group.
The sturcture of such object MUST be:
struct {
ProtocolVersion version = mls10;
CipherSuite cipher_suite;
opaque group_id<V>;
uint64 epoch;
opaque tree_hash<V>;
opaque confirmed_trasncript_hash<V>;
Extension extension<V>;
} GroupContext;
The use of key scheduling MUST follow the indications in sections 8.1 - 8.7 in RFC9420.
Secret trees
For the generation of encryption keys and nonces, the key schedule
begins with the encryption_secret at the root and derives a tree of
secrets with the same structure as the group's ratchet tree.
Each leaf in the secret tree is associated with the same group member
as the corresponding leaf in the ratchet tree.
If N is a parent node in the secret tree, the secrets of the children
of N MUST be defined following section 9 of
RFC9420.
Encryption keys
MLS encrypts three different types of information:
- Metadata (sender information).
- Handshake messages (Proposal and Commit).
- Application messages.
For handshake and application messages, a sequence of keys is derived via a sender ratchet. Each sender has their own sender ratchet, and each step along the ratchet is called a generation. These procedures MUST follow section 9.1 of RFC9420.
Deletion schedule
All security-sensitive values MUST be deleted as soon as they are consumed.
A sensitive value S is consumed if:
- S was used to encrypt or (successfully) decrypt a message.
- A key, nonce, or secret derived from S has been consumed.
The deletion procedure MUST follow the instruction described in section 9.2 of RFC9420.
Key packages
KeyPackage objects are used to ease the addition of clients to a group asynchronously.
A KeyPackage object specifies:
- Protocol version and cipher suite supported by the client.
- Public keys that can be used to encrypt Welcome messages. Welcome messages provide new members with the information to initialize their state for the epoch in which they were added or in which they want to add themselves to the group
- The content of the leaf node that should be added to the tree to represent this client.
KeyPackages are intended to be used only once and SHOULD NOT be reused.
Clients MAY generate and publish multiple KeyPackages to support multiple cipher suites.
The structure of the object MUST be:
struct {
ProtocolVersion version;
CipherSuite cipher_suite;
HPKEPublicKey init_key;
LeafNode leaf_node;
Extension extensions<V>;
/* SignWithLabel(., "KeyPackageTBS", KeyPackageTBS) */
opaque signature<V>;
}
struct {
ProtocolVersion version;
CipheSuite cipher_suite;
HPKEPublicKey init_key;
LeafNode leaf_node;
Extension extensions<V>;
}
KeyPackage object MUST be verified when:
- A
KeyPackageis downloaded by a group member, before it is used to add the client to the group. - When a
KeyPackageis received by a group member in anAddmessage.
Verification MUST be done as follows:
- Verify that the cipher suite and protocol version of the
KeyPackagematch those in theGroupContext. - Verify that the
leaf_nodeof theKeyPackageis valid for aKeyPackage. - Verify that the signature on the
KeyPackageis valid. - Verify that the value of
leaf_node.encryption_keyis different from the value of theinit_key field.
HPKE public keys are opaque values in a format defined by Section 4 of RFC9180.
Signature public keys are represented as opaque values in a format defined by the cipher suite's signature scheme.
Group creation
A group is always created with a single member. Other members are then added to the group using the usual Add/Commit mechanism. The creator of a group MUST set:
- the group ID.
- cipher suite.
- initial extensions for the group.
If the creator intends to add other members at the time of creation,
then it SHOULD fetch KeyPackages for those members, and select a
cipher suite and extensions according to their capabilities.
The creator MUST use the capabilities information in these
KeyPackages to verify that the chosen version and cipher suite is the
best option supported by all members.
Group IDs SHOULD be constructed so they are unique with high probability.
To initialize a group, the creator of the group MUST initialize a one member group with the following initial values:
- Ratchet tree: A tree with a single node, a leaf node containing an HPKE public key and credential for the creator.
- Group ID: A value set by the creator.
- Epoch:
0. - Tree hash: The root hash of the above ratchet tree.
- Confirmed transcript hash: The zero-length octet string.
- Epoch secret: A fresh random value of size
KDF.Nh. - Extensions: Any values of the creator's choosing.
The creator MUST also calculate the interim transcript hash:
- Derive the
confirmation_keyfor the epoch according to Section 8 of RFC9420. - Compute a
confirmation_tagover the emptyconfirmed_transcript_hashusing theconfirmation_keyas described in Section 8.1 of RFC9420. - Compute the updated
interim_transcript_hashfrom theconfirmed_transcript_hashand theconfirmation_tagas described in Section 8.2 RFC9420.
All members of a group MUST support the cipher suite and protocol
version in use. Additional requirements MAY be imposed by including a
required_capabilities extension in the GroupContext.
struct {
ExtensionType extension_types<V>;
ProposalType proposal_types<V>;
CredentialType credential_types<V>;
}
The flow diagram shows the procedure to fetch key material from other users:

Below follows the flow diagram for the creation of a group:

Group evolution
Group membership can change, and existing members can change their keys in order to achieve post-compromise security. In MLS, each such change is accomplished by a two-step process:
- A proposal to make the change is broadcast to the group in a Proposal message.
- A member of the group or a new member broadcasts a Commit message that causes one or more proposed changes to enter into effect.
The group evolves from one cryptographic state to another each time a Commit message is sent and processed. These states are called epochs and are uniquely identified among states of the group by eight-octet epoch values.
Proposals are included in a FramedContent by way of a Proposal
structure that indicates their type:
struct {
ProposalType proposal_type;
select (Proposal.proposal_type) {
case add: Add:
case update: Update;
case remove: Remove;
case psk: PreSharedKey;
case reinit: ReInit;
case external_init: ExternalInit;
case group_context_extensions: GroupContextExtensions;
}
}
On receiving a FramedContent containing a Proposal, a client MUST
verify the signature inside FramedContentAuthData and that the epoch
field of the enclosing FramedContent is equal to the epoch field of the
current GroupContext object.
If the verification is successful, then the Proposal SHOULD be cached
in such a way that it can be retrieved by hash in a later Commit
message.
Proposals are organized as follows:
Add: requests that a client with a specified KeyPackage be added to the group.Update: similar to Add, it replaces the sender's LeafNode in the tree instead of adding a new leaf to the tree.Remove: requests that the member with the leaf index removed be removed from the group.ReInit: requests to reinitialize the group with different parameters.ExternalInit: used by new members that want to join a group by using an external commit.GroupContentExtensions: it is used to update the list of extensions in the GroupContext for the group.
Proposals structure and semantics MUST follow sections 12.1.1 - 12.1.7 of RFC9420.
Any list of commited proposals MUST be validated either by a the group member who created the commit, or any group member processing such commit. The validation MUST be done according to one of the procedures described in Section 12.2 of RFC9420.
When creating or processing a Commit, a client applies a list of
proposals to the ratchet tree and GroupContext.
The client MUST apply the proposals in the list in the order described
in Section 12.3 of RFC9420.
Below follows the flow diagram for the addition of a member to a group:

The diagram below shows the procedure to remove a group member:

The flow diagram below shows an update procedure:

Commit messages
Commit messages initiate new group epochs. It informs group members to update their representation of the state of the group by applying the proposals and advancing the key schedule.
Each proposal covered by the Commit is included by a
ProposalOrRef value.
ProposalOrRef identify the proposal to be applied by value
or by reference.
Commits that refer to new Proposals from the committer
can be included by value.
Commits for previously sent proposals from anyone can be sent
by reference.
Proposals sent by reference are specified by including the hash of the
AuthenticatedContent.
Group members that have observed one or more valid proposals within an epoch MUST send a Commit message before sending application data. A sender and a receiver of a Commit MUST verify that the committed list of proposals is valid. The sender of a Commit SHOULD include all valid proposals received during the current epoch.
Functioning of commits MUST follow the instructions of Section 12.4 of RFC9420.
Application messages
Handshake messages provide an authenticated group key exchange
to clients.
To protect application messages sent among the members of a group, the
encryption_secret provided by the key schedule is used to derive a
sequence of nonces and keys for message encryption.
Each client MUST maintain their local copy of the key schedule for each epoch during which they are a group member. They derive new keys, nonces, and secrets as needed. This data MUST be deleted as soon as they have been used.
Group members MUST use the AEAD algorithm associated with the negotiated MLS ciphersuite to encrypt and decrypt Application messages according to the Message Framing section. The group identifier and epoch allow a device to know which group secrets should be used and from which Epoch secret to start computing other secrets and keys. Application messages SHOULD be padded to provide resistance against traffic analysis techniques. This avoids additional information to be provided to an attacker in order to guess the length of the encrypted message. Padding SHOULD be used on messages with zero-valued bytes before AEAD encryption.
Functioning of application messages MUST follow the instructions of Section 15 of RFC9420.
Implementation of the onchain component of the protocol
Assumptions
- Users have set a secure 1-1 communication channel.
- Each group is managed by a separate smart contract.
Addition of members to a group
- On-chain: Alice creates a Smart Contract with ACL.
- Off-chain: Alice sends the contract address and an invitation message to Bob over the secure channel.
- Off-chain: Bob sends a signed response confirming his Ethereum address and agreement to join.
- Off-chain: Alice verifies the signature using the public key of Bob.
- On-chain: Alice adds Bob’s address to the ACL.
- Off-chain: Alice sends a welcome message to Bob.
- Off-chain: Alice sends a broadcast message to all group members, notifying them the addition of Bob.

Updates in groups
Removal requests and update requests are considered the same operation. One assumes Alice is the creator of the contract. They MUST be processed as follows:
- Off-chain: Bob creates a new update request.
- Off-chain: Bob sends the update request to Alice.
- Off-chain: Alice verifies the request.
- On-chain: If the verification is successfull, Alice sends it to the smart contract for registration.
- Off-chain: Alice sends a broadcast message communicating the update to all users.

Ethereum-based authentication protocol
Introduction
Sign-in with Ethereum describes how Ethereum accounts authenticate with off-chain services by signing a standard message format parameterized by scope, session details, and security mechanisms. Sign-in with Ethereum (SIWE), which is described in the EIP 4361, MUST be the authentication method required.
Pattern
Message format (ABNF)
A SIWE Message MUST conform with the following Augmented Backus–Naur Form (RFC 5234) expression.
sign-in-with-ethereum =
[ scheme "://" ] domain %s" wants you to sign in with your
Ethereum account:" LF address LF
LF
[ statement LF ]
LF
%s"URI: " uri LF
%s"Version: " version LF
%s"Chain ID: " chain-id LF
%s"Nonce: " nonce LF
%s"Issued At: " issued-at
[ LF %s"Expiration Time: " expiration-time ]
[ LF %s"Not Before: " not-before ]
[ LF %s"Request ID: " request-id ]
[ LF %s"Resources:"
resources ]
scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
; See RFC 3986 for the fully contextualized
; definition of "scheme".
domain = authority
; From RFC 3986:
; authority = [ userinfo "@" ] host [ ":" port ]
; See RFC 3986 for the fully contextualized
; definition of "authority".
address = "0x" 40*40HEXDIG
; Must also conform to captilization
; checksum encoding specified in EIP-55
; where applicable (EOAs).
statement = *( reserved / unreserved / " " )
; See RFC 3986 for the definition
; of "reserved" and "unreserved".
; The purpose is to exclude LF (line break).
uri = URI
; See RFC 3986 for the definition of "URI".
version = "1"
chain-id = 1*DIGIT
; See EIP-155 for valid CHAIN_IDs.
nonce = 8*( ALPHA / DIGIT )
; See RFC 5234 for the definition
; of "ALPHA" and "DIGIT".
issued-at = date-time
expiration-time = date-time
not-before = date-time
; See RFC 3339 (ISO 8601) for the
; definition of "date-time".
request-id = *pchar
; See RFC 3986 for the definition of "pchar".
resources = *( LF resource )
resource = "- " URI
This specification defines the following SIWE Message fields that can be parsed from a SIWE Message by following the rules in ABNF Message Format. This section follows the section ABNF message format in EIP 4361.
-
schemeOPTIONAL. The URI scheme of the origin of the request. Its value MUST be a RFC 3986 URI scheme. -
domainREQUIRED. The domain that is requesting the signing. Its value MUST be a RFC 3986 authority. The authority includes an OPTIONAL port. If the port is not specified, the default port for the provided scheme is assumed.
If scheme is not specified, HTTPS is assumed by default.
-
addressREQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address encoding specified in ERC-55 where applicable. -
statementOPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a). -
uriREQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing. -
versionREQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification. -
chain-idREQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved. -
nonceREQUIRED. A random string (minimum 8 alphanumeric characters) chosen by the relying party and used to prevent replay attacks. -
issued-atREQUIRED. The time when the message was generated, typically the current time.
Its value MUST be an ISO 8601 datetime string.
expiration-timeOPTIONAL. The time when the signed authentication message is no longer valid.
Its value MUST be an ISO 8601 datetime string.
not-beforeOPTIONAL. The time when the signed authentication message will become valid.
Its value MUST be an ISO 8601 datetime string.
-
request-idOPTIONAL. An system-specific identifier that MAY be used to uniquely refer to the sign-in request. -
resourcesOPTIONAL. A list of information or references to information the user wishes to have resolved as part of authentication by the relying party.
Every resource MUST be a RFC 3986 URI separated by "\n- " where \n is the byte 0x0a.
Signing and Verifying Messages with Ethereum Accounts
-
For Externally Owned Accounts, the verification method specified in ERC-191 MUST be used.
-
For Contract Accounts,
-
The verification method specified in ERC-1271 SHOULD be used. Otherwise, the implementer MUST clearly define the verification method to attain security and interoperability for both wallets and relying parties.
-
When performing ERC-1271 signature verification, the contract performing the verification MUST be resolved from the specified
chain-id. -
Implementers SHOULD take into consideration that ERC-1271 implementations are not required to be pure functions. They can return different results for the same inputs depending on blockchain state. This can affect the security model and session validation rules.
-
Resolving Ethereum Name Service (ENS) Data
-
The relying party or wallet MAY additionally perform resolution of ENS data, as this can improve the user experience by displaying human friendly information that is related to the
address. Resolvable ENS data include:- The primary ENS name.
- The ENS avatar.
- Any other resolvable resources specified in the ENS documentation.
-
If resolution of ENS data is performed, implementers SHOULD take precautions to preserve user privacy and consent. Their
addresscould be forwarded to third party services as part of the resolution process.
Implementer steps: specifying the request origin
The domain and, if present, the scheme, in the SIWE Message MUST
correspond to the origin from where the signing request was made.
Implementer steps: verifying a signed message
The SIWE Message MUST be checked for conformance to the ABNF Message Format and its signature MUST be checked as defined in Signing and Verifying Messages with Ethereum Accounts.
Implementer steps: creating sessions
Sessions MUST be bound to the address and not to further resolved resources that can change.
Implementer steps: interpreting and resolving resources
Implementers SHOULD ensure that that URIs in the listed resources are human-friendly when expressed in plaintext form.
Wallet implementer steps: verifying the message format
The full SIWE message MUST be checked for conformance to the ABNF defined in ABNF Message Format.
Wallet implementers SHOULD warn users if the substring "wants you to sign in with your Ethereum account" appears anywhere in an
ERC-191
message signing request unless the message fully conforms to the format
defined ABNF Message Format.
Wallet implementer steps: verifying the request origin
Wallet implementers MUST prevent phishing attacks by verifying the
origin of the request against the scheme and domain fields in the
SIWE Message.
The origin SHOULD be read from a trusted data source such as the browser window or over WalletConnect ERC-1328 sessions for comparison against the signing message contents.
Wallet implementers MAY warn instead of rejecting the verification if the origin is pointing to localhost.
The following is a RECOMMENDED algorithm for Wallets to conform with the requirements on request origin verification defined by this specification.
The algorithm takes the following input variables:
- fields from the SIWE message.
originof the signing request: the origin of the page which requested the signin via the provider.allowedSchemes: a list of schemes allowed by the Wallet.defaultScheme: a scheme to assume when none was provided. Wallet implementers in the browser SHOULD use https.- developer mode indication: a setting deciding if certain risks should
be a warning instead of rejection. Can be manually configured or
derived from
originbeing localhost.
The algorithm is described as follows:
- If
schemewas not provided, then assigndefaultSchemeas scheme. - If
schemeis not contained inallowedSchemes, then theschemeis not expected and the Wallet MUST reject the request. Wallet implementers in the browser SHOULD limit the list of allowedSchemes to just 'https' unless a developer mode is activated. - If
schemedoes not match the scheme of origin, the Wallet SHOULD reject the request. Wallet implementers MAY show a warning instead of rejecting the request if a developer mode is activated. In that case the Wallet continues processing the request. - If the
hostpart of thedomainandorigindo not match, the Wallet MUST reject the request unless the Wallet is in developer mode. In developer mode the Wallet MAY show a warning instead and continues procesing the request. - If
domainandoriginhave mismatching subdomains, the Wallet SHOULD reject the request unless the Wallet is in developer mode. In developer mode the Wallet MAY show a warning instead and continues procesing the request. - Let
portbe the port component ofdomain, and if no port is contained in domain, assign port the default port specified for the scheme. - If
portis not empty, then the Wallet SHOULD show a warning if theportdoes not match the port oforigin. - If
portis empty, then the Wallet MAY show a warning iforigincontains a specific port. - Return request origin verification completed.
Wallet implementer steps: creating SIWE interfaces
Wallet implementers MUST display to the user the following fields from
the SIWE Message request by default and prior to signing, if they are
present: scheme, domain, address, statement, and resources.
Other present fields MUST also be made available to the user prior to
signing either by default or through an extended interface.
Wallet implementers displaying a plaintext SIWE Message to the user SHOULD require the user to scroll to the bottom of the text area prior to signing.
Wallet implementers MAY construct a custom SIWE user interface by parsing the ABNF terms into data elements for use in the interface. The display rules above still apply to custom interfaces.
Wallet implementer steps: supporting internationalization (i18n)
After successfully parsing the message into ABNF terms, translation MAY happen at the UX level per human language.
Consideration to secure 1-to-1 channels
There are situations where users need to set one-to-one communication channels in a secure way. One of these situations is when a user A wants to add a new user B to an existing group. In such situations communications between users MUST be done following the instructions in this specification describing the use of X3DH in combination with the double ratchet mechanism.
Considerations with respect to decentralization
The MLS protocol assumes the existence of a (central, untrusted) delivery service, whose responsabilites include:
- Acting as a directory service providing the initial keying material for clients to use.
- Routing MLS messages among clients.
The central delivery service can be avoided in protocols using the publish/gossip approach, such as gossipsub.
Concerning keys, each node can generate and disseminate their encryption key among the other nodes, so they can create a local version of the tree that allows for the generation of the group key.
Another important component is the authentication service, which is replaced with SIWE in this specification.
Privacy and Security Considerations
- For the information retrieval, the algorithm MUST include a access control mechanisms to restrict who can call the set and get functions.
- One SHOULD include event logs to track changes in public keys.
- The curve vurve448 MUST be chosen due to its higher security level: 224-bit security instead of the 128-bit security provided by X25519.
- It is important that Bob MUST NOT reuse
SPK.
Copyright
Copyright and related rights waived via CC0.
References
- Augmented BNF for Syntax Specifications
- Gossipsub
- HMAC-based Extract-and-Expand Key Derivation Function
- Hybrid Public Key Encryption
- Security Analysis and Improvements for the IETF MLS Standard for Group Messaging
- Signed Data Standard
- Sign-In with Ethereum
- Standard Signature Validation Method for Contracts
- The Messaging Layer Security Protocol
- Toy Ethereum Private Messaging Protocol
- Uniform Resource Identifier
- WalletConnect URI Format
ETH-SECPM
| Field | Value |
|---|---|
| Name | Secure channel setup using Ethereum accounts |
| Slug | 110 |
| Status | deleted |
| Category | Standards Track |
| Editor | Ramses Fernandez [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-06-05 —
36caaa6— Fix Errors rfc.vac.dev (#165) - 2025-06-02 —
db90adc— Fix LaTeX errors (#163) - 2025-04-04 —
517b639— Update the RFCs: Vac Raw RFC (#143) - 2024-08-29 —
13aaae3— Update eth-secpm.md (#84) - 2024-05-21 —
e234e9d— Update eth-secpm.md (#35) - 2024-03-21 —
2eaa794— Broken Links + Change Editors (#26) - 2024-02-28 —
b842725— Update eth-secpm.md - 2024-02-01 —
f2e1b4c— Rename ETH-SECPM.md to eth-secpm.md - 2024-02-01 —
22bb331— Update ETH-SECPM.md - 2024-01-27 —
5b8ce46— Create ETH-SECPM.md
NOTE
The content of this specification has been split between ETH-MLS-OFFCHAIN and NOISE-X3DH-DOUBLE-RATCHET LIPs.
Motivation
The need for secure communications has become paramount.
Traditional centralized messaging protocols are susceptible to various security threats,
including unauthorized access, data breaches, and single points of failure.
Therefore a decentralized approach to secure communication becomes increasingly relevant,
offering a robust solution to address these challenges.
This specification outlines a private messaging service using the Ethereum blockchain as authentication service. Rooted in the existing model, this proposal addresses the deficiencies related to forward privacy and authentication inherent in the current framework. The specification is divided into 3 sections:
- Private 1-to-1 communications protocol, based on Signal's double ratchet.
- Private group messaging protocol, based on the MLS protocol.
- Description of an Ethereum-based authentication protocol, based on SIWE.
Private 1-to-1 communications protocol
Theory
The specification is based on the noise protocol framework. It corresponds to the double ratchet scheme combined with the X3DH algorithm, which will be used to initialize the former. We chose to express the protocol in noise to be be able to use the noise streamlined implementation and proving features. The X3DH algorithm provides both authentication and forward secrecy, as stated in the X3DH specification.
This protocol will consist of several stages:
- Key setting for X3DH: this step will produce prekey bundles for Bob which will be fed into X3DH. It will also allow Alice to generate the keys required to run the X3DH algorithm correctly.
- Execution of X3DH: This step will output
a common secret key
SKtogether with an additional data vectorAD. Both will be used in the double ratchet algorithm initialization. - Execution of the double ratchet algorithm
for forward secure, authenticated communications,
using the common secret key
SK, obtained from X3DH, as a root key.
The protocol assumes the following requirements:
- Alice knows Bob’s Ethereum address.
- Bob is willing to participate in the protocol, and publishes his public key.
- Bob’s ownership of his public key is verifiable,
- Alice wants to send message M to Bob.
- An eavesdropper cannot read M’s content even if she is storing it or relaying it.
The inclusion of this first section devoted to secure 1-to-1 communications between users is motivated by the fact certain interactions between existing group members and prospective new members require secure communication channels.
Syntax
Cryptographic suite
The following cryptographic functions MUST be used:
X488as Diffie-Hellman functionDH.SHA256as KDF.AES256-GCMas AEAD algorithm.SHA512as hash function.XEd448for digital signatures.
X3DH initialization
This scheme MUST work on the curve curve448. The X3DH algorithm corresponds to the IX pattern in Noise.
Bob and Alice MUST define personal key pairs
(ik_B, IK_B) and (ik_A, IK_A) respectively where:
- The key
ikmust be kept secret, - and the key
IKis public.
Bob MUST generate new keys using
(ik_B, IK_B) = GENERATE_KEYPAIR(curve = curve448).
Bob MUST also generate a public key pair
(spk_B, SPK_B) = GENERATE_KEYPAIR(curve = curve448).
SPK is a public key generated and stored at medium-term.
Both signed prekey and the certificate MUST
undergo periodic replacement.
After replacing the key,
Bob keeps the old private key of SPK
for some interval, dependant on the implementation.
This allows Bob to decrypt delayed messages.
Bob MUST sign SPK for authentication:
SigSPK = XEd448(ik, Encode(SPK))
A final step requires the definition of
prekey_bundle = (IK, SPK, SigSPK, OPK_i)
One-time keys OPK MUST be generated as
(opk_B, OPK_B) = GENERATE_KEYPAIR(curve = curve448).
Before sending an initial message to Bob,
Alice MUST generate an AD: AD = Encode(IK_A) || Encode(IK_B).
Alice MUST generate ephemeral key pairs
(ek, EK) = GENERATE_KEYPAIR(curve = curve448).
The function Encode() transforms a
curve448 public key into a byte sequence.
This is specified in the RFC 7748
on elliptic curves for security.
One MUST consider q = 2^446 - 13818066809895115352007386748515426880336692474882178609894547503885
for digital signatures with (XEd448_sign, XEd448_verify):
XEd448_sign((ik, IK), message):
Z = randbytes(64)
r = SHA512(2^456 - 2 || ik || message || Z )
R = (r * convert_mont(5)) % q
h = SHA512(R || IK || M)
s = (r + h * ik) % q
return (R || s)
XEd448_verify(u, message, (R || s)):
if (R.y >= 2^448) or (s >= 2^446): return FALSE
h = (SHA512(R || 156326 || message)) % q
R_check = s * convert_mont(5) - h * 156326
if R == R_check: return TRUE
return FALSE
convert_mont(u):
u_masked = u % mod 2^448
inv = ((1 - u_masked)^(2^448 - 2^224 - 3)) % (2^448 - 2^224 - 1)
P.y = ((1 + u_masked) * inv)) % (2^448 - 2^224 - 1)
P.s = 0
return P
Use of X3DH
This specification combines the double ratchet with X3DH using the following data as initialization for the former:
- The
SKoutput from X3DH becomes theSKinput of the double ratchet. See section 3.3 of Signal Specification for a detailed description. - The
ADoutput from X3DH becomes theADinput of the double ratchet. See sections 3.4 and 3.5 of Signal Specification for a detailed description. - Bob’s signed prekey
SigSPKBfrom X3DH is used as Bob’s initial ratchet public key of the double ratchet.
X3DH has three phases:
- Bob publishes his identity key and prekeys to a server, a network, or dedicated smart contract.
- Alice fetches a prekey bundle from the server, and uses it to send an initial message to Bob.
- Bob receives and processes Alice's initial message.
Alice MUST perform the following computations:
dh1 = DH(IK_A, SPK_B, curve = curve448)
dh2 = DH(EK_A, IK_B, curve = curve448)
dh3 = DH(EK_A, SPK_B)
SK = KDF(dh1 || dh2 || dh3)
Alice MUST send to Bob a message containing:
IK_A, EK_A.- An identifier to Bob's prekeys used.
- A message encrypted with AES256-GCM using
ADandSK.
Upon reception of the initial message, Bob MUST:
- Perform the same computations above with the
DH()function. - Derive
SKand constructAD. - Decrypt the initial message encrypted with
AES256-GCM. - If decryption fails, abort the protocol.
Initialization of the double datchet
In this stage Bob and Alice have generated key pairs
and agreed a shared secret SK using X3DH.
Alice calls RatchetInitAlice() defined below:
RatchetInitAlice(SK, IK_B):
state.DHs = GENERATE_KEYPAIR(curve = curve448)
state.DHr = IK_B
state.RK, state.CKs = HKDF(SK, DH(state.DHs, state.DHr))
state.CKr = None
state.Ns, state.Nr, state.PN = 0
state.MKSKIPPED = {}
The HKDF function MUST be the proposal by
Krawczyk and Eronen.
In this proposal chaining_key and input_key_material
MUST be replaced with SK and the output of DH respectively.
Similarly, Bob calls the function RatchetInitBob() defined below:
RatchetInitBob(SK, (ik_B,IK_B)):
state.DHs = (ik_B, IK_B)
state.Dhr = None
state.RK = SK
state.CKs, state.CKr = None
state.Ns, state.Nr, state.PN = 0
state.MKSKIPPED = {}
Encryption
This function performs the symmetric key ratchet.
RatchetEncrypt(state, plaintext, AD):
state.CKs, mk = HMAC-SHA256(state.CKs)
header = HEADER(state.DHs, state.PN, state.Ns)
state.Ns = state.Ns + 1
return header, AES256-GCM_Enc(mk, plaintext, AD || header)
The HEADER function creates a new message header
containing the public key from the key pair output of the DHfunction.
It outputs the previous chain length pn,
and the message number n.
The returned header object contains ratchet public key
dh and integers pn and n.
Decryption
The function RatchetDecrypt() decrypts incoming messages:
RatchetDecrypt(state, header, ciphertext, AD):
plaintext = TrySkippedMessageKeys(state, header, ciphertext, AD)
if plaintext != None:
return plaintext
if header.dh != state.DHr:
SkipMessageKeys(state, header.pn)
DHRatchet(state, header)
SkipMessageKeys(state, header.n)
state.CKr, mk = HMAC-SHA256(state.CKr)
state.Nr = state.Nr + 1
return AES256-GCM_Dec(mk, ciphertext, AD || header)
Auxiliary functions follow:
DHRatchet(state, header):
state.PN = state.Ns
state.Ns = state.Nr = 0
state.DHr = header.dh
state.RK, state.CKr = HKDF(state.RK, DH(state.DHs, state.DHr))
state.DHs = GENERATE_KEYPAIR(curve = curve448)
state.RK, state.CKs = HKDF(state.RK, DH(state.DHs, state.DHr))
SkipMessageKeys(state, until):
if state.NR + MAX_SKIP < until:
raise Error
if state.CKr != none:
while state.Nr < until:
state.CKr, mk = HMAC-SHA256(state.CKr)
state.MKSKIPPED[state.DHr, state.Nr] = mk
state.Nr = state.Nr + 1
TrySkippedMessageKey(state, header, ciphertext, AD):
if (header.dh, header.n) in state.MKSKIPPED:
mk = state.MKSKIPPED[header.dh, header.n]
delete state.MKSKIPPED[header.dh, header.n]
return AES256-GCM_Dec(mk, ciphertext, AD || header)
else: return None
Information retrieval
Static data
Some data, such as the key pairs (ik, IK) for Alice and Bob,
MAY NOT be regenerated after a period of time.
Therefore the prekey bundle MAY be stored in long-term
storage solutions, such as a dedicated smart contract
which outputs such a key pair when receiving an Ethereum wallet
address.
Storing static data is done using a dedicated
smart contract PublicKeyStorage which associates
the Ethereum wallet address of a user with his public key.
This mapping is done by PublicKeyStorage
using a publicKeys function, or a setPublicKey function.
This mapping is done if the user passed an authorization process.
A user who wants to retrieve a public key associated
with a specific wallet address calls a function getPublicKey.
The user provides the wallet address as the only
input parameter for getPublicKey.
The function outputs the associated public key
from the smart contract.
Ephemeral data
Storing ephemeral data on Ethereum MAY be done using a combination of on-chain and off-chain solutions. This approach provides an efficient solution to the problem of storing updatable data in Ethereum.
- Ethereum stores a reference or a hash that points to the off-chain data.
- Off-chain solutions can include systems like IPFS, traditional cloud storage solutions, or decentralized storage networks such as a Swarm.
In any case, the user stores the associated IPFS hash, URL or reference in Ethereum.
The fact of a user not updating the ephemeral information can be understood as Bob not willing to participate in any communication.
This applies to KeyPackage,
which in the MLS specification are meant
o be stored in a directory provided by the delivery service.
If such an element does not exist,
KeyPackage MUST be stored according
to one of the two options outlined above.
Private group messaging protocol
Theoretical content
The Messaging Layer Security(MLS) protocol aims at providing a group of users with end-to-end encryption in an authenticated and asynchronous way. The main security characteristics of the protocol are: Message confidentiality and authentication, sender authentication, membership agreement, post-remove and post-update security, and forward secrecy and post-compromise security. The MLS protocol achieves: low-complexity, group integrity, synchronization and extensibility.
The extension to group chat described in forthcoming sections is built upon the MLS protocol.
Structure
Each MLS session uses a single cipher suite that specifies the primitives to be used in group key computations. The cipher suite MUST use:
X488as Diffie-Hellman function.SHA256as KDF.AES256-GCMas AEAD algorithm.SHA512as hash function.XEd448for digital signatures.
Formats for public keys, signatures and public-key encryption MUST follow Section 5.1 of RFC9420.
Hash-based identifiers
Some MLS messages refer to other MLS objects by hash. These identifiers MUST be computed according to Section 5.2 of RFC9420.
Credentials
Each member of a group presents a credential that provides one or more identities for the member and associates them with the member's signing key. The identities and signing key are verified by the Authentication Service in use for a group.
Credentials MUST follow the specifications of section 5.3 of RFC9420.
Below follows the flow diagram for the generation of credentials.
Users MUST generate key pairs by themselves.

Message framing
Handshake and application messages use a common framing structure providing encryption to ensure confidentiality within the group, and signing to authenticate the sender.
The structure is:
PublicMessage: represents a message that is only signed, and not encrypted. The definition and the encoding/decoding of aPublicMessageMUST follow the specification in section 6.2 of RFC9420.PrivateMessage: represents a signed and encrypted message, with protections for both the content of the message and related metadata.
The definition, and the encoding/decoding of a PrivateMessage MUST
follow the specification in section 6.3 of
RFC9420.
Applications MUST use PrivateMessage to encrypt application messages.
Applications SHOULD use PrivateMessage to encode handshake messages.
Each encrypted MLS message carries a "generation" number which is a per-sender incrementing counter. If a group member observes a gap in the generation sequence for a sender, then they know that they have missed a message from that sender.
Nodes contents
The nodes of a ratchet tree contain several types of data:
- Leaf nodes describe individual members.
- Parent nodes describe subgroups.
Contents of each kind of node, and its structure MUST follow the indications described in sections 7.1 and 7.2 of RFC9420.
Leaf node validation
KeyPackage objects describe the client's capabilities and provides
keys that can be used to add the client to a group.
The validity of a leaf node needs to be verified at the following stages:
- When a leaf node is downloaded in a
KeyPackage, before it is used to add the client to the group. - When a leaf node is received by a group member in an Add, Update, or Commit message.
- When a client validates a ratchet tree.
A client MUST verify the validity of a leaf node following the instructions of section 7.3 in RFC9420.
Ratchet tree evolution
Whenever a member initiates an epoch change, they MAY need to refresh the key pairs of their leaf and of the nodes on their direct path. This is done to keep forward secrecy and post-compromise security. The member initiating the epoch change MUST follow this procedure procedure. A member updates the nodes along its direct path as follows:
- Blank all the nodes on the direct path from the leaf to the root.
- Generate a fresh HPKE key pair for the leaf.
- Generate a sequence of path secrets, one for each node on the leaf's filtered direct path.
It MUST follow the procedure described in section 7.4 of [RFC9420 (https://datatracker.ietf.org/doc/rfc9420/).
- Compute the sequence of HPKE key pairs
(node_priv,node_pub), one for each node on the leaf's direct path.
It MUST follow the procedure described in section 7.4 of [RFC9420 (https://datatracker.ietf.org/doc/rfc9420/).
Views of the tree synchronization
After generating fresh key material and applying it to update their local tree state, the generator broadcasts this update to other members of the group. This operation MUST be done according to section 7.5 of [RFC9420 (https://datatracker.ietf.org/doc/rfc9420/).
Leaf synchronization
Changes to group memberships MUST be represented by adding and removing leaves of the tree. This corresponds to increasing or decreasing the depth of the tree, resulting in the number of leaves being doubled or halved. These operations MUST be done as described in section 7.7 of [RFC9420 (https://datatracker.ietf.org/doc/rfc9420/).
Tree and parent hashing
Group members can agree on the cryptographic state of the group by
generating a hash value that represents the contents of the group
ratchet tree and the member’s credentials.
The hash of the tree is the hash of its root node, defined recursively
from the leaves.
Tree hashes summarize the state of a tree at point in time.
The hash of a leaf is the hash of the LeafNodeHashInput object.
At the same time, the hash of a parent node including the root, is the
hash of a ParentNodeHashInput object.
Parent hashes capture information about how keys in the tree were
populated.
Tree and parent hashing MUST follow the directions in Sections 7.8 and 7.9 of RFC9420.
Key schedule
Group keys are derived using the Extract and Expand functions from
the KDF for the group's cipher suite, as well as the functions defined
below:
ExpandWithLabel(Secret, Label, Context, Length) = KDF.Expand(Secret,
KDFLabel, Length)
DeriveSecret(Secret, Label) = ExpandWithLabel(Secret, Label, "",
KDF.Nh)
KDFLabel MUST be specified as:
struct {
uint16 length;
opaque label<V>;
opaque context<V>;
} KDFLabel;
The fields of KDFLabel MUST be:
length = Length;
label = "MLS 1.0 " + Label;
context = Context;
Each member of the group MUST maintaint a GroupContext object
summarizing the state of the group.
The sturcture of such object MUST be:
struct {
ProtocolVersion version = mls10;
CipherSuite cipher_suite;
opaque group_id<V>;
uint64 epoch;
opaque tree_hash<V>;
opaque confirmed_trasncript_hash<V>;
Extension extension<V>;
} GroupContext;
The use of key scheduling MUST follow the indications in sections 8.1 - 8.7 in RFC9420.
Secret trees
For the generation of encryption keys and nonces, the key schedule
begins with the encryption_secret at the root and derives a tree of
secrets with the same structure as the group's ratchet tree.
Each leaf in the secret tree is associated with the same group member
as the corresponding leaf in the ratchet tree.
If N is a parent node in the secret tree, the secrets of the children
of N MUST be defined following section 9 of
RFC9420.
Encryption keys
MLS encrypts three different types of information:
- Metadata (sender information).
- Handshake messages (Proposal and Commit).
- Application messages.
For handshake and application messages, a sequence of keys is derived via a sender ratchet. Each sender has their own sender ratchet, and each step along the ratchet is called a generation. These procedures MUST follow section 9.1 of RFC9420.
Deletion schedule
All security-sensitive values MUST be deleted as soon as they are consumed.
A sensitive value S is consumed if:
- S was used to encrypt or (successfully) decrypt a message.
- A key, nonce, or secret derived from S has been consumed.
The deletion procedure MUST follow the instruction described in section 9.2 of RFC9420.
Key packages
KeyPackage objects are used to ease the addition of clients to a group asynchronously. A KeyPackage object specifies:
- Protocol version and cipher suite supported by the client.
- Public keys that can be used to encrypt Welcome messages. Welcome messages provide new members with the information to initialize their state for the epoch in which they were added or in which they want to add themselves to the group
- The content of the leaf node that should be added to the tree to represent this client.
KeyPackages are intended to be used only once and SHOULD NOT be reused.
Clients MAY generate and publish multiple KeyPackages to support multiple cipher suites.
The structure of the object MUST be:
struct {
ProtocolVersion version;
CipherSuite cipher_suite;
HPKEPublicKey init_key;
LeafNode leaf_node;
Extension extensions<V>;
/* SignWithLabel(., "KeyPackageTBS", KeyPackageTBS) */
opaque signature<V>;
}
struct {
ProtocolVersion version;
CipheSuite cipher_suite;
HPKEPublicKey init_key;
LeafNode leaf_node;
Extension extensions<V>;
}
KeyPackage object MUST be verified when:
- A
KeyPackageis downloaded by a group member, before it is used to add the client to the group. - When a
KeyPackageis received by a group member in anAddmessage.
Verification MUST be done as follows:
- Verify that the cipher suite and protocol version of the
KeyPackagematch those in theGroupContext. - Verify that the
leaf_nodeof theKeyPackageis valid for aKeyPackage. - Verify that the signature on the
KeyPackageis valid. - Verify that the value of
leaf_node.encryption_keyis different from the value of theinit_key field.
HPKE public keys are opaque values in a format defined by Section 4 of RFC9180.
Signature public keys are represented as opaque values in a format defined by the cipher suite's signature scheme.
Group creation
A group is always created with a single member. Other members are then added to the group using the usual Add/Commit mechanism. The creator of a group MUST set:
- the group ID.
- cipher suite.
- initial extensions for the group.
If the creator intends to add other members at the time of creation,
then it SHOULD fetch KeyPackages for those members, and select a
cipher suite and extensions according to their capabilities.
The creator MUST use the capabilities information in these
KeyPackages to verify that the chosen version and cipher suite is the
best option supported by all members.
Group IDs SHOULD be constructed so they are unique with high probability.
To initialize a group, the creator of the group MUST initialize a one member group with the following initial values:
- Ratchet tree: A tree with a single node, a leaf node containing an HPKE public key and credential for the creator.
- Group ID: A value set by the creator.
- Epoch:
0. - Tree hash: The root hash of the above ratchet tree.
- Confirmed transcript hash: The zero-length octet string.
- Epoch secret: A fresh random value of size
KDF.Nh. - Extensions: Any values of the creator's choosing.
The creator MUST also calculate the interim transcript hash:
- Derive the
confirmation_keyfor the epoch according to Section 8 of RFC9420. - Compute a
confirmation_tagover the emptyconfirmed_transcript_hashusing theconfirmation_keyas described in Section 8.1 of RFC9420. - Compute the updated
interim_transcript_hashfrom theconfirmed_transcript_hashand theconfirmation_tagas described in Section 8.2 RFC9420.
All members of a group MUST support the cipher suite and protocol
version in use. Additional requirements MAY be imposed by including a
required_capabilities extension in the GroupContext.
struct {
ExtensionType extension_types<V>;
ProposalType proposal_types<V>;
CredentialType credential_types<V>;
}
The flow diagram shows the procedure to fetch key material from other
users:

Below follows the flow diagram for the creation of a group:

Group evolution
Group membership can change, and existing members can change their keys in order to achieve post-compromise security. In MLS, each such change is accomplished by a two-step process:
- A proposal to make the change is broadcast to the group in a Proposal message.
- A member of the group or a new member broadcasts a Commit message that causes one or more proposed changes to enter into effect.
The group evolves from one cryptographic state to another each time a Commit message is sent and processed. These states are called epochs and are uniquely identified among states of the group by eight-octet epoch values.
Proposals are included in a FramedContent by way of a Proposal
structure that indicates their type:
struct {
ProposalType proposal_type;
select (Proposal.proposal_type) {
case add: Add:
case update: Update;
case remove: Remove;
case psk: PreSharedKey;
case reinit: ReInit;
case external_init: ExternalInit;
case group_context_extensions: GroupContextExtensions;
}
On receiving a FramedContent containing a Proposal, a client MUST
verify the signature inside FramedContentAuthData and that the epoch
field of the enclosing FramedContent is equal to the epoch field of the
current GroupContext object.
If the verification is successful, then the Proposal SHOULD be cached
in such a way that it can be retrieved by hash in a later Commit
message.
Proposals are organized as follows:
Add: requests that a client with a specified KeyPackage be added to the group.Update: similar to Add, it replaces the sender's LeafNode in the tree instead of adding a new leaf to the tree.Remove: requests that the member with the leaf index removed be removed from the group.ReInit: requests to reinitialize the group with different parameters.ExternalInit: used by new members that want to join a group by using an external commit.GroupContentExtensions: it is used to update the list of extensions in the GroupContext for the group.
Proposals structure and semantics MUST follow sections 12.1.1 - 12.1.7 of RFC9420.
Any list of commited proposals MUST be validated either by a the group member who created the commit, or any group member processing such commit. The validation MUST be done according to one of the procedures described in Section 12.2 of RFC9420.
When creating or processing a Commit, a client applies a list of
proposals to the ratchet tree and GroupContext.
The client MUST apply the proposals in the list in the order described
in Section 12.3 of RFC9420.
Below follows the flow diagram for the addition of a member to a group:

The diagram below shows the procedure to remove a group member:

The flow diagram below shows an update procedure:

Commit messages
Commit messages initiate new group epochs. It informs group members to update their representation of the state of the group by applying the proposals and advancing the key schedule.
Each proposal covered by the Commit is included by a ProposalOrRef
value.
ProposalOrRef identify the proposal to be applied by value or by
reference.
Commits that refer to new Proposals from the committer can be included
by value.
Commits for previously sent proposals from anyone can be sent by
reference.
Proposals sent by reference are specified by including the hash of the
AuthenticatedContent.
Group members that have observed one or more valid proposals within an epoch MUST send a Commit message before sending application data. A sender and a receiver of a Commit MUST verify that the committed list of proposals is valid. The sender of a Commit SHOULD include all valid proposals received during the current epoch.
Functioning of commits MUST follow the instructions of Section 12.4 of RFC9420.
Application messages
Handshake messages provide an authenticated group key exchange to
clients.
To protect application messages sent among the members of a group, the
encryption_secret provided by the key schedule is used to derive a
sequence of nonces and keys for message encryption.
Each client MUST maintain their local copy of the key schedule for each epoch during which they are a group member. They derive new keys, nonces, and secrets as needed. This data MUST be deleted as soon as they have been used.
Group members MUST use the AEAD algorithm associated with the negotiated MLS ciphersuite to encrypt and decrypt Application messages according to the Message Framing section. The group identifier and epoch allow a device to know which group secrets should be used and from which Epoch secret to start computing other secrets and keys. Application messages SHOULD be padded to provide resistance against traffic analysis techniques. This avoids additional information to be provided to an attacker in order to guess the length of the encrypted message. Padding SHOULD be used on messages with zero-valued bytes before AEAD encryption.
Functioning of application messages MUST follow the instructions of Section 15 of RFC9420.
Considerations with respect to decentralization
The MLS protocol assumes the existence on a (central, untrusted) delivery service, whose responsabilites include:
- Acting as a directory service providing the initial keying material for clients to use.
- Routing MLS messages among clients.
The central delivery service can be avoided in protocols using the publish/gossip approach, such as gossipsub.
Concerning keys, each node can generate and disseminate their encryption key among the other nodes, so they can create a local version of the tree that allows for the generation of the group key.
Another important component is the authentication service, which is replaced with SIWE in this specification.
Ethereum-based authentication protocol
Introduction
Sign-in with Ethereum describes how Ethereum accounts authenticate with off-chain services by signing a standard message format parameterized by scope, session details, and security mechanisms. Sign-in with Ethereum (SIWE), which is described in the [EIP 4361 (https://eips.ethereum.org/EIPS/eip-4361), MUST be the authentication method required.
Pattern
Message format (ABNF)
A SIWE Message MUST conform with the following Augmented Backus–Naur Form (RFC 5234) expression.
sign-in-with-ethereum =
[ scheme "://" ] domain %s" wants you to sign in with your
Ethereum account:" LF address LF
LF
[ statement LF ]
LF
%s"URI: " uri LF
%s"Version: " version LF
%s"Chain ID: " chain-id LF
%s"Nonce: " nonce LF
%s"Issued At: " issued-at
[ LF %s"Expiration Time: " expiration-time ]
[ LF %s"Not Before: " not-before ]
[ LF %s"Request ID: " request-id ]
[ LF %s"Resources:"
resources ]
scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
; See RFC 3986 for the fully contextualized
; definition of "scheme".
domain = authority
; From RFC 3986:
; authority = [ userinfo "@" ] host [ ":" port ]
; See RFC 3986 for the fully contextualized
; definition of "authority".
address = "0x" 40*40HEXDIG
; Must also conform to captilization
; checksum encoding specified in EIP-55
; where applicable (EOAs).
statement = *( reserved / unreserved / " " )
; See RFC 3986 for the definition
; of "reserved" and "unreserved".
; The purpose is to exclude LF (line break).
uri = URI
; See RFC 3986 for the definition of "URI".
version = "1"
chain-id = 1*DIGIT
; See EIP-155 for valid CHAIN_IDs.
nonce = 8*( ALPHA / DIGIT )
; See RFC 5234 for the definition
; of "ALPHA" and "DIGIT".
issued-at = date-time
expiration-time = date-time
not-before = date-time
; See RFC 3339 (ISO 8601) for the
; definition of "date-time".
request-id = *pchar
; See RFC 3986 for the definition of "pchar".
resources = *( LF resource )
resource = "- " URI
This specification defines the following SIWE Message fields that can be parsed from a SIWE Message by following the rules in ABNF Message Format:
-
schemeOPTIONAL. The URI scheme of the origin of the request. Its value MUST be a RFC 3986 URI scheme. -
domainREQUIRED. The domain that is requesting the signing. Its value MUST be a RFC 3986 authority. The authority includes an OPTIONAL port. If the port is not specified, the default port for the provided scheme is assumed.
If scheme is not specified, HTTPS is assumed by default.
-
addressREQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address encoding specified in ERC-55 where applicable. -
statementOPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a). -
uriREQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing. -
versionREQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification. -
chain-idREQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved. -
nonceREQUIRED. A random string (minimum 8 alphanumeric characters) chosen by the relying party and used to prevent replay attacks. -
issued-atREQUIRED. The time when the message was generated, typically the current time.
Its value MUST be an ISO 8601 datetime string.
expiration-timeOPTIONAL. The time when the signed authentication message is no longer valid.
Its value MUST be an ISO 8601 datetime string.
not-beforeOPTIONAL. The time when the signed authentication message will become valid.
Its value MUST be an ISO 8601 datetime string.
-
request-idOPTIONAL. An system-specific identifier that MAY be used to uniquely refer to the sign-in request. -
resourcesOPTIONAL. A list of information or references to information the user wishes to have resolved as part of authentication by the relying party.
Every resource MUST be a RFC 3986 URI separated by "\n- " where \n is the byte 0x0a.
Signing and Verifying Messages with Ethereum Accounts
-
For Externally Owned Accounts, the verification method specified in ERC-191 MUST be used.
-
For Contract Accounts,
-
The verification method specified in ERC-1271 SHOULD be used. Otherwise, the implementer MUST clearly define the verification method to attain security and interoperability for both wallets and relying parties.
-
When performing ERC-1271 signature verification, the contract performing the verification MUST be resolved from the specified
chain-id. -
Implementers SHOULD take into consideration that [ERC-1271 (https://eips.ethereum.org/EIPS/eip-1271) implementations are not required to be pure functions. They can return different results for the same inputs depending on blockchain state. This can affect the security model and session validation rules.
-
Resolving Ethereum Name Service (ENS) Data
-
The relying party or wallet MAY additionally perform resolution of ENS data, as this can improve the user experience by displaying human friendly information that is related to the
address. Resolvable ENS data include:- The primary ENS name.
- The ENS avatar.
- Any other resolvable resources specified in the ENS documentation.
-
If resolution of ENS data is performed, implementers SHOULD take precautions to preserve user privacy and consent. Their
addresscould be forwarded to third party services as part of the resolution process.
Implementer steps: specifying the request origin
The domain and, if present, the scheme, in the SIWE Message MUST
correspond to the origin from where the signing request was made.
Implementer steps: verifying a signed message
The SIWE Message MUST be checked for conformance to the ABNF Message Format and its signature MUST be checked as defined in Signing and Verifying Messages with Ethereum Accounts.
Implementer steps: creating sessions
Sessions MUST be bound to the address and not to further resolved resources that can change.
Implementer steps: interpreting and resolving resources
Implementers SHOULD ensure that that URIs in the listed resources are human-friendly when expressed in plaintext form.
Wallet implementer steps: verifying the message format
The full SIWE message MUST be checked for conformance to the ABNF defined in ABNF Message Format.
Wallet implementers SHOULD warn users if the substring "wants you to sign in with your Ethereum account" appears anywhere in an [ERC-191
(https://eips.ethereum.org/EIPS/eip-191) message signing request unless
the message fully conforms to the format defined ABNF Message Format.
Wallet implementer steps: verifying the request origin
Wallet implementers MUST prevent phishing attacks by verifying the
origin of the request against the scheme and domain fields in the
SIWE Message.
The origin SHOULD be read from a trusted data source such as the browser window or over WalletConnect ERC-1328 sessions for comparison against the signing message contents.
Wallet implementers MAY warn instead of rejecting the verification if the origin is pointing to localhost.
The following is a RECOMMENDED algorithm for Wallets to conform with the requirements on request origin verification defined by this specification.
The algorithm takes the following input variables:
- fields from the SIWE message.
originof the signing request: the origin of the page which requested the signin via the provider.allowedSchemes: a list of schemes allowed by the Wallet.defaultScheme: a scheme to assume when none was provided. Wallet implementers in the browser SHOULD use https.- developer mode indication: a setting deciding if certain risks should
be a warning instead of rejection. Can be manually configured or
derived from
originbeing localhost.
The algorithm is described as follows:
- If
schemewas not provided, then assigndefaultSchemeas scheme. - If
schemeis not contained inallowedSchemes, then theschemeis not expected and the Wallet MUST reject the request. Wallet implementers in the browser SHOULD limit the list of allowedSchemes to just 'https' unless a developer mode is activated. - If
schemedoes not match the scheme of origin, the Wallet SHOULD reject the request. Wallet implementers MAY show a warning instead of rejecting the request if a developer mode is activated. In that case the Wallet continues processing the request. - If the
hostpart of thedomainandorigindo not match, the Wallet MUST reject the request unless the Wallet is in developer mode. In developer mode the Wallet MAY show a warning instead and continues procesing the request. - If
domainandoriginhave mismatching subdomains, the Wallet SHOULD reject the request unless the Wallet is in developer mode. In developer mode the Wallet MAY show a warning instead and continues procesing the request. - Let
portbe the port component ofdomain, and if no port is contained in domain, assign port the default port specified for the scheme. - If
portis not empty, then the Wallet SHOULD show a warning if theportdoes not match the port oforigin. - If
portis empty, then the Wallet MAY show a warning iforigincontains a specific port. - Return request origin verification completed.
Wallet implementer steps: creating SIWE interfaces
Wallet implementers MUST display to the user the following fields from
the SIWE Message request by default and prior to signing, if they are
present: scheme, domain, address, statement, and resources.
Other present fields MUST also be made available to the user prior to
signing either by default or through an extended interface.
Wallet implementers displaying a plaintext SIWE Message to the user SHOULD require the user to scroll to the bottom of the text area prior to signing.
Wallet implementers MAY construct a custom SIWE user interface by parsing the ABNF terms into data elements for use in the interface. The display rules above still apply to custom interfaces.
Wallet implementer steps: supporting internationalization (i18n)
After successfully parsing the message into ABNF terms, translation MAY happen at the UX level per human language.
Privacy and Security Considerations
- The double ratchet "recommends" using AES in CBC mode. Since encryption must be with an AEAD encryption scheme, we will use AES in GCM mode instead (supported by Noise).
- For the information retrieval, the algorithm MUST include a access control mechanisms to restrict who can call the set and get functions.
- One SHOULD include event logs to track changes in public keys.
- The curve vurve448 MUST be chosen due to its higher security level: 224-bit security instead of the 128-bit security provided by X25519.
- It is important that Bob MUST NOT reuse
SPK.
Considerations related to the use of Ethereum addresses
With respect to the Authentication Service
- If users used their Ethereum addresses as identifiers, they MUST generate their own credentials. These credentials MUST use the digital signature key pair associated to the Ethereum address.
- Other users can verify credentials.
- With this approach, there is no need to have a dedicated Authentication Service responsible for the issuance and verification of credentials.
- The interaction diagram showing the generation of credentials becomes obsolete.
With respect to the Delivery Service
- Users MUST generate their own KeyPackage.
- Other users can verify KeyPackages when required.
- A Delivery Service storage system MUST verify KeyPackages before storing them.
- Interaction diagrams involving the DS do not change.
Consideration related to the onchain component of the protocol
Assumptions
- Users have set a secure 1-1 communication channel.
- Each group is managed by a separate smart contract.
Addition of members to a group
Alice knows Bob’s Ethereum address
- Off-chain - Alice and Bob set a secure communication channel.
- Alice creates the smart contract associated to the group. This smart contract MUST include an ACL.
- Alice adds Bob’s Ethereum address to the ACL.
- Off-chain - Alice sends a request to join the group to Bob. The
request MUST include the contract’s address:
RequestMLSPayload {"You are joining the group with smart contract: 0xabcd"} - Off-chain - Bob responds the request with a digitally signed
response. This response includes Bob’s credentials and key package:
ResponseMLSPayload {sig: signature(ethereum_sk, message_to_sign), address: ethereum_address, credentials, keypackage} - Off-chain - Alice verifies the signature, using Bob’s
ethereum_pkand checks that it corresponds to an address contained in the ACL. - Off-chain - Alice sends a welcome message to Bob.
- Off-chain - Alice SHOULD broadcasts a message announcing the
addition of Bob to other users of the group.

Alice does not know Bob’s Ethereum address
- Off-chain - Alice and Bob set a secure communication channel.
- Alice creates the smart contract associated to the group. This smart contract MUST include an ACL.
- Off-chain - Alice sends a request to join the group to Bob. The
request MUST include the contract’s address:
RequestMLSPayload{"You are joining the group with smart contract: 0xabcd"} - Off-chain - Bob responds the request with a digitally signed
response. This response includes Bob’s credentials, his Ethereum
address and key package:
ResponseMLSPayload {sig: signature(ethereum_sk, message_to_sign), address: ethereum_address, credentials, keypackage} - Off-chain - Alice verifies the signature using Bob’s
ethereum_pk. - Upon reception of Bob’s data, Alice registers data with the smart contract.
- Off-chain - Alice sends a welcome message to Bob.
- Off-chain - Alice SHOULD broadcasts a message announcing the addition of Bob to other users of the group.

Considerations regarding smart contracts
The role of the smart contract includes:
- Register user information and key packages: As described in the previous section.
- Updates of key material.
- Users MUST send any update in their key material to the other users of the group via off-chain messages.
- Upon reception of the new key material, the creator of the contract MUST update the state of the smart contract.
- Deletion of users.
- Any user can submit a proposal for the removal of a user via off-chain message.
- This proposal MUST be sent to the creator of the contract.
- The creator of the contract MUST update the ACL, and send messages to the group for key update.

It is important to note that both user removal and updates of any kind have a similar interaction flow.
- Queries of existing users.
- Any user can query the smart contract to know the state of the group, including existing users and removed ones.
- This aspect MUST be used when adding new members to verify that the prospective key package has not been already used.
Copyright
Copyright and related rights waived via CC0.
References
- Augmented BNF for Syntax Specifications
- Gossipsub
- HMAC-based Extract-and-Expand Key Derivation Function
- Hybrid Public Key Encryption
- Security Analysis and Improvements for the IETF MLS Standard for Group Messaging
- Signed Data Standard
- Sign-In with Ethereum
- Standard Signature Validation Method for Contracts
- The Double Ratchet Algorithm
- The Messaging Layer Security Protocol
- The X3DH Key Agreement Protocol
- Toy Ethereum Private Messaging Protocol
- Uniform Resource Identifier
- WalletConnect URI Format
EXTENDED-KADEMLIA-DISCOVERY
| Field | Value |
|---|---|
| Name | Extended Kademlia Discovery with capability filtering |
| Slug | 143 |
| Status | raw |
| Category | Standards Track |
| Editor | Simon-Pierre Vivier [email protected] |
| Contributors | Hanno Cornelius [email protected] |
Timeline
Abstract
This specification defines a lightweight peer discovery mechanism
built on top of the libp2p Kademlia DHT.
It allows nodes to advertise themselves by storing a new type of peer record under
their own peer ID and enables other nodes to discover peers in the network via
random walks through the DHT.
The mechanism supports capability-based filtering of services entries,
making it suitable for overlay networks that
require connectivity to peers offering specific protocols or features.
Motivation
The standard libp2p Kademlia DHT provides content routing and peer routing toward specific keys or peer IDs, but offers limited support for general-purpose random peer discovery — i.e. finding any well-connected peer in the network.
Existing alternatives such as mDNS, Rendezvous, or bootstrap lists do not always satisfy the needs of large-scale decentralized overlay networks that require:
- Organic growth of connectivity without strong trust in bootstrap nodes
- Discovery of peers offering specific capabilities (e.g. protocols, bandwidth classes, service availability)
- Resilience against eclipse attacks and network partitioning
- Low overhead compared to gossip-based or pubsub-based discovery
By leveraging the already-deployed Kademlia routing table and random-walk behavior, this document define a simple, low-cost discovery primitive that reuses existing infrastructure while adding capability advertisement and filtering via a new record type.
Semantic
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Please refer to libp2p Kademlia DHT specification (Kad-DHT)
and extensible peer records specification (XPR) for terminology used in this document.
Protocol
Record Propagation
A node that wants to make itself discoverable,
also known as an advertiser,
MUST encode its discoverable information in an XPR.
The encoded information MUST be sufficient for discoverers to connect to this advertiser.
It MAY choose to encode some or all of its capabilities (and related information)
as services in the XPR.
This will allow future discoverers to filter discovered records based on desired capabilities.
In order to advertise this record,
the advertiser SHOULD first retrieve the k closest peers to its own peer ID
in its own Kad-DHT routing table.
This assumes that the routing table has been previously initialised
and follows the regular bootstrap process as per the Kad-DHT specification.
The advertiser SHOULD then send a PUT_VALUE message to these k peers
to store the XPR against its own peer ID.
This process SHOULD be repeated periodically to maintain the advertised record.
We RECOMMEND an interval of once every 30 minutes.
Use of XPR in identify
Advertisers SHOULD include their XPRs as the signedPeerRecord
in libp2p Identify messages.
Note: For more information, see the
identifyprotocol implementations, such as go-libp2p, as at the time of writing (Jan 2026) thesignedPeerRecordfield extension is not yet part of any official specification.
Record Discovery
A node that wants to discover peers to connect to,
also known as a discoverer,
SHOULD perform the following random walk discovery procedure (FIND_RANDOM):
-
Choose a random value in the
Kad-DHTkey space. (R_KEY). -
Follow the
Kad-DHTpeer routing algorithm, withR_KEYas the target. This procedure loops theKad-DHTFIND_NODEprocedure to the target key, each time receiving closer peers (closerPeers) to the target key in response, until no new closer peers can be found. Since the target is random, the discoverer SHOULD consider each previously unseen peer in each response'scloserPeersfield, as a randomly discovered node of potential interest. The discoverer MUST keep track of such peers asdiscoveredPeers. -
For each
discoveredPeer, attempt to retrieve a correspondingXPR. This can be done in one of two ways:3.1 If the
discoveredPeerin the response contains at least one multiaddress in theaddrsfield, attempt a connection to that peer and wait to receive theXPRas part of theidentifyprocedure.3.2 If the
discoveredPeerdoes not includeaddrsinformation, or the connection attempt to includedaddrsfails, or more service information is required before a connection can be attempted, MAY perform a value retrieval procedure to thediscoveredPeerID. -
For each retrieved
XPR, validate the signature against the peer ID. In addition, the discoverer MAY filter discovered peers based on the capabilities encoded within theservicesfield of theXPR. The discoverer SHOULD ignore (and disconnect, if already connected) discovered peers with invalidXPRs or that do not advertise theservicesof interest to the discoverer.
Privacy Enhancements
To prevent network topology mapping and eclipse attacks,
Kad-DHT nodes MUST NOT disclose connection type in response messages.
The connection field of every Peer MUST always be set to NOT_CONNECTED.
API Specification
Implementers of this protocol, SHOULD wrap the implementation in a functional interface similar to the one defined below.
In Extended Kademlia Discovery, the discovery protocol is based on a random DHT walk, optionally filtering the randomly discovered peers by capability. However, it's possible to define discovery protocols with better performance in finding peers with specific capabilities. The aim is to define an API that is compatible with Extended Kademlia Discovery and more sophisticated capability discovery protocols, maintaining similar function signatures even if the underlying protocol differs. This section may be extracted into a separate API specification once new capability discovery protocols are defined.
The API is defined in the form of C-style bindings. However, this simply serves to illustrate the exposed functions and can be adapted into the conventions of any strongly typed language. Although unspecified in the API below, all functions SHOULD return an error result type appropriate to the implementation language.
start()
Start the discovery protocol, including all tasks related to bootstrapping and maintaining the routing table and advertising this node and its capabilities.
In the case of Extended Kademlia Discovery,
start() will kick off the periodic task of refreshing the propagated XPR.
stop()
Stop the discovery protocol, including all tasks related to maintaining the routing table and advertising this node and its capabilities.
In the case of Extended Kademlia Discovery,
stop() will cancel the periodic task of refreshing the propagated XPR.
start_advertising(const char* service_id)
Start advertising this node against any capability
encoded as an input service_id string.
In the case of Extended Kademlia Discovery,
start_advertising() will include the input service_id
in the regularly propagated XPR.
stop_advertising(const char* service_id)
Stop advertising this node against the capability
encoded in the input service_id string.
In the case of Extended Kademlia Discovery,
stop_advertising() will exclude the service_id
from the regularly propagated XPR,
if it was previously included.
ExtensiblePeerRecords* lookup(const char* service_id, ...)
Lookup and return records for peers supporting the capability encoded in the input service_id string,
using the underlying discovery protocol.
service_id is an OPTIONAL input argument.
If unset, it indicates a lookup for peers supporting any (or zero) capabilities.
In the case of Extended Kademlia Discovery,
lookup() will trigger the random walk record discovery,
filtering discovered records based on service_id, if specified.
If no service_id is specified,
Extended Kademlia Discovery will just return a random selection of peer records,
matching any capability.
Copyright
Copyright and related rights waived via CC0.
References
- extended peer records specification
- libp2p Kademlia DHT specification
- RFC002 Signed Envelope
- RFC003 Routing Records
- capability discovery
EXTENSIBLE-PEER-RECORDS
| Field | Value |
|---|---|
| Name | Extensible Peer Records |
| Slug | 74 |
| Status | raw |
| Category | Standards Track |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Simon-Pierre Vivier [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258)
Abstract
This RFC proposes Extensible Peer Records, an extension of libp2p's routing records, that enables peers to encode an arbitrary list of supported services and essential service-related information in distributable records. This version of routing records allows peers to communicate capabilities such as protocol support, and essential information related to such capabilities. This is especially useful when (signed) records are used in peer discovery, allowing discoverers to filter for peers matching a desired set of capability criteria. Extensible Peer Records maintain backwards compatibility with standard libp2p routing records, while adding an extensible service information field that supports finer-grained capability communication.
A note on terminology: We opt to call this structure a "peer record", even though the corresponding libp2p specification refers to a "routing record". This is because the libp2p specification itself defines an internal
PeerRecordtype, and, when serialised into a signed envelope, this is most often called a "signed peer record" (see, for example, go-libp2p identify protocol).
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Motivation
We propose a new peer record as an extension of libp2p's RFC003 Routing Records that allows encoding an arbitrary list of services, and essential information pertaining to those services, supported by the peer.
There are at least two reasons why a peer might want to encode service information in its peer records:
- To augment
identifywith peer capabilities: The libp2pidentifyprotocol allows peers to exchange critical information, such as supported protocols, on first connection. The peer record (in a signed envelope) can also be exchanged duringidentify. However, peers may want to exchange finer-grained information related to supported protocols/services, that would otherwise require an application-level negotiation protocol, or that is critical to connect to the service in the first place. An example would be nodes supporting libp2pmixprotocol also needing to exchange the mix key before the service can be used. - To advertise supported services: If the peer record is used as the discoverable record for a peer (as we propose for various discovery methods) that peer may want to encode a list of supported services in its advertised record. These services may be (but is not limited to) a list of supported libp2p protocols and critical information pertaining to that service (such as the mix key, explained above). Discoverers can then filter discovered records for desired capabilities based on the encoded service information or use it to initiate the service.
Wire protocol
Extensible Peer Records
Extensible Peer Records MUST adhere to the following structure:
syntax = "proto3";
package peer.pb;
// ExtensiblePeerRecord messages contain information that is useful to share with other peers.
// Currently, an ExtensiblePeerRecord contains the public listen addresses for a peer
// and an extensible list of supported services as key-value pairs.
//
// ExtensiblePeerRecords are designed to be serialised to bytes and placed inside of
// SignedEnvelopes before sharing with other peers.
message ExtensiblePeerRecord {
// AddressInfo is a wrapper around a binary multiaddr. It is defined as a
// separate message to allow us to add per-address metadata in the future.
message AddressInfo {
bytes multiaddr = 1;
}
// peer_id contains a libp2p peer id in its binary representation.
bytes peer_id = 1;
// seq contains a monotonically-increasing sequence counter to order ExtensiblePeerRecords in time.
uint64 seq = 2;
// addresses is a list of public listen addresses for the peer.
repeated AddressInfo addresses = 3;
message ServiceInfo{
string id = 1;
optional bytes data = 2;
}
// Extensible list of advertised services
repeated ServiceInfo services = 4;
}
A peer MAY include a list of supported services in the services field.
These services could be libp2p protocols,
in which case it is RECOMMENDED that the ServiceInfo id field
be derived from the libp2p protocol identifier.
In any case, for each supported service,
the id field MUST be populated with a string identifier for that service.
In addition, the data field MAY be populated with additional information about the service.
It is RECOMMENDED that each data field be no more than 33 bytes.
(We choose 33 here to allow for the encoding of 256 bit keys with parity.
Also see Size constraints for recommendations on limiting the overall record size.)
The rest of the ExtensiblePeerRecord
MUST be populated as per the libp2p PeerRecord specification.
Due to the natural extensibility of protocol buffers,
serialised ExtensiblePeerRecords are backwards compatible with libp2p PeerRecords,
only adding the functionality related to service info exchange.
Size constraints
To limit the impact on resources,
ExtensiblePeerRecords SHOULD NOT be used to encode information
that is not essential for discovery or service initiation.
Since these records are likely to be exchanged frequently,
they should be kept as small as possible while still providing all necessary functionality.
Although specific applications MAY choose to enforce a smaller size,
it is RECOMMENDED that an absolute maximum size of 1024 bytes is enforced for valid records.
Extensible Peer Records may be included in size-constrained protocols
that further limit the size (such as DNS).
Wrapping in Signed Peer Envelopes
Extensible Peer Records MUST be wrapped in libp2p signed envelopes
before distributing them to peers.
The corresponding ExtensiblePeerRecord message is serialised into the signed envelope's payload field.
Signed Envelope Domain
Extensible Peer Records MUST use libp2p-routing-state as domain separator string
for the envelope signature.
This is the same as for ordinary libp2p routing records.
Signed Envelope Payload Type
Extensible Peer Records MUST use the UTF8 string /libp2p/extensible-peer-record/
as the payload_type value.
Note: this will make Extensible Peer Records a subtype of the "namespace" multicodec. In future we may define a more compact multicodec type for Extensible Peer Records.
Copyright
Copyright and related rights waived via CC0.
References
GOSSIPSUB-TOR-PUSH
| Field | Value |
|---|---|
| Name | Gossipsub Tor Push |
| Slug | 105 |
| Status | raw |
| Category | Standards Track |
| Editor | Daniel Kaiser [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-05-27 —
99be3b9— Move Raw Specs (#37) - 2024-02-01 —
cd8c9f4— Update and rename GOSSIPSUB-TOR-PUSH.md to gossipsub-tor-push.md - 2024-01-27 —
0db60c1— Create GOSSIPSUB-TOR-PUSH.md
Abstract
This document extends the libp2p gossipsub specification specifying gossipsub Tor Push, a gossipsub-internal way of pushing messages into a gossipsub network via Tor. Tor Push adds sender identity protection to gossipsub.
Protocol identifier: /meshsub/1.1.0
Note: Gossipsub Tor Push does not have a dedicated protocol identifier. It uses the same identifier as gossipsub and works with all pubsub based protocols. This allows nodes that are oblivious to Tor Push to process messages received via Tor Push.
Background
Without extensions, libp2p gossipsub does not protect sender identities.
A possible design of an anonymity extension to gossipsub is pushing messages through an anonymization network before they enter the gossipsub network. Tor is currently the largest anonymization network. It is well researched and works reliably. Basing our solution on Tor both inherits existing security research, as well as allows for a quick deployment.
Using the anonymization network approach, even the first gossipsub node that relays a given message cannot link the message to its sender (within a relatively strong adversarial model). Taking the low bandwidth overhead and the low latency overhead into consideration, Tor offers very good anonymity properties.
Functional Operation
Tor Push allows nodes to push messages over Tor into the gossipsub network. The approach specified in this document is fully backwards compatible. Gossipsub nodes that do not support Tor Push can receive and relay Tor Push messages, because Tor Push uses the same Protocol ID as gossipsub.
Messages are sent over Tor via SOCKS5. Tor Push uses a dedicated libp2p context to prevent information leakage. To significantly increase resilience and mitigate circuit failures, Tor Push establishes several connections, each to a different randomly selected gossipsub node.
Specification
This section specifies the format of Tor Push messages, as well as how Tor Push messages are received and sent, respectively.
Wire Format
The wire format of a Tor Push message corresponds verbatim to a typical libp2p pubsub message.
message Message {
optional string from = 1;
optional bytes data = 2;
optional bytes seqno = 3;
required string topic = 4;
optional bytes signature = 5;
optional bytes key = 6;
}
Receiving Tor Push Messages
Any node supporting a protocol with ID /meshsub/1.1.0 (e.g. gossipsub),
can receive Tor Push messages.
Receiving nodes are oblivious to Tor Push and
will process incoming messages according to the respective meshsub/1.1.0 specification.
Sending Tor Push Messages
In the following, we refer to nodes sending Tor Push messages as Tp-nodes (Tor Push nodes).
Tp-nodes MUST setup a separate libp2p context, i.e. libp2p switch, which MUST NOT be used for any purpose other than Tor Push. We refer to this context as Tp-context. The Tp-context MUST NOT share any data, e.g. peer lists, with the default context.
Tp-peers are peers a Tp-node plans to send Tp-messages to.
Tp-peers MUST support /meshsub/1.1.0.
For retrieving Tp-peers,
Tp-nodes SHOULD use an ambient peer discovery method
that retrieves a random peer sample (from the set of all peers),
e.g. 33/WAKU2-DISCV5.
Tp-nodes MUST establish a connection as described in sub-section
Tor Push Connection Establishment to at least one Tp-peer.
To significantly increase resilience,
Tp-nodes SHOULD establish Tp-connections to D peers,
where D is the desired gossipsub out-degree,
with a default value of 8.
Each Tp-message MUST be sent via the Tp-context over at least one Tp-connection. To increase resilience, Tp-messages SHOULD be sent via the Tp-context over all available Tp-connections.
Control messages of any kind, e.g. gossipsub graft, MUST NOT be sent via Tor Push.
Connection Establishment
Tp-nodes establish a /meshsub/1.1.0 connection to tp-peers via
SOCKS5 over Tor.
Establishing connections, which in turn establishes the respective Tor circuits, can be done ahead of time.
Epochs
Tor Push introduces epochs. The default epoch duration is 10 minutes. (We might adjust this default value based on experiments and evaluation in future versions of this document. It seems a good trade-off between traceablity and circuit building overhead.)
For each epoch, the Tp-context SHOULD be refreshed, which includes
- libp2p peer-ID
- Tp-peer list
- connections to Tp-peers
Both Tp-peer selection for the next epoch and establishing connections to the newly selected peers SHOULD be done during the current epoch and be completed before the new epoch starts. This avoids adding latency to message transmission.
Security/Privacy Considerations
Fingerprinting Attacks
Protocols that feature distinct patterns are prone to fingerprinting attacks when using them over Tor Push. Both malicious guards and exit nodes could detect these patterns and link the sender and receiver, respectively, to transmitted traffic. As a mitigation, such protocols can introduce dummy messages and/or padding to hide patterns.
DoS
General DoS against Tor
Using untargeted DoS to prevent Tor Push messages from entering the gossipsub network would cost vast resources, because Tor Push transmits messages over several circuits and the Tor network is well established.
Targeting the Guard
Denying the service of a specific guard node blocks Tp-nodes using the respective guard. Tor guard selection will replace this guard [TODO elaborate]. Still, messages might be delayed during this window which might be critical to certain applications.
Targeting the Gossipsub Network
Without sophisticated rate limiting (for example using 17/WAKU2-RLN-RELAY), attackers can spam the gossipsub network. It is not enough to just block peers that send too many messages, because these messages might actually come from a Tor exit node that many honest Tp-nodes use. Without Tor Push, protocols on top of gossipsub could block peers if they exceed a certain message rate. With Tor Push, this would allow the reputation-based DoS attack described in Bitcoin over Tor isn't a Good Idea.
Peer Discovery
The discovery mechanism could be abused to link requesting nodes to their Tor connections to discovered nodes. An attacker that controls both the node that responds to a discovery query, and the node who’s ENR the response contains, can link the requester to a Tor connection that is expected to be opened to the node represented by the returned ENR soon after.
Further, the discovery mechanism (e.g. discv5) could be abused to distribute disproportionately many malicious nodes. For instance if p% of the nodes in the network are malicious, an attacker could manipulate the discovery to return malicious nodes with 2p% probability. The discovery mechanism needs to be resilient against this attack.
Roll-out Phase
During the roll-out phase of Tor Push, during which only a few nodes use Tor Push, attackers can narrow down the senders of Tor messages to the set of gossipsub nodes that do not originate messages. Nodes who want anonymity guarantees even during the roll-out phase can use separate network interfaces for their default context and Tp-context, respectively. For the best protection, these contexts should run on separate physical machines.
Copyright
Copyright and related rights waived via CC0.
References
- libp2p gossipsub
- libp2p pubsub
- libp2p pubsub message
- libp2p switch
- SOCKS5
- Tor
- 33/WAKU2-DISCV5
- Bitcoin over Tor isn't a Good Idea
- 17/WAKU2-RLN-RELAY
HASHGRAPHLIKE CONSENSUS
| Field | Value |
|---|---|
| Name | Hashgraphlike Consensus Protocol |
| Slug | 73 |
| Status | raw |
| Category | Standards Track |
| Editor | Ugur Sen [email protected] |
| Contributors | seemenkina [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-09-15 —
f051117— VAC-RAW/Consensus-hashgraphlike RFC (#142)
Abstract
This document specifies a scalable, decentralized, and Byzantine Fault Tolerant (BFT) consensus mechanism inspired by Hashgraph, designed for binary decision-making in P2P networks.
Motivation
Consensus is one of the essential components of decentralization. In particular, in the decentralized group messaging application is used for binary decision-making to govern the group. Therefore, each user contributes to the decision-making process. Besides achieving decentralization, the consensus mechanism MUST be strong:
-
Under the assumption of at least
2/3honest users in the network. -
Each user MUST conclude the same decision and scalability: message propagation in the network MUST occur within
O(log n)rounds, wherenis the total number of peers, in order to preserve the scalability of the messaging application.
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Flow
Any user in the group initializes the consensus by creating a proposal.
Next, the user broadcasts the proposal to the whole network.
Upon each user receives the proposal, validates the proposal,
adds its vote as yes or no and with its signature and timestamp.
The user then sends the proposal and vote to a random peer in a P2P setup,
or to a subscribed gossipsub channel if gossip-based messaging is used.
Therefore, each user first validates the signature and then adds its new vote.
Each sending message counts as a round.
After log(n) rounds all users in the network have the others vote
if at least 2/3 number of users are honest where honesty follows the protocol.
In general, the voting-based consensus consists of the following phases:
- Initialization of voting
- Exchanging votes across the rounds
- Counting the votes
Assumptions
- The users in the P2P network can discover the nodes or they are subscribing same channel in a gossipsub.
- We MAY have non-reliable (silent) nodes.
- Proposal owners MUST know the number of voters.
1. Initialization of voting
A user initializes the voting with the proposal payload which is implemented using protocol buffers v3 as follows:
syntax = "proto3";
package vac.voting;
message Proposal {
string name = 10; // Proposal name
string payload = 11; // Proposal description
uint32 proposal_id = 12; // Unique identifier of the proposal
bytes proposal_owner = 13; // Public key of the creator
repeated Vote votes = 14; // Vote list in the proposal
uint32 expected_voters_count = 15; // Maximum number of distinct voters
uint32 round = 16; // Number of rounds
uint64 timestamp = 17; // Creation time of proposal
uint64 expiration_timestamp = 18; // The timestamp at which the proposal becomes outdated
bool liveness_criteria_yes = 19; // Shows how managing the silent peers vote
}
message Vote {
uint32 vote_id = 20; // Unique identifier of the vote
bytes vote_owner = 21; // Voter's public key
uint32 proposal_id = 22; // Linking votes and proposals
uint64 timestamp = 23; // Time when the vote was cast
bool vote = 24; // Vote bool value (true/false)
bytes parent_hash = 25; // Hash of previous owner's Vote
bytes received_hash = 26; // Hash of previous received Vote
bytes vote_hash = 27; // Hash of all previously defined fields in Vote
bytes signature = 28; // Signature of vote_hash
}
To initiate a consensus for a proposal,
a user MUST complete all the fields in the proposal, including attaching its vote
and the payload that shows the purpose of the proposal.
Notably, parent_hash and received_hash are empty strings because there is no previous or received hash.
Then the initialization section ends when the user who creates the proposal sends it
to the random peer from the network or sends it to the proposal to the specific channel.
2. Exchanging votes across the peers
Once the peer receives the proposal message P_1 from a 1-1 or a gossipsub channel does the following checks:
-
Check the signatures of the each votes in proposal, in particular for proposal
P_1, verify the signature ofV_1whereV_1 = P_1.votes[0]withV_1.signatureandV_1.vote_owner -
Do
parent_hashcheck: If there are repeated votes from the same sender, check that the hash of the former vote is equal to theparent_hashof the later vote. -
Do
received_hashcheck: If there are multiple votes in a proposal, check that the hash of a vote is equal to thereceived_hashof the next one. -
After successful verification of the signature and hashes, the receiving peer proceeds to generate
P_2containing a new voteV_2as following:4.1. Add its public key as
P_2.vote_owner.4.2. Set
timestamp.4.3. Set boolean
vote.4.4. Define
V_2.parent_hash = 0if there is no previous peer's vote, otherwise hash of previous owner's vote.4.5. Set
V_2.received_hash = hash(P_1.votes[0]).4.6. Set
proposal_idfor thevote.4.7. Calculate
vote_hashby hash of all previously defined fields in Vote:V_2.vote_hash = hash(vote_id, owner, proposal_id, timestamp, vote, parent_hash, received_hash)4.8. Sign
vote_hashwith its private key corresponding the public key asvote_ownercomponent then addsV_2.vote_hash. -
Create
P_2with by addingV_2as follows:5.1. Assign
P_2.name,P_2.proposal_id, andP_2.proposal_ownerto be identical to those inP_1.5.2. Add the
V_2to theP_2.Voteslist.5.3. Increase the round by one, namely
P_2.round = P_1.round + 1.5.4. Verify that the proposal has not expired by checking that:
current_time in [P_timestamp, P_expiration_timestamp]. If this does not hold, other peers ignore the message.
After the peer creates the proposal P_2 with its vote V_2,
sends it to the random peer from the network or
sends it to the proposal to the specific channel.
3. Determining the result
Because consensus depends on meeting a quorum threshold,
each peer MUST verify the accumulated votes to determine whether the necessary conditions have been satisfied.
The voting result is set YES if the majority of the 2n/3 from the distinct peers vote YES.
To verify, the findDistinctVoter method processes the proposal by traversing its Votes list to determine the number of unique voters.
If this method returns true, the peer proceeds with strong validation, which ensures that if any honest peer reaches a decision, no other honest peer can arrive at a conflicting result.
-
Check each
signaturein the vote as shown in the Section 2. -
Check the
parent_hashchain if there are multiple votes from the same owner namelyvote_iandvote_i+1respectively, the parent hash ofvote_i+1should be the hash ofvote_i -
Check the
previous_hashchain, each received hash ofvote_i+1should be equal to the hash ofvote_i. -
Check the
timestampagainst the replay attack. In particular, thetimestampcannot be the old in the determined threshold. -
Check that the liveness criteria defined in the Liveness section are satisfied.
If a proposal is verified by all the checks,
the countVote method counts each YES vote from the list of Votes.
4. Properties
The consensus mechanism satisfies liveness and security properties as follows:
Liveness
Liveness refers to the ability of the protocol to eventually reach a decision when sufficient honest participation is present.
In this protocol, if n > 2 and more than n/2 of the votes among at least 2n/3 distinct peers are YES,
then the consensus result is defined as YES; otherwise, when n ≤ 2, unanimous agreement (100% YES votes) is required.
The peer calculates the result locally as shown in the Section 3. From the hashgraph property, if a node could calculate the result of a proposal, it implies that no peer can calculate the opposite of the result. Still, reliability issues can cause some situations where peers cannot receive enough messages, so they cannot calculate the consensus result.
Rounds are incremented when a peer adds and sends the new proposal.
Calculating the required number of rounds, 2n/3 from the distinct peers' votes is achieved in two ways:
2n/3rounds in pure P2P networks2rounds in gossipsub
Since the message complexity is O(1) in the gossipsub channel,
in case the network has reliability issues,
the second round is used for the peers cannot receive all the messages from the first round.
If an honest and online peer has received at least one vote but not enough to reach consensus, it MAY continue to propagate its own vote — and any votes it has received — to support message dissemination. This process can continue beyond the expected round count, as long as it remains within the expiration time defined in the proposal. The expiration time acts as a soft upper bound to ensure that consensus is either reached or aborted within a bounded timeframe.
Equality of votes
An equality of votes occurs when verifying at least 2n/3 distinct voters and
applying liveness_criteria_yes the number of YES and NO votes is equal.
Handling ties is an application-level decision. The application MUST define a deterministic tie policy:
RETRY: re-run the vote with a new proposal_id, optionally adjusting parameters.
REJECT: abort the proposal and return voting result as NO.
The chosen policy SHOULD be consistent for all peers via proposal's payload to ensure convergence on the same outcome.
Silent Node Management
Silent nodes are the nodes that not participate the voting as YES or NO. There are two possible counting votes for the silent peers.
- Silent peers means YES: Silent peers counted as YES vote, if the application prefer the strong rejection for NO votes.
- Silent peers means NO: Silent peers counted as NO vote, if the application prefer the strong acception for NO votes.
The proposal is set to default true, which means silent peers' votes are counted as YES namely liveness_criteria_yes is set true by default.
Security
This RFC uses cryptographic primitives to prevent the malicious behaviours as follows:
- Vote forgery attempt: creating unsigned invalid votes
- Inconsistent voting: a malicious peer submits conflicting votes (e.g., YES to some peers and NO to others) in different stages of the protocol, violating vote consistency and attempting to undermine consensus.
- Integrity breaking attempt: tampering history by changing previous votes.
- Replay attack: storing the old votes to maliciously use in fresh voting.
5. Copyright
Copyright and related rights waived via CC0
6. References
LOGOS-CAPABILITY-DISCOVERY
| Field | Value |
|---|---|
| Name | Logos Capability Discovery Protocol |
| Slug | 107 |
| Status | raw |
| Category | Standards Track |
| Editor | Arunima Chaudhuri [email protected] |
| Contributors | Ugur Sen [email protected], Hanno Cornelius [email protected] |
Timeline
- 2026-01-27 —
ab9337e— Add recipe for algorithms (#232) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-12-09 —
aaf158a— VAC/RAW/LOGOS-DISCOVERY-CAPABILITY RFC (#212)
Note: This specification is currently a WIP and undergoing a high rate of changes.
Abstract
This RFC defines the Logos Capability Discovery protocol, a discovery mechanism inspired by DISC-NG service discovery built on top of Kad-dht.
The protocol enables nodes to:
- Advertise their participation in specific services
- Efficiently discover other peers participating in those services
In this RFC, the terms "capability" and "service" are used interchangeably. Within Logos, a node’s “capabilities” map directly to the “services” it participates in. Similarly, "peer" and "node" refer to the same entity: a participant in the Logos Discovery network.
Logos discovery extends Kad-dht toward a multi-service, resilient discovery layer, enhancing reliability while maintaining compatibility with existing Kad-dht behavior. For everything else that isn't explicitly stated herein, it is safe to assume behaviour similar to Kad-dht.
Motivation
In decentralized networks supporting multiple services, efficient peer discovery for specific services is critical. Traditional approaches face several challenges:
- Inefficiency: Random-walk–based discovery is inefficient for unpopular services.
- Load imbalance: A naive approach where nodes advertise their service at DHT peers whose IDs are closest to the service ID leads to hotspots and overload at popular services.
- Scalability: Discovery must scale logarithmically across many distinct services.
Logos discovery addresses these through:
- Service-specific routing tables
- Adaptive advertisement placement with admission control
- Improved lookup operations balancing efficiency and resilience
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Protocol Roles
The Logos capability discovery protocol defines three roles that nodes can perform:
Advertiser
Advertisers are nodes that participate in a service and want to be discovered by other peers.
Responsibilities:
- Advertisers SHOULD register advertisements for their service across registrars
- Advertisers SHOULD handle registration responses
Discoverer
Discoverers are nodes attempting to find peers that provide a specific service.
Responsibilities:
- Discoverers SHOULD query registrars for advertisements of a service
Registrar
Registrars are nodes that store and serve advertisements.
Responsibilities:
- Registrars MUST use a waiting time based admission control mechanism to decide whether to store an advertisement coming from an advertiser or not.
- Registrars SHOULD respond to query requests for advertisements coming from discoverers.
Definitions
DHT Routing Table
Every participant in the kad-dht peer discovery layer
maintains the peer routing table KadDHT(peerID).
It is a distributed key-value store with
peer IDs
as key against their matching
signed peer records values.
It is centered on the node's own peerID.
“Centered on” means the table is organized using that ID as the reference point for computing distances with other peers and assigning peers to buckets.
Bucket
Each routing table is organized into buckets. A bucket is a logical container that stores information about peers at a particular distance range from a reference ID.
Conceptually:
- Each bucket corresponds to a specific range in the XOR distance metric
- Peers are assigned to buckets based on their XOR distance from the table's center ID
- Buckets enable logarithmic routing by organizing the keyspace into manageable segments
The number of buckets and their organization is described in the Distance section. Implementation considerations for bucket management are detailed in Bucket Management section.
Service
A service is a logical sub-network within the larger peer-to-peer network. It represents a specific capability a node supports — for example, a particular protocol or functionality it offers. A service MUST be identified by a libp2p protocol ID via the identify protocol.
Service ID
The service ID service_id_hash MUST be the SHA-256 hash of the protocol ID.
For example:
| Service Identifier | Service ID |
|---|---|
/waku/store/1.0.0 | 313a14f48b3617b0ac87daabd61c1f1f1bf6a59126da455909b7b11155e0eb8e |
/libp2p/mix/1.2.0 | 9c55878d86e575916b267195b34125336c83056dffc9a184069bcb126a78115d |
Advertisement Cache
An advertisement cache ad_cache is a bounded storage structure
maintained by registrars to store accepted advertisements.
Advertise Table
For every service it participates in, an advertiser node MUST maintain an
advertise table AdvT(service_id_hash) centered on service_id_hash.
The table MAY be initialized using
peers from the advertiser’s KadDHT(peerID) routing table.
It SHOULD be updated opportunistically through interactions with
registrars during the advertisement process.
Each bucket in the advertise table contains a list of registrar peers at a
particular XOR distance range from service_id_hash, which are candidates for placing
advertisements.
Search Table
For every service it attempts to discover, a discoverer node MUST maintain a
search table DiscT(service_id_hash) centered on service_id_hash.
The table MAY be initialized using
peers from the discoverer’sKadDHT(peerID) routing table.
It SHOULD be updated through interactions with
registrars during lookup operations.
Each bucket in the search table contains a list of registrar peers at a
particular XOR distance range from service_id_hash, which discoverers can query to
retrieve advertisements for that service.
Registrar Table
For every service for which it stores and serves advertisements,
a registrar node SHOULD maintain a registrar table RegT(service_id_hash)
centered on service_id_hash.
The table MAY be initialized using peers from the
registrar’s KadDHT(peerID) routing table.
It SHOULD be updated opportunistically
through interactions with advertisers and discoverers.
Each bucket in the registrar table contains a list of peer nodes
at a particular XOR distance range from service_id_hash.
Registrars use this table to return
closerPeers in REGISTER and GET_ADS responses,
enabling advertisers and discoverers to
refine their service-specific routing tables.
Address
A multiaddress is a standardized way used in libp2p to represent network addresses. Implementations SHOULD use multiaddresses for peer connectivity. However, implementations MAY use alternative address representations if they:
- Remain interoperable
- Convey sufficient information (IP + port) to establish connections
Signature
Refer to Peer Ids and Keys
to know about supported signatures.
In the base Kad DHT specification, signatures are optional,
typically implemented as a PKI signature over the tuple (key || value || author).
In this RFC digital signatures MUST be used to
authenticate advertisements and tickets.
Expiry Time E
E is advertisement expiry time in seconds.
The expiry time E is a system wide parameter,
not an individual advertisement field or parameter of an individual registrar.
Data Structures
Advertisement
An advertisement indicates that a specific node participates in a service.
In this RFC we refer to advertisement objects as ads.
For a single advertisement object we use ad.
An advertisement logically represents:
- Service identification: Which service the node participates in (via
service_id_hash) - Peer identification: The advertiser's unique peer ID
- Network addresses: How to reach the advertiser (multiaddrs)
- Authentication: Cryptographic proof that the advertiser controls the peer ID
Implementations are RECOMMENDED to use ExtensiblePeerRecord (XPR) encoding for advertisements. See the Advertisement Encoding section in the wire protocol specification for transmission format details.
Ticket
Tickets are digitally signed objects issued by registrars to advertisers to track accumulated waiting time for admission into the advertisement cache.
A ticket logically represents:
- Advertisement reference: The advertisement this ticket is associated with
- Time tracking: When the ticket was created (
t_init) and last modified (t_mod) - Waiting time: How long the advertiser must wait before retrying (
t_wait_for) - Authentication: Cryptographic proof that the registrar issued this ticket
Tickets enable stateless admission control at registrars. Advertisers accumulate waiting time across registration attempts by presenting tickets from previous attempts.
See the RPC Messages section for the wire format specification of tickets and the Registration Flow section for how tickets are used in the admission protocol.
Protocol Specifications
System Parameters
The system parameters are derived directly from the DISC-NG paper. Implementations SHOULD modify them as needed based on specific requirements.
| Parameter | Default Value | Description |
|---|---|---|
K_register | 3 | Max number of active (i.e. unexpired) registrations + ongoing registration attempts per bucket. |
K_lookup | 5 | For each bucket in the search table, number of random registrar nodes queried by discoverers |
F_lookup | 30 | number of advertisers to find in the lookup process. We stop lookup process when we have found these many advertisers |
F_return | 10 | max number of service-specific peers returned from a single registrar |
E | 900 seconds | Advertisement expiry time (15 minutes) |
C | 1,000 | Advertisement cache capacity |
P_occ | 10 | Occupancy exponent for waiting time calculation |
G | 10⁻⁷ | Safety parameter for waiting time calculation |
δ | 1 second | Registration window time |
m | 16 | Number of buckets for service-specific tables |
Distance
The distance d between any two keys in Logos Capability Discovery
MUST be calculated using the bitwise XOR applied to their 256-bit SHA-256 representations.
This provides a deterministic, uniform, and symmetric way to measure proximity in the keyspace.
The keyspace is the entire numerical range of possible peerID and service_id_hash
— the 256-bit space in which all SHA-256–derived IDs exist.
XOR MUST be used to measure distances between them in the keyspace.
For every node in the network, the peerID is unique.
In this system, both peerID and the service_id_hash are 256-bit SHA-256 hashes.
Thus both belong to the same keyspace.
KadDHT(peerID) table is centered on peerID.
AdvT(service_id_hash), DiscT(service_id_hash)
and RegT(service_id_hash) are service-specific tables
and MUST be centered on service_id_hash.
Bootstrapping of service-specific tables
MAY be done from the KadDHT(peerID) table.
When inserting a peer into the service-specific tables, the bucket index into which the peer will be inserted MUST be determined by:
- x = reference ID which is the
service_id_hash - y = target peer ID
peerID - L = 256 = bit length of IDs
m= 16 = number of buckets in the advertise/search tabled = x ⊕ y = service_id_hash ⊕ peerID= bitwise XOR distance (interpreted as an unsigned integer)
The bucket index i where y is placed in x's table is calculated as:
- Let
lz = CLZ(d)= number of leading zeros in the 256-bit representation ofd i = min( ⌊ (lz * m) / 256 ⌋ , m − 1 )- Special case: For
d = 0(same ID),i = m - 1
Lower-index buckets represent peers far away from service_id_hash in the keyspace,
while higher-index buckets contain peers closer to service_id_hash.
This property allows efficient logarithmic routing:
each hop moves to a peer that shares a longer prefix of bits
with the target service_id_hash.
Service-specific tables may initially have low peer density,
especially when service_id_hash and peerID are distant in the keyspace.
Buckets SHOULD be filled opportunistically through response.closerPeers
during interactions (see Peer Table Updates)
using the same formula.
RPC Messages
All RPC messages use the libp2p Kad-DHT message format with extensions for Logos discovery operations.
Base Message Structure
syntax = "proto2";
message Register {
// Used in response to indicate the status of the registration
enum RegistrationStatus {
CONFIRMED = 0;
WAIT = 1;
REJECTED = 2;
}
// Ticket protobuf definition
message Ticket {
// Copy of the original advertisement
bytes advertisement = 1;
// Ticket creation timestamp (Unix time in seconds)
uint64 t_init = 2;
// Last modification timestamp (Unix time in seconds)
uint64 t_mod = 3;
// Remaining wait time in seconds
uint32 t_wait_for = 4;
// Ed25519 signature over (ad || t_init || t_mod || t_wait_for)
bytes signature = 5;
}
// Used to indicate the encoded advertisement against the key (service ID)
bytes advertisement = 1;
// Used in response to indicate the status of the registration
RegistrationStatus status = 2;
// Used in response to provide a ticket if status is WAIT
// Used in request to provide a ticket if previously received
optional Ticket ticket = 3;
}
message GetAds {
// Used in response to provide a list of encoded advertisements
repeated bytes advertisements = 1;
}
// Record represents a dht record that contains a value
// for a key value pair
message Record {
// The key that references this record
bytes key = 1;
// The actual value this record is storing
bytes value = 2;
// Note: These fields were removed from the Record message
//
// Hash of the authors public key
// optional string author = 3;
// A PKI signature for the key+value+author
// optional bytes signature = 4;
// Time the record was received, set by receiver
// Formatted according to https://datatracker.ietf.org/doc/html/rfc3339
string timeReceived = 5;
};
message Message {
enum MessageType {
PUT_VALUE = 0;
GET_VALUE = 1;
ADD_PROVIDER = 2;
GET_PROVIDERS = 3;
FIND_NODE = 4;
PING = 5;
REGISTER = 6; // New DISC-NG capability discovery type
GET_ADS = 7; // New DISC-NG capability discovery type
}
enum ConnectionType {
// sender does not have a connection to peer, and no extra information (default)
NOT_CONNECTED = 0;
// sender has a live connection to peer
CONNECTED = 1;
// sender recently connected to peer
CAN_CONNECT = 2;
// sender recently tried to connect to peer repeatedly but failed to connect
// ("try" here is loose, but this should signal "made strong effort, failed")
CANNOT_CONNECT = 3;
}
message Peer {
// ID of a given peer.
bytes id = 1;
// multiaddrs for a given peer
repeated bytes addrs = 2;
// used to signal the sender's connection capabilities to the peer
ConnectionType connection = 3;
}
// defines what type of message it is.
MessageType type = 1;
// defines what coral cluster level this query/response belongs to.
// in case we want to implement coral's cluster rings in the future.
int32 clusterLevelRaw = 10; // NOT USED
// Used to specify the key associated with this message.
// PUT_VALUE, GET_VALUE, ADD_PROVIDER, GET_PROVIDERS
// New DISC-NG capability discovery contains service ID hash for types REGISTER, GET_ADS
bytes key = 2;
// Used to return a value
// PUT_VALUE, GET_VALUE
Record record = 3;
// Used to return peers closer to a key in a query
// GET_VALUE, GET_PROVIDERS, FIND_NODE
repeated Peer closerPeers = 8;
// Used to return Providers
// GET_VALUE, ADD_PROVIDER, GET_PROVIDERS
repeated Peer providerPeers = 9;
// Used in REGISTER request and response
Register register = 21;
// Used in GET_ADS response
GetAds getAds = 22;
}
Key Design Principles:
- Standard Kad-DHT message types (
PUT_VALUE,GET_VALUE,ADD_PROVIDER,GET_PROVIDERS,FIND_NODE,PING) remain completely unchanged - The
Recordmessage is preserved as-is for Kad-DHT routing table operations - Logos adds two new message types (
REGISTER,GET_ADS) without modifying existing types - Advertisements are encoded as generic
bytes(RECOMMENDED: ExtensiblePeerRecord/XPR) to avoid coupling the protocol to specific formats - The existing
keyfield is reused forservice_id_hashin Logos operations - Nodes without Logos Capability Discovery support will ignore
REGISTERandGET_ADSmessages
Advertisement Encoding
Advertisements in the Register.advertisement and GetAds.advertisements fields are encoded as bytes. Implementations are RECOMMENDED to use ExtensiblePeerRecord (XPR):
ExtensiblePeerRecord {
peer_id: <advertiser_peer_id>
seq: <monotonic_sequence>
addresses: [
AddressInfo { multiaddr: <addr1> },
AddressInfo { multiaddr: <addr2> }
]
services: [
ServiceInfo {
id: "/waku/store/1.0.0" // service protocol identifier
data: <optional_metadata>
}
]
}
Size constraints:
- Each
ServiceInfo.datafield SHOULD be ≤ 33 bytes - Total encoded XPR SHOULD be ≤ 1024 bytes
The XPR MUST be wrapped in a signed envelope with:
- Domain:
libp2p-routing-state - Payload type:
/libp2p/extensible-peer-record/
Alternative encodings MAY be used if they provide equivalent functionality and can be verified by discoverers.
REGISTER Message
Used by advertisers to register their advertisements with registrars.
REGISTER Request
| Field | Usage | Value |
|---|---|---|
type | REQUIRED | REGISTER (6) |
key | REQUIRED | service_id_hash (32 bytes) |
register.advertisement | REQUIRED | Encoded advertisement as bytes (RECOMMENDED: XPR) |
register.ticket | OPTIONAL | Ticket from previous registration attempt |
| All other fields | UNUSED | Empty/not set |
Example (First attempt):
Message {
type: REGISTER
key: <service_id_hash>
register: {
advertisement: <encoded_xpr_bytes>
}
}
Example (Retry with ticket):
Message {
type: REGISTER
key: <service_id_hash>
register: {
advertisement: <encoded_xpr_bytes>
ticket: {
ad: <encoded_xpr_bytes>
t_init: 1234567890
t_mod: 1234567900
t_wait_for: 300
signature: <registrar_signature>
}
}
}
REGISTER Response
| Field | Usage | Value |
|---|---|---|
type | REQUIRED | REGISTER (6) |
register.status | REQUIRED | CONFIRMED, WAIT, or REJECTED |
closerPeers | REQUIRED | List of peers for advertise table |
register.ticket | CONDITIONAL | Present if status = WAIT |
| All other fields | UNUSED | Empty/not set |
Status values:
CONFIRMED: Advertisement stored in cacheWAIT: Not yet accepted, wait and retry with ticketREJECTED: Invalid signature, duplicate, or error
Example (WAIT):
Message {
type: REGISTER
register: {
status: WAIT
ticket: {
ad: <encoded_xpr_bytes>
t_init: 1234567890
t_mod: 1234567905
t_wait_for: 295
signature: <registrar_signature>
}
}
closerPeers: [
{id: <peer1_id>, addrs: [<addr1>]},
{id: <peer2_id>, addrs: [<addr2>]}
]
}
Example (CONFIRMED):
Message {
type: REGISTER
register: {
status: CONFIRMED
}
closerPeers: [
{id: <peer1_id>, addrs: [<addr1>]},
{id: <peer2_id>, addrs: [<addr2>]}
]
}
Example (REJECTED):
Message {
type: REGISTER
register: {
status: REJECTED
}
}
GET_ADS Message
Used by discoverers to retrieve advertisements from registrars.
GET_ADS Request
| Field | Usage | Value |
|---|---|---|
type | REQUIRED | GET_ADS (7) |
key | REQUIRED | service_id_hash (32 bytes) |
| All other fields | UNUSED | Empty/not set |
Example:
Message {
type: GET_ADS
key: <service_id_hash>
}
GET_ADS Response
| Field | Usage | Value |
|---|---|---|
type | REQUIRED | GET_ADS (7) |
getAds.advertisements | REQUIRED | List of encoded advertisements (up to F_return = 10) |
closerPeers | REQUIRED | List of peers for search table |
| All other fields | UNUSED | Empty/not set |
Each advertisement in getAds.advertisements is encoded as bytes (RECOMMENDED: XPR).
Discoverers MUST verify signatures before accepting.
Example:
Message {
type: GET_ADS
getAds: {
advertisements: [
<encoded_xpr_bytes_1>,
<encoded_xpr_bytes_2>,
... // up to F_return
]
}
closerPeers: [
{id: <peer1_id>, addrs: [<addr1>]},
{id: <peer2_id>, addrs: [<addr2>]}
]
}
Message Validation
REGISTER Request Validation
Registrars MUST validate:
type=REGISTER(6)key= 32 bytes (valid SHA-256)register.advertisementis present and non-empty- If
register.ticketpresent:- Valid signature issued by this registrar
ticket.admatchesregister.advertisement- Retry within window:
ticket.t_mod + ticket.t_wait_for ≤ NOW() ≤ ticket.t_mod + ticket.t_wait_for + δ
- Advertisement signature is valid (see Advertisement Signature Verification)
- Advertisement not already in
ad_cache
Respond with register.status = REJECTED if validation fails.
GET_ADS Request Validation
Registrars MUST validate:
type=GET_ADS(7)key= 32 bytes (valid SHA-256)
Return empty getAds.advertisements list or close stream if validation fails.
Advertisement Signature Verification
Both registrars (on REGISTER) and discoverers (on GET_ADS response) MUST verify advertisement signatures. For XPR-encoded advertisements:
VERIFY_ADVERTISEMENT(encoded_ad_bytes, service_id_hash):
envelope = DECODE_SIGNED_ENVELOPE(encoded_ad_bytes)
assert(envelope.domain == "libp2p-routing-state")
assert(envelope.payload_type == "/libp2p/extensible-peer-record/")
xpr = DECODE_XPR(envelope.payload)
public_key = DERIVE_PUBLIC_KEY(xpr.peer_id)
assert(VERIFY_ENVELOPE_SIGNATURE(envelope, public_key))
// Verify service advertised
service_found = false
for service in xpr.services:
if SHA256(service.id) == service_id_hash:
service_found = true
assert(service_found)
Discard advertisements with invalid signatures or that don't advertise the requested service.
Note: ExtensiblePeerRecord uses protocol strings (e.g.,
/waku/store/1.0.0) inServiceInfo.id. Logos discovery usesservice_id_hash = SHA256(ServiceInfo.id)for routing. When verifying, implementations MUST hash the protocol string and compare with thekeyfield (service_id_hash).
Stream Management
Following standard Kad-DHT behavior:
- Implementations MAY reuse streams for sequential requests
- Implementations MUST handle multiple requests per stream
- Reset stream on protocol errors or validation failures
- Prefix messages with length as unsigned varint per multiformats spec
Error Handling
| Error | Handling |
|---|---|
| Invalid message format | Close stream |
| Signature verification failure | REJECTED for REGISTER; discard invalid ads for GET_ADS |
| Timeout | Close stream, retry with exponential backoff |
| Cache full (registrar) | Issue ticket with waiting time |
| Unknown service_id_hash | Empty advertisements list but include closerPeers |
| Missing required fields | Close stream |
Protocol identifier: /logos/capability-discovery/1.0.0
Implementations SHOULD support both standard Kad-DHT operations
and Logos discovery operations simultaneously.
Nodes operating in Kad-DHT-only mode will
simply not respond to REGISTER or GET_ADS requests.
Sequence Diagram
Advertisement Placement
Overview
For each service, identified by service_id_hash,
that an advertiser wants to advertise,
the advertiser MUST instantiate a
new AdvT(service_id_hash),
centered on that service_id_hash.
The advertiser MAY bootstrap AdvT(service_id_hash)
from KadDHT(peerID) using the formula
described in the Distance section.
The advertiser SHOULD try to maintain up to K_register
active registrations per bucket.
It does so by selecting random registrars
from each bucket of AdvT(service_id_hash)
and following the registration maintenance procedure.
These ongoing registrations MAY be tracked in a separate data structure.
Ongoing registrations include those registrars
which has an active ad or the advertiser is
trying to register its ad into that registrar.
Registration Maintenance Requirements
To maintain each registration, the advertiser:
- MUST send a REGISTER message to the registrar.
If there is already a cached
ticketfrom a previous registration attempt for the sameadin the same registrar, theticketMUST also be included in the REGISTER message. - On receipt of a Response,
SHOULD add the closer peers indicated in the response to
AdvT(service_id_hash)using the formula described in the Distance section. - MUST interpret the response
statusfield and schedule actions accordingly:- If the
statusisConfirmed, the registration is maintained in the registrar'sad_cacheforEseconds. AfterEseconds the advertiser MUST remove the registration from the ongoing registrations for that bucket. - If the
statusisWait, the advertiser MUST schedule a next registration attempt to the same registrar based on theticket.t_wait_forvalue included in the response. The Response contains aticket, which MUST be included in the next registration attempt to this registrar. - If the
statusisRejected, the advertiser MUST remove the registrar from the ongoing registrations for that bucket and SHOULD NOT attempt further registrations with this registrar for this advertisement.
- If the
Advertisement Algorithm
Advertisers place advertisements across multiple registrars
using the ADVERTISE() algorithm.
The advertisers run ADVERTISE() periodically.
We RECOMMEND that the following algorithms be used
to implement the advertisement placement requirements specified above.
procedure ADVERTISE(service_id_hash):
ongoing ← MAP<bucketIndex; LIST<registrars>>
AdvT(service_id_hash) ← KadDHT(peerID)
for i in 0, 1, ..., m-1:
while ongoing[i].size < K_register:
registrar ← AdvT(service_id_hash).getBucket(i).getRandomNode()
if registrar = None:
break
end if
ongoing[i].add(registrar)
ad.service_id_hash ← service_id_hash
ad.peerID ← peerID
ad.addrs ← node.addrs
SIGN(ad)
async(ADVERTISE_SINGLE(registrar, ad, i, service_id_hash))
end while
end for
end procedure
procedure ADVERTISE_SINGLE(registrar, ad, i, service_id_hash):
ticket ← None
while True:
response ← registrar.Register(ad, ticket)
AdvT(service_id_hash).add(response.closerPeers)
if response.status = Confirmed:
SLEEP(E)
break
else if response.status = Wait:
SLEEP(min(E, response.ticket.t_wait_for))
ticket ← response.ticket
else:
break
end if
end while
ongoing[i].remove(registrar)
end procedure
Refer to the Advertiser Algorithms Explanation section for a detailed explanation.
Service Discovery
Overview
Discoverers are nodes attempting to find peers that
provide a specific service identified by service_id_hash.
Discovery Table DiscT(service_id_hash) Requirements
For each service that a discoverer wants to find,
it MUST instantiate a search table DiscT(service_id_hash),
centered on that service_id_hash.
Lookup Requirements
The LOOKUP(service_id_hash) is carried out by discoverer nodes to query registrar nodes
to get advertisements of a particular service_id_hash.
The LOOKUP(service_id_hash) procedure MUST work as a gradual search
on the search table DiscT(service_id_hash) of the service whose advertisements it wants.
The LOOKUP(service_id_hash) MUST start from far buckets (b_0)
which has registrar nodes with fewer shared bits with service_id_hash
and moving to buckets (b_(m-1)) containing registrar nodes with
higher number of shared bits or closer to service_id_hash.
To perform a lookup, discoverers:
- SHOULD query
K_lookuprandom registrar nodes from every bucket ofDiscT(service_id_hash). - MUST verify the signature of each advertisement received before accepting it.
- SHOULD add closer peers returned by registrars
in the response to
DiscT(service_id_hash)to improve future lookups. - SHOULD retrieve at most
F_returnadvertisement peers from a single registrar. - SHOULD run the lookup process periodically. Implementations can choose the interval based on their requirements.
Lookup Algorithm
We RECOMMEND that the following algorithm be used to implement the service discovery requirements specified above. Implementations MAY use alternative algorithms as long as they satisfy requirements specified in the previous section.
procedure LOOKUP(service_id_hash):
DiscT(service_id_hash) ← KadDHT(peerID)
foundPeers ← SET<Peers>
for i in 0, 1, ..., m-1:
for j in 0, ..., K_lookup - 1:
peer ← DiscT(service_id_hash).getBucket(i).getRandomNode()
if peer = None:
break
end if
response ← peer.GetAds(service_id_hash)
for ad in response.ads:
assert(ad.hasValidSignature())
foundPeers.add(ad.peerID)
if foundPeers.size ≥ F_lookup:
break
end if
end for
DiscT(service_id_hash).add(response.closerPeers)
if foundPeers.size ≥ F_lookup:
return foundPeers
end if
end for
end for
return foundPeers
end procedure
Refer to the Lookup Algorithm Explanation section for the detailed explanation.
Admission Protocol
Overview
Registrars are nodes that store and serve advertisements. They play a critical role in the Logos discovery network by acting as intermediaries between advertisers and discoverers.
Admission Control Requirements
Registrars MUST use a waiting time based admission protocol
to admit advertisements into their ad_cache.
The mechanism does not require registrars to maintain
any state for each ongoing request preventing DoS attacks.
When a registrar node receives a REGISTER request from an advertiser node
to admit its ad for a service into the ad_cache,
the registrar MUST process the request according to the following requirements:
- The registrar MUST NOT admit an advertisement
if an identical
adalready exists in thead_cache. - The Registrar MUST calculate waiting time using the formula in Waiting Time Calculation.
- If no
ticketis provided in theREGISTERrequest then this is the advertiser's first registration attempt for thead. The registrar MUST create a newticketand return the signedticketto the advertiser with statusWait. - If a
ticketis provided, the registrar MUST verify:- valid signature issued by this registrar
ticket.admatches currentadadis not in thead_cache- retry is within the registration window
- Reject if any verification fails
- The registrar MUST recalculate the waiting time based on current cache state
- The registrar MUST calculate remaining wait time:
t_remaining = t_wait - (NOW() - ticket.t_init). This ensures advertisers accumulate waiting time across retries
- If
t_remaining ≤ 0, the registrar MUST add theadto thead_cache. The registrar MUST track the admission time internally for expiry management. The registrar SHOULD return response withstatus = Confirmed - If
t_remaining > 0, the advertiser SHOULD continue waiting. The registrar MUST issue a newticketwith updatedticket.t_modandticket.t_wait_for = MIN(E, t_remaining). The registrar MUST sign the newticket. The registrar SHOULD return response with statusWaitand the new signedticket. - The registrar SHOULD include a list of closer peers (
response.closerPeers) using the algorithm described in Peer Table Updates section.
ad_cache Maintenance:
- Size limited by capacity
C - Ads expire after time
Efrom admission and are removed - No duplicate ads allowed
Registration Flow
We RECOMMEND that the following algorithm be used by registrars to implement the admission control requirements specified above.
Refer to the Register Message section
for the request and response structure of REGISTER.
procedure REGISTER(ad, ticket):
assert(ad not in ad_cache)
response.ticket.ad ← ad
t_wait ← CALCULATE_WAITING_TIME(ad)
if ticket.empty():
t_remaining ← t_wait
response.ticket.t_init ← NOW()
response.ticket.t_mod ← NOW()
else:
assert(ticket.hasValidSignature())
assert(ticket.ad = ad)
assert(ad.notInAdCache())
t_scheduled ← ticket.t_mod + ticket.t_wait_for
assert(t_scheduled ≤ NOW() ≤ t_scheduled + δ)
t_remaining ← t_wait - (NOW() - ticket.t_init)
end if
if t_remaining ≤ 0:
ad_cache.add(ad)
response.status ← Confirmed
else:
response.status ← Wait
response.ticket.t_wait_for ← MIN(E, t_remaining)
response.ticket.t_mod ← NOW()
SIGN(response.ticket)
end if
response.closerPeers ← GETPEERS(ad.service_id_hash)
return response
end procedure
Refer to the Register Algorithm Explanation section for detailed explanation.
Lookup Response Algorithm
Overview
Registrars SHOULD respond to GET_ADS requests from discoverers.
When responding, registrars:
- SHOULD return up to
F_returnadvertisements from theirad_cachefor the requestedservice_id_hash. - SHOULD include a list of closer peers to help discoverers populate their search table using the algorithm described in Peer Table Updates section.
Recommended Lookup Response Algorithm
Registrars respond to GET_ADS requests from discoverers
using the LOOKUP_RESPONSE() algorithm.
We RECOMMEND using the following algorithm.
procedure LOOKUP_RESPONSE(service_id_hash):
response.ads ← ad_cache.getAdvertisements(service_id_hash)[:F_return]
response.closerPeers ← GETPEERS(service_id_hash)
return response
end procedure
Peer Table Updates
Overview
While responding to both REGISTER requests by advertisers
and GET_ADS request by discoverers,
registrars play an important role in helping nodes discover the network topology.
The registrar table RegT(service_id_hash) is a routing structure
that SHOULD be maintained by registrars
to provide better peer suggestions to advertisers and discoverers.
Registrars SHOULD use the formula specified in the Distance section
to add peers to RegT(service_id_hash).
Peers are added under the following circumstances:
- Registrars MAY initialize their registrar table
RegT(service_id_hash)from theirKadDHT(peerID)using the formula described in the Distance Section. - When an advertiser sends a
REGISTERrequest, the registrar SHOULD add the advertiser'speerIDtoRegT(service_id_hash). - When a discoverer sends a
GET_ADSrequest, the registrar SHOULD add the discoverer'speerIDtoRegT(service_id_hash). - When registrars receive responses from other registrars
(if acting as advertiser or discoverer themselves),
they SHOULD add peers from
closerPeersfields to relevantRegT(service_id_hash)tables.
Note: The
ad_cacheandRegT(service_id_hash)are completely different data structures that serve different purposes and are independent of each other.
When responding to requests, registrars:
- SHOULD return a list of peers to help advertisers
populate their
AdvT(service_id_hash)tables and discoverers populate theirDiscT(service_id_hash)tables. - SHOULD return peers that are diverse and distributed across different buckets to prevent malicious registrars from polluting routing tables.
Recommended Peer Selection Algorithm
We RECOMMEND that the following algorithm be used to select peers to return in responses.
procedure GETPEERS(service_id_hash):
peers ← SET<peers>
RegT(service_id_hash) ← KadDHT(peerID)
for i in 0, 1, ..., m-1:
peer ← b_i(service_id_hash).getRandomNode()
if peer ≠ None:
peers.add(peer)
end if
end for
return peers
end procedure
peersis initialized as an empty setRegT(service_id_hash)is initialized from the node’sKadDHT(peerID).- Go through all
mbuckets in the registrar’s table —- Pick one random peer from bucket
i.getRandomNode()function remembers already returned nodes and never returns the same one twice. - If
peerreturned is not null then we move on to next bucket. Else we try to get another peer in the same bucket
- Pick one random peer from bucket
- Return
peerswhich contains one peer from every bucket ofRegT(service_id_hash).
The algorithm returns one random peer per bucket to provide diverse suggestions
and prevent malicious registrars from polluting routing tables.
Contacting registrars in consecutive buckets divides the search space by a constant factor,
and allows learning new peers from more densely-populated routing tables towards the destination.
The procedure mitigates the risk of having malicious peers polluting the table
while still learning rare peers in buckets close to service_id_hash.
Waiting Time Calculation
Formula
The waiting time is the time advertisers have to
wait before their ad is admitted to the ad_cache.
The waiting time is given based on the ad itself
and the current state of the registrar’s ad_cache.
The waiting time for an advertisement MUST be calculated using:
w(ad) = E × (1/(1 - c/C)^P_occ) × (c(ad.service_id_hash)/C + score(getIP(ad.addrs)) + G)
c: Current cache occupancyc(ad.service_id_hash): Number of advertisements forservice_id_hashin cachegetIP(ad.addrs)is a function to get the IP address from the multiaddress of the advertisement.score(getIP(ad.addrs)): IP similarity score (0 to 1). Refer to the IP Similarity Score section
Section System Parameters can be referred for the definitions of the remaining parameters in the formula.
Issuing waiting times promote diversity in the ad_cache.
It results in high waiting times and slower admission for malicious advertisers
using Sybil identities from a limited number of IP addresses.
It also promotes less popular services with fast admission
ensuring fairness and robustness against failures of single registrars.
Scaling
The waiting time is normalized by the ad’s expiry time E.
It binds waiting time to E and allows us to reason about the number of incoming requests
regardless of the time each ad spends in the ad_cache.
Occupancy Score
occupancy_score = 1 / (1 - c/C)^P_occ
The occupancy score increases progressively as the cache fills:
- When
c << C: Score ≈ 1 (minimal impact) - As the
ad_cachefills up, the score will be amplified by the divisor of the equation. The higher the value ofP_occ, the faster the increase. Implementations should consider this while setting the value forP_occ - As
c → C: Score → ∞ (prevents overflow)
Service Similarity
service_similarity = c(ad.service_id_hash) / C
The service similarity score promotes diversity:
- Low when
service_id_hashhas few advertisements in cache. Thus lower waiting time. - High when
service_id_hashdominates the cache. Thus higher waiting time.
IP Similarity Score
The IP similarity score is used to detect and limit Sybil attacks where malicious actors create multiple advertisements from the same network or IP prefix.
Registrars MUST use an IP similarity score to
limit the number of ads coming from the same subnetwork
by increasing their waiting time.
The IP similarity mechanism MUST:
- Calculate a score (0-1): higher scores indicate similar IP prefixes (potential Sybil attacks)
- Track IP addresses of
adscurrently in thead_cache. - MUST update its tracking structure when:
- A new
adis admitted to thead_cache: MUST add the IP - An
adexpires after timeE: MUST remove IP if there are no other activeadsfrom the same IP
- A new
- Recalculate score for each registration attempt
Tree Structure
We RECOMMEND using an IP tree data structure
to efficiently track and calculate IP similarity scores.
An IP tree is a binary tree that stores
IPs used by ads currently present in the ad_cache.
This data structure provides logarithmic time complexity
for insertion, deletion, and score calculation.
Implementations MAY use alternative data structures
as long as they satisfy the requirements specified above.
Apart from root, the IP tree is a 32-level binary tree where:
- Each vertex stores
IP_counter(number of IPs passing through). It is initially set to 0. - Edges represent bits (0/1) in IPv4 binary representation
- When an
adis admitted to thead_cache, its IPv4 address is added to the IP tracking structure using theADD_IP_TO_TREE()algorithm. - Every time a waiting time is calculated for a registration attempt,
the registrar calculates the IP similarity score for the advertiser's IP address.
using
CALCULATE_IP_SCORE()algorithm. - When an
adis removed from thead_cacheafterE, The registrar also removes the IP from IP tracking structure using theREMOVE_FROM_IP_TREE()algorithm if there are no other activeadinad_cachefrom the same IP. - All the algorithms work efficiently with O(32) time complexity.
- The root
IP_countertracks total IPs currently inad_cache
ADD_IP_TO_TREE() algorithm
The algorithm traverses the tree following the IP's binary representation, incrementing counters at each visited node.
procedure ADD_IP_TO_TREE(tree, IP):
v ← tree.root
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
v.IP_counter ← v.IP_counter + 1
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
end for
end procedure
CALCULATE_IP_SCORE() algorithm
The algorithm traverses the tree following the IP's binary representation
to detect how many IPs share common prefixes, providing a Sybil attack measure.
At each node, if the IP_counter is larger than expected in a perfectly balanced tree,
it indicates too many IPs share that prefix, incrementing the similarity score.
procedure CALCULATE_IP_SCORE(tree, IP):
v ← tree.root
score ← 0
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
if v.IP_counter > tree.root.IP_counter / 2^i:
score ← score + 1
end if
end for
return score / 32
end procedure
REMOVE_FROM_IP_TREE() algorithm
This algorithm traverses the tree following the IP's binary representation, decrementing counters at each visited node.
procedure REMOVE_FROM_IP_TREE(tree, IP):
v ← tree.root
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
v.IP_counter ← v.IP_counter - 1
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
end for
end procedure
Implementations can extend the IP tree algorithms to IPv6 by using a 128-level binary tree, corresponding to the 128-bit length of IPv6 addresses.
Safety Parameter
The safety parameter G ensures waiting times never reach zero even when:
- Service similarity is zero (new service).
- IP similarity is zero (completely distinct IP)
It prevents ad_cache overflow in cases when attackers try to
send ads for random services or from diverse IPs.
Lower Bound Enforcement
To prevent "ticket grinding" attacks where advertisers repeatedly request new tickets hoping for better waiting times, registrars MUST enforce lower bounds:
Invariant: A new waiting time w_2 at time t_2
cannot be smaller than a previous waiting time w_1 at time t_1
(where t_1 < t_2) by more than the elapsed time:
w_2 ≥ w_1 - (t_2 - t_1)
Thus registrars MUST maintain lower bound state for:
- Each service in the cache:
bound(service_id_hash)andtimestamp(service_id_hash) - Each IP prefix in the IP tree:
bound(IP)andtimestamp(IP)
The total waiting time will respect the lower bound if lower bound is enforced on these.
These two sets have a bounded size as number of ads present in the ad_cache
at a time is bounded by the cache capacity C.
How SHOULD lower bound be calculated for service IDs:
When new service_id_hash enters the cache, bound(service_id_hash) is set to 0,
and a timestamp(service_id_hash) is set to the current time.
When a new ticket request arrives for the same service_id_hash,
the registrar calculates the service waiting time w_s and then applies the lower-bound rule:
w_s = max(w_s, bound(service_id_hash) - timestamp(service_id_hash))
The values bound(service_id_hash) and timestamp(service_id_hash)
are updated whenever a new ticket is issued
and the condition w_s > (bound(service_id_hash) - timestamp(service_id_hash))is satisfied.
How SHOULD lower bound be calculated for IPs: Registrars enforce lower-bound state for the advertiser’s IP address using IP tree (refer to the IP Similarity Score section).
Implementation Notes
Client and Server Mode
Logos discovery respects the client/server mode distinction from the base Kad-dht specification:
- Server mode nodes: MAY be Discoverer, Advertiser or Registrar
- Client mode nodes: MUST be only Discoverer
Implementations MAY include incentivization mechanisms to encourage peers to participate as advertisers or registrars, rather than operating solely in client mode. This helps prevent free-riding behavior, ensures a fair distribution of network load, and maintains the overall resilience and availability of the discovery layer. Incentivization mechanisms are beyond the scope of this RFC.
Bucket Management
Bucket Representation
For simplicity in this RFC, we represent each bucket as a list of peer IDs. However, in a full implementation, each entry in the bucket MUST store complete peer information necessary to enable communication.
Bucket Size
The number of entries a bucket can hold is implementation-dependent:
- Smaller buckets → lower memory usage but reduced resilience to churn
- Larger buckets → better redundancy but increased maintenance overhead
Implementations SHOULD ensure that each bucket contains only unique peers. If the peer to be added is already present in the bucket, the implementation SHOULD NOT create a duplicate entry and SHOULD instead update the existing entry.
Bucket Overflow Handling
When a bucket reaches its maximum capacity and a new peer needs to be added, implementations SHOULD decide how to handle the overflow. The specific strategy is implementation-dependent, but implementations MAY consider one of the following approaches:
-
Least Recently Used (LRU) Eviction: Replace the peer that was least recently contacted or updated. This keeps more active and responsive peers in the routing table.
-
Least Recently Seen (LRS) Eviction: Replace the peer that was seen (added to the bucket) earliest. This provides a time-based rotation of peers.
-
Ping-based Eviction: When the bucket is full, ping the least recently contacted peer. If the ping fails, replace it with the new peer. If the ping succeeds, keep the existing peer and discard the new one. This prioritizes responsive, reachable peers.
-
Reject New Peer: Keep existing peers and reject the new peer. This strategy assumes existing peers are more stable or valuable.
-
Bucket Extension: Dynamically increase bucket capacity (within reasonable limits) when overflow occurs, especially for buckets closer to the center ID.
Implementations MAY combine these strategies or use alternative approaches based on their specific requirements for performance, security, and resilience.
References
[0] Kademlia: A Peer-to-Peer Information System Based on the XOR Metric
[1] DISC-NG: Robust Service Discovery in the Ethereum Global Network
[2] libp2p Kademlia DHT specification
Appendix
This appendix provides detailed explanations of some algorithms and helper procedures referenced throughout this RFC. To maintain clarity and readability in the main specification, the body contains only the concise pseudocode and high-level descriptions.
Advertiser Algorithms Explanation
Refer to the Advertisement Algorithm section for the pseudocode.
ADVERTISE() algorithm explanation
- Initialize a map
ongoingfor tracking which registrars are currently being advertised to. - Initialize
AdvT(service_id_hash)by bootstrapping peers from the advertiser’sKadDHT(peerID)using the formula described in the Distance section. - Iterate over all buckets (i = 0 through
m-1), wheremis the number of buckets inAdvT(service_id_hash)andongoingmap. Each bucket corresponds to a particular distance from theservice_id_hash.ongoing[i]contains list of registrars with active (unexpired) registrations or ongoing registration attempts at a distanceifrom theservice_id_hashof the service that the advertiser is advertising for.- Advertisers continuously maintain up to
K_registeractive (unexpired) registrations or ongoing registration attempts in every bucket of theongoingmap for its service. IncreasingK_registermakes the advertiser easier to find at the cost of increased communication and storage costs. - Pick a random registrar from bucket
iofAdvT(service_id_hash)to advertise to.AdvT(service_id_hash).getBucket(i)→ returns a list of registrars in bucketifromAdvT(service_id_hash).getRandomNode()→ function returns a random registrar node. The advertiser tries to place its advertisement into that registrar. The function remembers already returned nodes and never returns the same one twice during the sameadplacement process. If there are no peers, it returnsNone.
- if we get a peer then we add that to that bucket
ongoing[i] - Build the advertisement object
adcontainingservice_id_hash,peerID, andaddrs(Refer to the Advertisement section). Then it is signed by the advertiser using the node’s private key (Ed25519 signature) - Then send this
adasynchronously to the selected registrar. The helperADVERTISE_SINGLE()will handle registration to a single registrar. Asynchronous execution allows multipleads(to multiple registrars) to proceed in parallel.
ADVERTISE_SINGLE() algorithm explanation
ADVERTISE_SINGLE() algorithm handles registration to one registrar at a time
- Initialize
tickettoNoneas we have not yet got any ticket from registrar - Keep trying until the registrar confirms or rejects the
ad.- Send the
adto the registrar usingRegisterrequest. Request structure is described in section Register Message Structure. If we already have a ticket, include it in the request. - The registrar replies with a
response. Refer to the Register Message Structure section for the response structure - Add the list of peers returned by the registrar
response.closerPeerstoAdvT(service_id_hash). Refer to the [Distance](#distance section) on how to add. - If the registrar accepted the advertisement successfully,
wait for
Eseconds, then stop retrying because theadis already registered. - If the registrar says “wait” (its cache is full or overloaded),
sleep for the time written in the ticket
ticket.t_wait_for(but not more thanE). Then updateticketwith the new one from the registrar, and try again. - If the registrar rejects the ad, stop trying with this registrar.
- Send the
- Remove this registrar from the
ongoingmap in bucket i (ongoing[i]), since we’ve finished trying with it.
Discoverer Algorithms
LOOKUP(service_id_hash) algorithm explanation
Refer to the Lookup Algorithm section for the pseudocode.
DiscT(service_id_hash)is initialized by bootstrapping peers from the discoverer’sKadDHT(peerID)using the formula described in the Distance section.- Create an empty set
foundPeersto store unique advertisers peer IDs discovered during the lookup. - Go through each bucket of
DiscT(service_id_hash)— from farthest (b₀) to closest (bₘ₋₁) to the service IDservice_id_hash. For each bucket, query up toK_lookuprandom peers.- Pick a random registrar node from bucket
iofDiscT(service_id_hash)to queryDiscT(service_id_hash).getBucket(i)→ returns a list of registrars in bucketifromDiscT(service_id_hash).getRandomNode()→ function returns a random registrar node. The discover queries this node to getadsfor a particular service IDservice_id_hash. The function remembers already returned nodes and never returns the same one twice. If there are no peers, it returnsNone.
- A
GET_ADSrequest is sent to the selected registrar peer. Refer to the GET_ADS Message section to see the request and response structure forGET_ADS. The response returned by the registrar node is stored inresponse - The
responsecontains a list of advertisementsresponse.ads. A queried registrar returns at mostF_returnadvertisements. If it returns more we can just randomly keepF_returnof them. For each advertisement returned:- Verify its digital signature for authenticity.
- Add the advertiser’s peer ID
ad.peerIDto the listfoundPeers.
- The
responsealso contains a list of peersresponse.closerPeersthat is inserted intoDiscT(service_id_hash)using the formula described in the Distance section. - Stop early if enough advertiser peers (
F_lookup) have been found — no need to continue searching. For popular servicesF_lookupadvertisers are generally found in the initial phase from the farther buckets and the search terminates. But for unpopular ones it might take longer but not more thanO(log N)where N is number of nodes participating in the network as registrars. - If early stop doesn’t happen then the search stops when no unqueried registrars remain in any of the buckets.
- Pick a random registrar node from bucket
- Return
foundPeerswhich is the final list of discovered advertisers that provide serviceservice_id_hash
Making the advertisers and discoverers walk towards service_id_hash in a similar fashion
guarantees that the two processes overlap and contact a similar set of registrars that relay the ads.
At the same time, contacting random registrars in each encountered bucket using getRandomNode()
makes it difficult for an attacker to strategically place malicious registrars in the network.
The first bucket b_0(service_id_hash) covers the largest fraction of the key space
as it corresponds to peers with no common prefix to service_id_hash (i.e. 50% of all the registrars).
Placing malicious registrars in this fraction of the key space
to impact service discovery process would require considerable resources.
Subsequent buckets cover smaller fractions of the key space,
making it easier for the attacker to place Sybils
but also increasing the chance of advertisers already gathering enough ads in previous buckets.
Parameters F_return and F_lookup play an important role in setting a compromise between security and efficiency.
A small value of F_return << F_lookup increases the diversity of the source of ads received by the discoverer
but increases search time, and requires reaching buckets covering smaller key ranges where eclipse risks are higher.
On the other hand, similar values for F_lookup and F_return reduce overheads
but increase the danger of a discoverer receiving ads uniquely from malicious nodes.
Finally, low values of F_lookup stop the search operation early,
before reaching registrars close to the service hash, contributing to a more balanced load distribution.
Implementations should consider these trade-offs carefully when selecting appropriate values.
Registrar Algorithms
REGISTER() algorithm explanation
Refer to the Registration Flow section for the pseudocode
- Make sure this advertisement
adis not already in the registrar’s advertisement cachead_cache. - Prepare a response ticket
response.ticketlinked to thisad. - Then calculate how long the advertiser should wait
t_waitbefore being admitted. Refer to the Waiting Time Calculation section for details. - Check if this is the first registration attempt (no ticket yet):
- If yes then it’s the first try. The advertiser must wait for the full waiting time
t_wait. The ticket’s creation timet_initand last-modified timet_modare both set toNOW(). - If no, then this is a retry, so a previous ticket exists.
- Validate that the ticket is properly signed by the registrar,
belongs to this same advertisement and that the
adis still not already in thead_cache. - Ensure the retry is happening within the allowed time window
δafter the scheduled time. If the advertiser waits too long or too short, the ticket is invalid. - Calculate how much waiting time is left
t_remainingby subtracting how long the advertiser has already waited (NOW() - ticket.t_init) fromt_wait.
- Validate that the ticket is properly signed by the registrar,
belongs to this same advertisement and that the
- If yes then it’s the first try. The advertiser must wait for the full waiting time
- Check if the remaining waiting time
t_remainingis less than or equal to 0. This means the waiting time is over.t_remainingcan be 0 also when the registrar decides that the advertiser doesn’t have to wait for admission to thead_cache(waiting timet_waitis 0).- If yes, add the
adtoad_cacheand confirm registration. The advertisement is now officially registered. - If no, then there is still time to wait.
In this case registrar does not store
adbut instead issues a ticket.- set
reponse.statustowait - Update the ticket with the new remaining waiting time
t_wait_for - Update the ticket last modification time
t_mod - Sign the ticket again. The advertiser will retry later using this new ticket.
- set
- If yes, add the
- Add a list of peers closer to the
ad.service_id_hashusing theGETPEERS()function to the response (the advertiser uses this to updateAdvT(service_id_hash)). - Send the full response back to the advertiser
Upon receiving a ticket, the advertiser waits for the specified t_wait time
before trying to register again with the same registrar.
Tickets can only be used within registration window δ,
preventing attackers from accumulating and batch-submitting tickets.
Clock synchronization is not required as advertisers only use t_wait_for values.
Waiting times are recalculated on each retry based on current cache state, ensuring advertisers accumulate waiting time and are eventually admitted. This stateless design protects registrars from memory exhaustion and DoS attacks.
The waiting time t_wait is not fixed.
Each time an advertiser tries to register,
the registrar recalculates a new waiting time.
The remaining time t_remaining is then computed as the difference between
the new waiting time and the time the advertiser has already waited,
as recorded in the ticket.
With every retry, the advertiser accumulates waiting time and will eventually be admitted.
However, if the advertiser misses its registration window or fails to include the last ticket,
it loses all accumulated waiting time and
must restart the registration process from the beginning.
Implementations must consider these factors
while deciding the registration window δ time.
This design lets registrars prioritize advertisers that have waited longer
without keeping any per-request state before the ad is admitted to the cache.
Because waiting times are recalculated and tickets are stored only on the advertiser’s side,
the registrar is protected from memory exhaustion
and DoS attacks caused by inactive or malicious advertisers.
MIX
| Field | Value |
|---|---|
| Name | LIBP2P-MIX |
| Slug | 99 |
| Status | raw |
| Category | Standards Track |
| Editor | Prem Prathi [email protected] |
| Contributors | Akshaya Mani [email protected], Daniel Kaiser [email protected], Hanno Cornelius |
Timeline
- 2026-01-29 —
925aeac— chore: changes to how per-hop proof is added to sphinx packet which makes it simpler (#263) - 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-12-15 —
dabc317— fixing format errors in mix rfc (#229) - 2025-12-12 —
4f54254— fix format errors in math sections for mix rfc (#225) - 2025-12-11 —
7f1df32— chore: use sembreaks for easy review and edits (#223) - 2025-12-10 —
e742cd5— RFC Addition: Section 9 Security Considerations (#194) - 2025-12-10 —
9d11a22— docs: finalize Section 8 Sphinx Packet Construction and Handling (#202) - 2025-10-05 —
36be428— RFC Refactor: Sphinx Packet Format (#173) - 2025-06-27 —
5e3b478— RFC Refactor PR: Modular Rewrite of Mix Protocol Specification (#158) - 2025-06-02 —
db90adc— Fix LaTeX errors (#163) - 2024-11-08 —
38fce27— typo fix - 2024-09-16 —
7f5276e— libp2p Mix Protocol Spec Draft (#97)
Abstract
The Mix Protocol defines a decentralized anonymous message routing layer for libp2p networks. It enables sender anonymity by routing each message through a decentralized mix overlay network composed of participating libp2p nodes, known as mix nodes. Each message is routed independently in a stateless manner, allowing other libp2p protocols to selectively anonymize messages without modifying their core protocol behavior.
1. Introduction
The Mix Protocol is a custom libp2p protocol that defines a message-layer routing abstraction designed to provide sender anonymity in peer-to-peer systems built on the libp2p stack. It addresses the absence of native anonymity primitives in libp2p by offering a modular, content-agnostic protocol that other libp2p protocols can invoke when anonymity is required.
This document describes the design, behavior, and integration of the Mix Protocol within the libp2p architecture. Rather than replacing or modifying existing libp2p protocols, the Mix Protocol complements them by operating independently of connection state and protocol negotiation. It is intended to be used as an optional anonymity layer that can be selectively applied on a per-message basis.
Integration with other libp2p protocols is handled through external interface components—the Mix Entry and Exit layers—which mediate between these protocols and the Mix Protocol instances. These components allow applications to defer anonymity concerns to the Mix layer without altering their native semantics or transport assumptions.
The rest of this document describes the motivation for the protocol, defines relevant terminology, presents the protocol architecture, and explains how the Mix Protocol interoperates with the broader libp2p protocol ecosystem.
2. Terminology
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
The following terms are used throughout this specification:
-
Origin Protocol A libp2p protocol (e.g., Ping, GossipSub) that generates and receives the actual message payload. The origin protocol MUST decide on a per-message basis whether to route the message through the Mix Protocol or not.
-
Mix Node A libp2p node that supports the Mix Protocol and participates in the mix network. A mix node initiates anonymous routing when invoked with a message. It also receives and processes Sphinx packets when selected as a hop in a mix path.
-
Mix Path A non-repeating sequence of mix nodes through which a Sphinx packet is routed across the mix network.
-
Mixify A per-message flag set by the origin protocol to indicate that a message should be routed using the Mix Protocol or not. Only messages with mixify set are forwarded to the Mix Entry Layer. Other messages SHOULD be routed using the origin protocol's default behavior. The phrases 'messages to be mixified', 'to mixify a message' and related variants are used informally throughout this document to refer to messages that either have the
mixifyflag set or are selected to have it set. -
Mix Entry Layer A component that receives messages to be mixified from an origin protocol and forwards them to the local Mix Protocol instance. The Entry Layer is external to the Mix Protocol.
-
Mix Exit Layer A component that receives decrypted messages from a Mix Protocol instance and delivers them to the appropriate origin protocol instance at the destination. Like the Entry Layer, it is external to the Mix Protocol.
-
Mixnet or Mix Network A decentralized overlay network formed by all nodes that support the Mix Protocol. It operates independently of libp2p's protocol-level routing and origin protocol behavior.
-
Sphinx Packet A cryptographic packet format used by the Mix Protocol to encapsulate messages. It uses layered encryption to hide routing information and protect message contents as packets are forwarded hop-by-hop. Sphinx packets are fixed-size and indistinguishable from one another, providing unlinkability and metadata protection.
-
Initialization Vector (IV) A fixed-length input used to initialize block ciphers to add randomness to the encryption process. It ensures that encrypting the same plaintext with the same key produces different ciphertexts. The IV is not secret but must be unique for each encryption.
-
Single-Use Reply Block (SURB) A pre-computed Sphinx header that encodes a return path back to the sender. SURBs are generated by the sender and included in the Sphinx packet sent to the recipient. It enables the recipient to send anonymous replies, without learning the sender's identity, the return path, or the forwarding delays.
3. Motivation and Background
libp2p enables modular peer-to-peer applications, but it lacks built-in support for sender anonymity. Most protocols expose persistent peer identifiers, transport metadata, or traffic patterns that can be exploited to deanonymize users through passive observation or correlation.
While libp2p supports NAT traversal mechanisms such as Circuit Relay, these focus on connectivity rather than anonymity. Relays may learn peer identities during stream setup and can observe traffic timing and volume, offering no protection against metadata analysis.
libp2p also supports a Tor transport for network-level anonymity, tunneling traffic through long-lived, encrypted circuits. However, Tor relies on session persistence and is ill-suited for protocols requiring per-message unlinkability.
The Mix Protocol addresses this gap with a decentralized message routing layer based on classical mix network principles. It applies layered encryption and per-hop delays to obscure both routing paths and timing correlations. Each message is routed independently, providing resistance to traffic analysis and protection against metadata leakage
By decoupling anonymity from connection state and transport negotiation, the Mix Protocol offers a modular privacy abstraction that existing libp2p protocols can adopt without altering their core behavior.
To better illustrate the differences in design goals and threat models, the following subsection contrasts the Mix Protocol with Tor, a widely known anonymity system.
3.1 Comparison with Tor
The Mix Protocol differs fundamentally from Tor in several ways:
-
Unlinkability: In the Mix Protocol, there is no direct connection between source and destination. Each message is routed independently, eliminating correlation through persistent circuits.
-
Delay-based mixing: Mix nodes introduce randomized delays (e.g., from an exponential distribution) before forwarding messages, making timing correlation significantly harder.
-
High-latency focus: Tor prioritizes low-latency communication for interactive web traffic, whereas the Mix Protocol is designed for scenarios where higher latency is acceptable in exchange for stronger anonymity.
-
Message-based design: Each message in the Mix Protocol is self-contained and independently routed. No sessions or state are maintained between messages.
-
Resistance to endpoint attacks: The Mix Protocol is less susceptible to certain endpoint-level attacks, such as traffic volume correlation or targeted probing, since messages are delayed, reordered, and unlinkable at each hop.
To understand the underlying anonymity properties of the Mix Protocol, we next describe the core components of a mix network.
4. Mixing Strategy and Packet Format
The Mix Protocol relies on two core design elements to achieve sender unlinkability and metadata protection: a mixing strategy and a cryptographically secure mix packet format.
4.1 Mixing Strategy
A mixing strategy defines how mix nodes delay and reorder incoming packets to resist timing correlation and input-output linkage. Two commonly used approaches are batch-based mixing and continuous-time mixing.
In batching-based mixing, each mix node collects incoming packets over a fixed or adaptive interval, shuffles them, and forwards them in a batch. While this provides some unlinkability, it introduces high latency, requires synchronized flushing rounds, and may result in bursty output traffic. Anonymity is bounded by the batch size, and performance may degrade under variable message rates.
The Mix Protocol instead uses continuous-time mixing, where each mix node applies a randomized delay to every incoming packet, typically drawn from an exponential distribution. This enables theoretically unbounded anonymity sets, since any packet may, with non-zero probability, be delayed arbitrarily long. In practice, the distribution is truncated once the probability of delay falls below a negligible threshold. Continuous-time mixing also offers improved bandwidth utilization and smoother output traffic compared to batching-based approaches.
To make continuous-time mixing tunable and predictable, the sender MUST select the mean delay for each hop and encode it into the Sphinx packet header. This allows top-level applications to balance latency and anonymity according to their requirements.
4.2 Mix Packet Format
A mix packet format defines how messages are encapsulated and routed through a mix network. It must ensure unlinkability between incoming and outgoing packets, prevent metadata leakage (e.g., path length, hop position, or payload size), and support uniform processing by mix nodes regardless of direction or content.
The Mix Protocol uses Sphinx packets to meet these goals. Each message is encrypted in layers corresponding to the selected mix path. As a packet traverses the network, each mix node removes one encryption layer to obtain the next hop and delay, while the remaining payload remains encrypted and indistinguishable.
Sphinx packets are fixed in size and bit-wise unlinkable. This ensures that they appear identical on the wire regardless of payload, direction, or route length, reducing opportunities for correlation based on packet size or format. Even mix nodes learn only the immediate routing information and the delay to be applied. They do not learn their position in the path or the total number of hops.
The packet format is resistant to tagging and replay attacks and is compact and efficient to process. Sphinx packets also include per-hop integrity checks and enforces a maximum path length. Together with a constant-size header and payload, this provides bounded protection against endless routing and malformed packet propagation.
It also supports anonymous and indistinguishable reply messages through Single-Use Reply Blocks (SURBs), although reply support is not implemented yet.
A complete specification of the Sphinx packet structure and fields is provided in [Section 6].
5. Protocol Overview
The Mix Protocol defines a decentralized, message-based routing layer that provides sender anonymity within the libp2p framework. It is agnostic to message content and semantics. Each message is treated as an opaque payload, wrapped into a Sphinx packet and routed independently through a randomly selected mix path. Along the path, each mix node removes one layer of encryption, adds a randomized delay, and forwards the packet to the next hop. This combination of layered encryption and per-hop delay provides resistance to traffic analysis and enables message-level unlinkability.
Unlike typical custom libp2p protocols, the Mix Protocol is stateless—it does not establish persistent streams, negotiate protocols, or maintain sessions. Each message is self-contained and routed independently.
The Mix Protocol sits above the transport layer and below the protocol layer in the libp2p stack. It provides a modular anonymity layer that other libp2p protocols MAY invoke selectively on a per-message basis.
Integration with other libp2p protocols is handled through external components that mediate between the origin protocol and the Mix Protocol instances. This enables selective anonymous routing without modifying protocol semantics or internal behavior.
The following subsections describe how the Mix Protocol integrates with origin protocols via the Mix Entry and Exit layers, how per-message anonymity is controlled through the mixify flag, the rationale for defining Mix as a protocol rather than a transport, and the end-to-end message interaction flow.
5.1 Integration with Origin Protocols
libp2p protocols that wish to anonymize messages MUST do so by integrating with the Mix Protocol via the Mix Entry and Exit layers.
-
The Mix Entry Layer receives messages to be mixified from an origin protocol and forwards them to the local Mix Protocol instance.
-
The Mix Exit Layer receives the final decrypted message from a Mix Protocol instance and forwards it to the appropriate origin protocol instance at the destination over a client-only connection.
This integration is external to the Mix Protocol and is not handled by mix nodes themselves.
5.2 Mixify Option
Some origin protocols may require selective anonymity, choosing to anonymize only certain messages based on their content, context, or destination. For example, a protocol may only anonymize messages containing sensitive metadata while delivering others directly to optimize performance.
To support this, origin protocols MAY implement a per-message mixify flag that indicates whether a message should be routed using the Mix Protocol.
- If the flag is set, the message MUST be handed off to the Mix Entry Layer for anonymous routing.
- If the flag is not set, the message SHOULD be routed using the origin protocol's default mechanism.
This design enables protocols to invoke the Mix Protocol only for selected messages, providing fine-grained control over privacy and performance trade-offs.
5.3 Why a Protocol, Not a Transport
The Mix Protocol is specified as a custom libp2p protocol rather than a transport to support selective anonymity while remaining compatible with libp2p's architecture.
As noted in Section 5.2, origin protocols may anonymize only specific messages based on content or context. Supporting such selective behavior requires invoking Mix on a per-message basis.
libp2p transports, however, are negotiated per peer connection and apply globally to all messages exchanged between two peers. Enabling selective anonymity at the transport layer would therefore require changes to libp2p's core transport semantics.
Defining Mix as a protocol avoids these constraints and offers several benefits:
- Supports selective invocation on a per-message basis.
- Works atop existing secure transports (e.g., QUIC, TLS) without requiring changes to the transport stack.
- Preserves a stateless, content-agnostic model focused on anonymous message routing.
- Integrates seamlessly with origin protocols via the Mix Entry and Exit layers.
This design preserves the modularity of the libp2p stack and allows Mix to be adopted without altering existing transport or protocol behavior.
5.4 Protocol Interaction Flow
A typical end-to-end Mix Protocol flow consists of the following three conceptual phases. Only the second phase—the anonymous routing performed by mix nodes—is part of the core Mix Protocol. The entry-side and exit-side integration steps are handled externally by the Mix Entry and Exit layers.
-
Entry-side Integration (Mix Entry Layer):
- The origin protocol generates a message and sets the
mixifyflag. - The message is passed to the Mix Entry Layer, which invokes the local Mix Protocol instance with the message, destination, and origin protocol codec as input.
- The origin protocol generates a message and sets the
-
Anonymous Routing (Core Mix Protocol):
- The Mix Protocol instance wraps the message in a Sphinx packet and selects a random mix path.
- Each mix node along the path:
- Processes the Sphinx packet by removing one encryption layer.
- Applies a delay and forwards the packet to the next hop.
- The final node in the path (exit node) decrypts the final layer, extracting the original plaintext message, destination, and origin protocol codec.
-
Exit-side Integration (Mix Exit Layer):
- The Mix Exit Layer receives the plaintext message, destination, and origin protocol codec.
- It routes the message to the destination origin protocol instance using a client-only connection.
The destination node does not need to support the Mix Protocol to receive or respond to anonymous messages.
The behavior described above represents the core Mix Protocol. In addition, the protocol supports a set of pluggable components that extend its functionality. These components cover areas such as node discovery, delay strategy, spam resistance, cover traffic generation, and incentivization. Some are REQUIRED for interoperability; others are OPTIONAL or deployment-specific. The next section describes each component.
5.5 Stream Management and Multiplexing
Each Mix Protocol message is routed independently, and forwarding it to the next hop requires opening a new libp2p stream using the Mix Protocol. This applies to both the initial Sphinx packet transmission and each hop along the mix path.
In high-throughput environments (e.g., messaging systems with continuous anonymous traffic), mix nodes may frequently communicate with a subset of mix nodes. Opening a new stream for each Sphinx packet in such scenarios can incur performance costs, as each stream setup requires a multistream handshake for protocol negotiation.
While libp2p supports multiplexing multiple streams over a single transport connection using stream muxers such as mplex and yamux, it does not natively support reusing the same stream over multiple message transmissions. However, stream reuse may be desirable in the mixnet setting to reduce overhead and avoid hitting per protocol stream limits between peers.
The lifecycle of streams, including their reuse, eviction, or pooling strategy, is outside the scope of this specification. It SHOULD be handled by the libp2p host, connection manager, or transport stack.
Mix Protocol implementations MUST NOT assume persistent stream availability and SHOULD gracefully fall back to opening a new stream when reuse is not possible.
6. Pluggable Components
Pluggable components define functionality that extends or configures the behavior of the Mix Protocol beyond its core message routing logic. Each component in this section falls into one of two categories:
- Required for interoperability and path construction (e.g., discovery, delay strategy).
- Optional or deployment-specific (e.g. spam protection, cover traffic, incentivization).
The following subsections describe the role and expected behavior of each.
6.1 Discovery
The Mix Protocol does not mandate a specific peer discovery mechanism. However, nodes participating in the mixnet MUST be discoverable so that other nodes can construct routing paths that include them.
To enable this, regardless of the discovery mechanism used, each mix node MUST make the following information available to peers:
- Indicate Mix Protocol support (e.g., using a
mixfield or bit). - Its X25519 public key for Sphinx encryption.
- One or more routable libp2p multiaddresses that identify the mix node's own network endpoints.
To support sender anonymity at scale, discovery mechanism SHOULD support unbiased random sampling from the set of live mix nodes. This enables diverse path construction and reduces exposure to adversarial routing bias.
While no existing mechanism provides unbiased sampling by default, Waku's ambient discovery—an extension over Discv5—demonstrates an approximate solution. It combines topic-based capability advertisement with periodic peer sampling. A similar strategy could potentially be adapted for the Mix Protocol.
A more robust solution would involve integrating capability-aware discovery directly into the libp2p stack, such as through extensions to libp2p-kaddht.
This would enable direct lookup of mix nodes based on protocol support and eliminate reliance on external mechanisms such as Discv5.
Such an enhancement remains exploratory and is outside the scope of this specification.
Regardless of the mechanism, the goal is to ensure mix nodes are discoverable and that path selection is resistant to bias and node churn.
6.2 Delay Strategy
The Mix Protocol uses per-hop delay as a core mechanism for achieving timing unlinkability. For each hop in the mix path, the sender MUST specify a mean delay value, which is embedded in the Sphinx packet header. The mix node at each hop uses this value to sample a randomized delay before forwarding the packet.
By default, delays are sampled from an exponential distribution. This supports continuous-time mixing, produces smooth output traffic, and enables tunable trade-offs between latency and anonymity. Importantly, it allows for unbounded anonymity sets: each packet may, with non-zero probability, be delayed arbitrarily long.
The delay strategy is considered pluggable, and other distributions MAY be used to match application-specific anonymity or performance requirements. However, any delay strategy MUST ensure that:
- Delays are sampled independently at each hop.
- Delay sampling introduces sufficient variability to obscure timing correlation between packet arrival and forwarding across multiple hops.
Strategies that produce deterministic or tightly clustered output delays are NOT RECOMMENDED, as they increase the risk of timing correlation. Delay strategies SHOULD introduce enough uncertainty to prevent adversaries from linking packet arrival and departure times, even when monitoring multiple hops concurrently.
6.3 Spam Protection
The Mix Protocol supports an optional, pluggable spam protection mechanism to defend the mixnet against denial-of-service (DoS) or resource exhaustion attacks. Without spam protection, malicious actors could flood mix nodes with valid Sphinx packets, exhausting computational resources or bandwidth of the mix nodes eventually making them unusable. Spam protection is critical to make the mixnet robust enough to be used in a production environment.
The spam protection mechanism is pluggable because different deployments may require different trade-offs between computational overhead, attack resistance, and Sybil resistance. For a specific mix deployment, the same spam protection mechanism MUST be followed by all participating mix nodes to ensure interoperability.
Two primary architectural approaches exist for integrating spam protection: sender-generated proofs and per-hop generated proofs. Each approach has distinct implications for computational overhead, latency, security properties, Sybil resistance, and implementation complexity.
Detailed design requirements, architectural approaches, packet structure considerations, node responsibilities, and recommendations are specified in Section 9.
Note that application-level or origin-protocol spam protection mechanisms are out of scope for the Mix Protocol and MUST be handled by the appropriate protocol layer itself. For example, Waku protocols use RLN (Rate Limiting Nullifiers) for spam protection, which is handled by Waku-Relay protocol, and the RLN proof would be included as part of the mix payload.
6.4 Cover Traffic
Cover traffic is an optional mechanism used to improve privacy by making the presence or absence of actual messages indistinguishable to observers. It helps achieve unobservability where a passive adversary cannot determine whether a node is sending real messages or not.
In the Mix Protocol, cover traffic is limited to loop messages—dummy Sphinx packets that follow a valid mix path and return to the originating node. These messages carry no application payload but are indistinguishable from real messages in structure, size, and routing behavior.
Cover traffic MAY be generated by either mix nodes or senders. The strategy for generating such traffic—such as timing and frequency—is pluggable and not specified in this document.
Implementations that support cover traffic SHOULD generate loop messages at randomized intervals. This helps mask actual sending behavior and increases the effective anonymity set. Timing strategies such as Poisson processes or exponential delays are commonly used, but the choice is left to the implementation.
In addition to enhancing privacy, loop messages can be used to assess network liveness or path reliability without requiring explicit acknowledgments.
6.5 Incentivization
The Mix Protocol supports a simple tit-for-tat model to discourage free-riding and promote mix node participation. In this model, nodes that wish to send anonymous messages using the Mix Protocol MUST also operate a mix node. This requirement ensures that participants contribute to the anonymity set they benefit from, fostering a minimal form of fairness and reciprocity.
This tit-for-tat model is intentionally lightweight and decentralized. It deters passive use of the mixnet by requiring each user to contribute bandwidth and processing capacity. However, it does not guarantee the quality of service provided by participating nodes. For example, it does not prevent nodes from running low-quality or misbehaving mix instances, nor does it deter participation by compromised or transient peers.
The Mix Protocol does not mandate any form of payment, token exchange, or accounting. More sophisticated economic models—such as stake-based participation, credentialed relay networks, or zero-knowledge proof-of-contribution systems—MAY be layered on top of the protocol or enforced via external coordination.
Additionally, network operators or application-layer policies MAY require nodes to maintain minimum uptime, prove their participation, or adhere to service-level guarantees.
While the Mix Protocol defines a minimum participation requirement, additional incentivization extensions are considered pluggable and experimental in this version of the specification. No specific mechanism is standardized.
7. Core Mix Protocol Responsibilities
This section defines the core routing behavior of the Mix Protocol, which all conforming implementations MUST support.
The Mix Protocol defines the logic for anonymously routing messages through the decentralized mix network formed by participating libp2p nodes. Each mix node MUST implement support for:
- initiating anonymous routing when invoked with a message.
- receiving and processing Sphinx packets when selected as a hop in a mix path.
These roles and their required behaviors are defined in the following subsections.
7.1 Protocol Identifier
The Mix Protocol is identified by the protocol string "/mix/1.0.0".
All Mix Protocol interactions occur over libp2p streams negotiated using this identifier. Each Sphinx packet transmission—whether initiated locally or forwarded as part of a mix path—involves opening a new libp2p stream to the next hop. Implementations MAY optimize performance by reusing streams where appropriate; see Section 5.5 for more details on stream management.
7.2 Initiation
A mix node initiates anonymous routing only when it is explicitly invoked with a message to be routed. As specified in Section 5.2, the decision to anonymize a message is made by the origin protocol. When anonymization is required, the origin protocol instance forwards the message to the Mix Entry Layer, which then passes the message to the local Mix Protocol instance for routing.
To perform message initiation, a mix node MUST:
- Select a random mix path.
- Assign a delay value for each hop and encode it into the Sphinx packet header.
- Wrap message in a Sphinx packet by applying layered encryption in reverse order of nodes in the selected mix path.
- Forward the resulting packet to the first mix node in the mix path using the Mix Protocol.
The Mix Protocol does not interpret message content or origin protocol context. Each invocation is stateless, and the implementation MUST NOT retain routing metadata or per-message state after the packet is forwarded.
7.3 Sphinx Packet Receiving and Processing
A mix node that receives a Sphinx packet is oblivious to its position in the path. The first hop is indistinguishable from other intermediary hops in terms of processing and behavior.
After decrypting one layer of the Sphinx packet, the node MUST inspect the routing information. If this layer indicates that the next hop is the final destination, the packet MUST be processed as an exit. Otherwise, it MUST be processed as an intermediary.
7.3.1 Intermediary Processing
To process a Sphinx packet as an intermediary, a mix node MUST:
- Extract the next hop address and associated delay from the decrypted packet.
- Wait for the specified delay.
- Forward the updated packet to the next hop using the Mix Protocol.
A mix node performing intermediary processing MUST treat each packet as stateless and self-contained.
7.3.2 Exit Processing
To process a Sphinx packet as an exit, a mix node MUST:
- Extract the plaintext message from the final decrypted packet.
- Forward the valid message to the Mix Exit Layer for delivery to the destination origin protocol instance.
The node MUST NOT retain decrypted content after forwarding.
The routing behavior described in this section relies on the use of Sphinx packets to preserve unlinkability and confidentiality across hops. The next section specifies their structure, cryptographic components, and construction.
8. Sphinx Packet Format
The Mix Protocol uses the Sphinx packet format to enable unlinkable, multi-hop message routing with per-hop confidentiality and integrity. Each message transmitted through the mix network is encapsulated in a Sphinx packet constructed by the initiating mix node. The packet is encrypted in layers such that each hop in the mix path can decrypt exactly one layer and obtain the next-hop routing information and forwarding delay, without learning the complete path or the message origin. Only the final hop learns the destination, which is encoded in the innermost routing layer.
Sphinx packets are self-contained and indistinguishable on the wire, providing strong metadata protection. Mix nodes forward packets without retaining state or requiring knowledge of the source or destination beyond their immediate routing target.
To ensure uniformity, each Sphinx packet consists of a fixed-length header and a payload that is padded to a fixed maximum size. Although the original message payload may vary in length, padding ensures that all packets are identical in size on the wire. This ensures unlinkability and protects against correlation attacks based on message size.
If a message exceeds the maximum supported payload size, it MUST be fragmented before being passed to the Mix Protocol. Fragmentation and reassembly are the responsibility of the origin protocol or the top-level application. The Mix Protocol handles only messages that do not require fragmentation.
The structure, encoding, and size constraints of the Sphinx packet are detailed in the following subsections.
8.1 Packet Structure Overview
Each Sphinx packet consists of three fixed-length header fields— $α$, $β$, and $γ$ —followed by a fixed-length encrypted payload $δ$. Together, these components enable per-hop message processing with strong confidentiality and integrity guarantees in a stateless and unlinkable manner.
- $α$ (Alpha): An ephemeral public value. Each mix node uses its private key and $α$ to derive a shared session key for that hop. This session key is used to decrypt and process one layer of the packet.
- $β$ (Beta): The nested encrypted routing information. It encodes the next hop address, the forwarding delay, integrity check $γ$ for the next hop, and the $β$ for subsequent hops. At the final hop, $β$ encodes the destination address and fixed-length zero padding to preserve uniform size.
- $γ$ (Gamma): A message authentication code computed over $β$ using the session key derived from $α$. It ensures header integrity at each hop.
- $δ$ (Delta): The encrypted payload. It consists of the message padded to a fixed maximum length and encrypted in layers corresponding to each hop in the mix path.
At each hop, the mix node derives the session key from $α$, verifies the header integrity using $γ$, decrypts one layer of $β$ to extract the next hop and delay, and decrypts one layer of $δ$. It then constructs a new packet with updated values of $α$, $β$, $γ$, and $δ$, and forwards it to the next hop. At the final hop, the mix node decrypts the innermost layer of $β$ and $δ$, which yields the destination address and the original application message respectively.
All Sphinx packets are fixed in size and indistinguishable on the wire. This uniform format, combined with layered encryption and per-hop integrity protection, ensures unlinkability, tamper resistance, and robustness against correlation attacks.
The structure and semantics of these fields, the cryptographic primitives used, and the construction and processing steps are defined in the following subsections.
8.2 Cryptographic Primitives
This section defines the cryptographic primitives used in Sphinx packet construction and processing.
-
Security Parameter: All cryptographic operations target a minimum of $κ = 128$ bits of security, balancing performance with resistance to modern attacks.
-
Elliptic Curve Group $\mathbb{G}$:
- Curve: Curve25519
- Notation: Let $g$ denote the canonical base point (generator) of $\mathbb{G}$.
- Purpose: Used for deriving Diffie–Hellman-style shared key at each hop using $α$.
- Representation: Small 32-byte group elements, efficient for both encryption and key exchange.
- Scalar Field: The curve is defined over the finite field $\mathbb{Z}_q$, where $q = 2^{252} + 27742317777372353535851937790883648493$. Ephemeral exponents used in Sphinx packet construction are selected uniformly at random from $\mathbb{Z}_q^*$, the multiplicative subgroup of $\mathbb{Z}_q$.
-
Hash Function:
- Construction: SHA-256
- Notation: The hash function is denoted by $H(\cdot)$ in subsequent sections.
-
Key Derivation Function (KDF):
- Purpose: To derive encryption keys, IVs, and MAC key from the shared session key at each hop.
- Construction: SHA-256 hash with output truncated to $128$ bits.
- Key Derivation: The KDF key separation labels (e.g.,
"aes_key","mac_key") are fixed strings and MUST be agreed upon across implementations.
-
Symmetric Encryption: AES-128 in Counter Mode (AES-CTR)
- Purpose: To encrypt $β$ and $δ$ for each hop.
- Keys and IVs: Each derived from the session key for the hop using the KDF.
-
Message Authentication Code (MAC):
- Construction: HMAC-SHA-256 with output truncated to $128$ bits.
- Purpose: To compute $γ$ for each hop.
- Key: Derived using KDF from the session key for the hop.
These primitives are used consistently throughout packet construction and decryption, as described in the following sections.
8.3 Packet Component Sizes
This section defines the size of each component in a Sphinx packet, deriving them from the security parameter and protocol parameters introduced earlier. All Sphinx packets MUST be fixed in length to ensure uniformity and indistinguishability on the wire. The serialized packet is structured as follows:
+--------+----------+--------+----------+
| α | β | γ | δ |
| 32 B | variable | 16 B | variable |
+--------+----------+--------+----------+
8.3.1 Header Field Sizes
The header consists of the fields $α$, $β$, and $γ$, totaling a fixed size per maximum path length:
-
$α$ (Alpha): 32 bytes The size of $α$ is determined by the elliptic curve group representation used (Curve25519), which encodes group elements as 32-byte values.
-
$β$ (Beta): $((t + 1)r + 1)κ$ bytes The size of $β$ depends on:
-
Maximum path length ($r$): The recommended value of $r=5$ balances bandwidth versus anonymity tradeoffs.
-
Combined address and delay width ($tκ$): The recommended $t=6$ accommodates standard libp2p relay multiaddress representations plus a 2-byte delay field. While the actual multiaddress and delay fields may be shorter, they are padded to $tκ$ bytes to maintain fixed field size. The structure and rationale for the $tκ$ block and its encoding are specified in Section 8.4.
Note: This expands on the original Sphinx packet format, which embeds a fixed $κ$-byte mix node identifier per hop in $β$. The Mix Protocol generalizes this to $tκ$ bytes to accommodate libp2p multiaddresses and forwarding delays while preserving the cryptographic properties of the original design.
-
Per-hop $γ$ size ($κ$) (defined below): Accounts for the integrity tag included with each hop's routing information.
Using the recommended value of $r=5$ and $t=6$, the resulting $β$ size is $576$ bytes. At the final hop, $β$ encodes the destination address in the first $tκ-2$ bytes and the remaining bytes are zero-padded.
-
-
$γ$ (Gamma): $16$ bytes The size of $γ$ equals the security parameter $κ$, providing a $κ$-bit integrity tag at each hop.
Thus, the total header length is:
$\begin{aligned} |Header| &= α + β + γ \\ &= 32 + ((t + 1)r + 1)κ + 16 \end{aligned}$
Notation: $|x|$ denotes the size (in bytes) of field $x$.
Using the recommended value of $r = 5$ and $t = 6$, the header size is:
$\begin{aligned} |Header| &= 32 + 576 + 16 \\ &= 624 \ bytes \end{aligned}$
8.3.2 Payload Size
This subsection defines the size of the encrypted payload $δ$ in a Sphinx packet.
$δ$ contains the application message, padded to a fixed maximum length to ensure all packets are indistinguishable on the wire. The size of $δ$ is calculated as:
$\begin{aligned} |δ| &= TotalPacketSize - HeaderSize \end{aligned}$
The recommended total packet size is $4608$ bytes, chosen to:
- Accommodate larger libp2p application messages, such as those commonly observed in Status chat using Waku (typically ~4KB payloads),
- Allow inclusion of additional data such as SURBs without requiring fragmentation,
- Maintain reasonable per-hop processing and bandwidth overhead.
This recommended total packet size of $4608$ bytes yields:
$\begin{aligned} Payload &= 4608 - 624 \\ &= 3984\ bytes \end{aligned}$
Implementations MUST account for payload extensions, such as SURBs, when determining the maximum message size that can be encapsulated in a single Sphinx packet. Details on SURBs are defined in [Section X.X].
The following subsection defines the padding and fragmentation requirements for ensuring this fixed-size constraint.
8.3.3 Padding and Fragmentation
Implementations MUST ensure that all messages shorter than the maximum payload size are padded before Sphinx encapsulation to ensure that all packets are indistinguishable on the wire. Messages larger than the maximum payload size MUST be fragmented by the origin protocol or top-level application before being passed to the Mix Protocol. Reassembly is the responsibility of the consuming application, not the Mix Protocol.
8.3.4 Anonymity Set Considerations
The fixed maximum packet size is a configurable parameter. Protocols or applications that choose to configure a different packet size (either larger or smaller than the default) MUST be aware that using unique or uncommon packet sizes can reduce their effective anonymity set to only other users of the same size. Implementers SHOULD align with widely used defaults to maximize anonymity set size.
Similarly, parameters such as $r$ and $t$ are configurable. Changes to these parameters affect header size and therefore impact payload size if the total packet size remains fixed. However, if such changes alter the total packet size on the wire, the same anonymity set considerations apply.
When spam protection is enabled (see Section 9), the total wire size may increase depending on the integration approach. For per-hop generated proofs, the proof is appended after the Sphinx packet, and the proof size MUST be fixed to maintain packet indistinguishability.
The following subsection defines how the next-hop or destination address and forwarding delay are encoded within $β$ to enable correct routing and mixing behavior.
8.4 Address and Delay Encoding
Each hop's $β$ includes a fixed-size block containing the next-hop address and the forwarding delay, except for the final hop, which encodes the destination address and a delay-sized zero padding. This section defines the structure and encoding of that block.
The combined address and delay block MUST be exactly $tκ$ bytes in length, as defined in Section 8.3.1, regardless of the actual address or delay values. The first $(tκ - 2)$ bytes MUST encode the address, and the final $2$ bytes MUST encode the forwarding delay. This fixed-length encoding ensures that packets remain indistinguishable on the wire and prevents correlation attacks based on routing metadata structure.
Implementations MAY use any address and delay encoding format agreed upon by all participating mix nodes, as long as the combined length is exactly $tκ$ bytes. The encoding format MUST be interpreted consistently by all nodes within a deployment.
For interoperability, a recommended default encoding format involves:
-
Encoding the next-hop or destination address as a libp2p multi-address:
- To keep the address block compact while allowing relay connectivity, each mix node is limited to one IPv4 circuit relay multiaddress. This ensures that most nodes can act as mix nodes, including those behind NATs or firewalls.
- In libp2p terms, this combines transport addresses with multiple peer identities to form an address that describes a relay circuit:
/ip4/<ipv4>/tcp/<port>/p2p/<relayPeerID>/p2p-circuit/p2p/<relayedPeerID>Variants may include directly reachable peers and transports such as/quic-v1, depending on the mix node's supported stack. - IPv6 support is deferred, as it adds $16$ bytes just for the IP field.
- Future revisions may extend this format to support IPv6 or DNS-based multiaddresses.
With these constraints, the recommended encoding layout is:
- IPv4 address (4 bytes)
- Protocol identifier e.g., TCP or QUIC (1 byte)
- Port number (2 bytes)
- Peer IDs (39 bytes, post-Base58 decoding)
-
Encoding the forwarding delay as an unsigned 16-bit integer (2 bytes), representing the mean delay in milliseconds for the configured delay distribution, using big endian network byte order. The delay distribution is pluggable, as defined in Section 6.2.
If the encoded address or delay is shorter than its respective allocated field, it MUST be padded with zeros. If it exceeds the allocated size, it MUST be rejected or truncated according to the implementation policy.
Note: Future versions of the Mix Protocol may support address compression by encoding only the peer identifier and relying on external peer discovery mechanisms to retrieve full multiaddresses at runtime. This would allow for more compact headers and greater address flexibility, but requires fast and reliable lookup support across deployments. This design is out of scope for the current version.
With the field sizes and encoding conventions established, the next section describes how a mix node constructs a complete Sphinx packet when initiating the Mix Protocol.
8.5 Packet Construction
This section defines how a mix node constructs a Sphinx packet when initiating the Mix Protocol on behalf of a local origin protocol instance. The construction process wraps the message in a sequence of encryption layers—one for each hop—such that only the corresponding mix node can decrypt its layer and retrieve the routing instructions for that hop.
8.5.1 Inputs
To initiate the Mix Protocol, the origin protocol instance submits a message to the Mix Entry Layer on the same node. This layer forwards it to the local Mix Protocol instance, which constructs a Sphinx packet using the following REQUIRED inputs:
- Application message: The serialized message provided by the origin protocol instance. The Mix Protocol instance attaches one or two SURBs prior to encapsulating the message in the Sphinx packet. The initiating node MUST ensure that the resulting payload size does not exceed the maximum supported size defined in Section 8.3.2.
- Origin protocol codec: The libp2p protocol string corresponding to the origin protocol instance. This is included in the payload so that the exit node can route the message to the intended destination protocol after decryption.
- Mix Path length $L$: The number of mix nodes to include in the path. The mix path MUST consist of at least three hops, each representing a distinct mix node.
- Destination address $Δ$: The routing address of the intended recipient of the message. This address is encoded in $(tκ - 2)$ bytes as defined in Section 8.4 and revealed only at the last hop.
8.5.2 Construction Steps
This subsection defines how the initiating mix node constructs a complete Sphinx packet using the inputs defined in Section 8.5.1. The construction MUST follow the cryptographic structure defined in Section 8.1, use the primitives specified in Section 8.2, and adhere to the component sizes and encoding formats from Section 8.3 and Section 8.4.
The construction MUST proceed as follows:
-
Prepare Application Message
- Attach one or more SURBs, if required. Their format and processing are specified in [Section X.X].
- Append the origin protocol codec in a format that enables the exit node to reliably extract it during parsing. A recommended encoding approach is to prefix the codec string with its length, encoded as a compact varint field limited to two bytes. Regardless of the scheme used, implementations MUST agree on the format within a deployment to ensure deterministic decoding.
- Pad the result to the maximum application message length of $3968$ bytes using a deterministic padding scheme. This value is derived from the fixed payload size in Section 8.3.2 ($3984$ bytes) minus the security parameter $κ = 16$ bytes defined in Section 8.2. The chosen scheme MUST yield a fixed-size padded output and MUST be consistent across all mix nodes to ensure correct interpretation during unpadding. For example, schemes that explicitly encode the padding length and prepend zero-valued padding bytes MAY be used.
- Let the resulting message be $m$.
-
Select A Mix Path
- First obtain an unbiased random sample of live, routable mix nodes using some discovery mechanism. The choice of discovery mechanism is deployment-specific as defined in Section 6.1. The discovery mechanism MUST be unbiased and provide, at a minimum, the multiaddress and X25519 public key of each mix node.
- From this sample, choose a random mix path of length $L \geq 3$. As defined in Section 2, a mix path is a non-repeating sequence of mix nodes.
- For each hop $i \in {0 \ldots L-1}$:
- Retrieve the multiaddress and corresponding X25519 public key $y_i$ of the $i$-th mix node.
- Encode the multiaddress in $(tκ - 2)$ bytes as defined in Section 8.4. Let the resulting encoded multiaddress be $\mathrm{addr_i}$.
-
Wrap Plaintext Payload In Sphinx Packet
a. Compute Ephemeral Secrets
- Choose a random private exponent $x \in_R \mathbb{Z}_q^*$.
- Initialize:
$
\begin{aligned} α_0 &= g^x \\ s_0 &= y_0^x \\ b_0 &= H(α_0\ |\ s_0) \end{aligned}$ - For each hop $i$ (from $1$ to $L-1$), compute:
$
\begin{aligned} α_i &= α_{i-1}^{b_{i-1}} \\ s_i &= y_{i}^{x\prod_{\text{j=0}}^{\text{i-1}} b_{j}} \\ b_i &= H(α_i\ |\ s_i) \end{aligned}$
Note that the length of $α_i$ is $32$ bytes, $0 \leq i \leq L-1$ as defined in Section 8.3.1.
b. Compute Per-Hop Filler Strings Filler strings are encrypted strings that are appended to the header during encryption. They ensure that the header length remains constant across hops, regardless of the position of a node in the mix path.
To compute the sequence of filler strings, perform the following steps:
-
Initialize $Φ_0 = \epsilon$ (empty string).
-
For each $i$ (from $1$ to $L-1$):
-
Derive per-hop AES key and IV:
$
\begin{array}{l} Φ_{\mathrm{aes\_key}_{i-1}} = \mathrm{KDF}(\text{"aes\_key"} \mid s_{i-1})\\ Φ_{\mathrm{iv}_{i-1}} = \mathrm{KDF}(\text{"iv"} \mid s_{i-1}) \end{array}$ -
Compute the filler string $Φ_i$ using $\text{AES-CTR}^\prime_i$, which is AES-CTR encryption with the keystream starting from index $((t+1)(r-i)+t+2)κ$ :
$
\begin{array}{l} Φ_i = \mathrm{AES\text{-}CTR}'_i\bigl(Φ_{\mathrm{aes\_key}_{i-1}}, Φ_{\mathrm{iv}_{i-1}}, Φ_{i-1} \mid 0_{(t+1)κ} \bigr), \\ \text{where } 0_x \text{ defines the string of } 0 \text{ bits of length } x\text{.} \end{array}$
-
Note that the length of $Φ_i$ is $(t+1)iκ$, $0 \leq i \leq L-1$.
c. Construct Routing Header The routing header as defined in Section 8.1 is the encrypted structure that carries the forwarding instructions for each hop. It ensures that a mix node can learn only its immediate next hop and forwarding delay without inferring the full path.
Filler strings computed in the previous step are appended during encryption to ensure that the header length remains constant across hops. This prevents a node from distinguishing its position in the path based on header size.
To construct the routing header, perform the following steps for each hop $i = L-1$ down to $0$, recursively:
-
Derive per-hop AES key, MAC key, and IV:
$
\begin{array}{l} β_{\mathrm{aes\_key}_i} = \mathrm{KDF}(\text{"aes\_key"} \mid s_i)\\ \mathrm{mac\_key}_i = \mathrm{KDF}(\text{"mac\_key"} \mid s_{i})\\ β_{\mathrm{iv}_i} = \mathrm{KDF}(\text{"iv"} \mid s_i) \end{array}$ -
Set the per hop two-byte encoded delay $\mathrm{delay}_i$ as defined in Section 8.4:
- If final hop (i.e., $i = L - 1$), encode two byte zero padding.
- For all other hop $i$, $i < L - 1$, select the mean forwarding delay for the delay strategy configured by the application, and encode it as a two-byte value. The delay strategy is pluggable, as defined in Section 6.2.
-
Using the derived keys and encoded forwarding delay, compute the nested encrypted routing information $β_i$:
-
If $i = L-1$ (i.e., exit node):
$
\begin{array}{l} β_i = \mathrm{AES\text{-}CTR}\bigl(β_{\mathrm{aes\_key}_i}, β_{\mathrm{iv}_i}, Δ \mid \mathrm{delay}_i \mid 0_{((t+1)(r-L)+2)κ} \bigr) \bigm| Φ_{L-1} \end{array}$ -
Otherwise (i.e., intermediary node):
$
\begin{array}{l} β_i = \mathrm{AES\text{-}CTR}\bigl(β_{\mathrm{aes\_key}_i}, β_{\mathrm{iv}_i}, \mathrm{addr}_{i+1} \mid \mathrm{delay}_i \mid γ_{i+1} \mid β_{i+1 \, [0 \ldots (r(t+1) - t)κ - 1]} \bigr),\\ \text{where notation } X_{[a \ldots b]} \text{ denotes the substring of } X \text{ from byte offset } a \text{ to } b\text{, inclusive, using zero-based indexing.} \end{array}$
Note that the length of $\beta_i$ is $(r(t+1)+1)κ$, $0 \leq i \leq L-1$ as defined in Section 8.3.1.
-
Compute the message authentication code $γ_i$:
$
\begin{array}{l} γ_i = \mathrm{HMAC\text{-}SHA\text{-}256}\bigl(\mathrm{mac\_key}_i, β_i \bigr) \end{array}$
Note that the length of $\gamma_i$ is $κ$, $0 \leq i \leq L-1$ as defined in Section 8.3.1.
-
d. Encrypt Payload The encrypted payload $δ$ contains the message $m$ defined in Step 1, prepended with a $κ$-byte string of zeros. It is encrypted in layers such that each hop in the mix path removes exactly one layer using the per-hop session key. This ensures that only the final hop (i.e., the exit node) can fully recover $m$, validate its integrity, and forward it to the destination. To compute the encrypted payload, perform the following steps for each hop $i = L-1$ down to $0$, recursively:
-
Derive per-hop AES key and IV:
$
\begin{array}{l} δ_{\mathrm{aes\_key}_i} = \mathrm{KDF}(\text{"δ\_aes\_key"} \mid s_i)\\ δ_{\mathrm{iv}_i} = \mathrm{KDF}(\text{"δ\_iv"} \mid s_i) \end{array}$ -
Using the derived keys, compute the encrypted payload $δ_i$:
-
If $i = L-1$ (i.e., exit node):
$
\begin{array}{l} δ_i = \mathrm{AES\text{-}CTR}\bigl(δ_{\mathrm{aes\_key}_i}, δ_{\mathrm{iv}_i}, 0_{κ} \mid m \bigr) \end{array}$ -
Otherwise (i.e., intermediary node):
$
\begin{array}{l} δ_i = \mathrm{AES\text{-}CTR}\bigl(δ_{\mathrm{aes\_key}_i}, δ_{\mathrm{iv}_i}, δ_{i+1} \bigr) \end{array}$
Note that the length of $\delta_i$, $0 \leq i \leq L-1$ is $|m| + κ$ bytes.
Given that the derived size of $\delta_i$ is $3984$ bytes as defined in Section 8.3.2, this allows $m$ to be of length $3984-16 = 3968$ bytes as defined in Step 1.
-
e. Assemble Final Packet The final Sphinx packet is structured as defined in Section 8.3:
α = α_0 // 32 bytes β = β_0 // 576 bytes γ = γ_0 // 16 bytes δ = δ_0 // 3984 bytesSerialize the final packet using a consistent format and prepare it for transmission.
f. Transmit Packet
- Sample a randomized delay from the same distribution family used for per-hop delays (in Step 3.e.) with an independently chosen mean.
This delay prevents timing correlation when multiple Sphinx packets are sent in quick succession. Such bursts may occur when an upstream protocol fragments a large message, or when several messages are sent close together.
- After the randomized delay elapses, transmit the serialized packet to the first hop via a libp2p stream negotiated under the
"/mix/1.0.0"protocol identifier.
Implementations MAY reuse an existing stream to the first hop as described in Section 5.5, if doing so does not introduce any observable linkability between the packets.
Once a Sphinx packet is constructed and transmitted by the initiating node, it is processed hop-by-hop by the remaining mix nodes in the path.
Each node receives the packet over a libp2p stream negotiated under the "/mix/1.0.0" protocol.
The following subsection defines the per-hop packet handling logic expected of each mix node, depending on whether it acts as an intermediary or an exit.
8.6 Sphinx Packet Handling
Each mix node MUST implement a handler for incoming data received over libp2p streams negotiated under the "/mix/1.0.0" protocol identifier.
The incoming stream may have been reused by the previous hop, as described in Section 5.5.
Implementations MUST ensure that packet handling remains stateless and unlinkable, regardless of stream reuse.
Upon receiving the stream payload, the node MUST interpret it as a Sphinx packet and process it in one of two roles—intermediary or exit— as defined in Section 7.3. This section defines the exact behavior for both roles.
8.6.1 Shared Preprocessing
Upon receiving a stream payload over a libp2p stream, the mix node MUST first deserialize it into a Sphinx packet (α, β, γ, δ).
The deserialized fields MUST match the sizes defined in Section 8.5.2 step 3.e., and the total packet length MUST match the fixed packet size defined in Section 8.3.2.
If the stream payload does not match the expected length, it MUST be discarded and the processing MUST terminate.
After successful deserialization, the mix node performs the following steps:
-
Derive Session Key
Let $x \in \mathbb{Z}_q^*$ denote the node's X25519 private key. Compute the shared secret $s = α^x$.
-
Check for Replays
- Compute the tag $H(s)$.
- If the tag exists in the node's table of previously seen tags, discard the packet and terminate processing.
- Otherwise, store the tag in the table.
The table MAY be flushed when the node rotates its private key. Implementations SHOULD perform this cleanup securely and automatically.
-
Check Header Integrity
-
Derive the MAC key from the session secret $s$:
$
\begin{array}{l} \mathrm{mac\_key} = \mathrm{KDF}(\text{"mac\_key"} \mid s) \end{array}$ -
Verify the integrity of the routing header:
$
\begin{array}{l} γ \stackrel{?}{=} \mathrm{HMAC\text{-}SHA\text{-}256}(\mathrm{mac\_key}, β) \end{array}$If the check fails, discard the packet and terminate processing.
-
-
Decrypt One Layer of the Routing Header
-
Derive the routing header AES key and IV from the session secret $s$:
$
\begin{array}{l} β_{\mathrm{aes\_key}} = \mathrm{KDF}(\text{"aes\_key"} \mid s)\\ β_{\mathrm{iv}} = \mathrm{KDF}(\text{"iv"} \mid s) \end{array}$ -
Decrypt the suitably padded $β$ to obtain the routing block $B$ for this hop:
$
\begin{array}{l} B = \mathrm{AES\text{-}CTR}\bigl(β_{\mathrm{aes\_key}}, β_{\mathrm{iv}}, β \mid 0_{(t+1)κ} \bigr) \end{array}$This step removes the filler string appended during header encryption in Section 8.5.2 step 3.c. and yields the plaintext routing information for this hop.
The routing block $B$ MUST be parsed according to the rules and field layout defined in Section 8.6.2 to determine whether the current node is an intermediary or the exit.
-
-
Decrypt One Layer of the Payload
-
Derive the payload AES key and IV from the session secret $s$:
$
\begin{array}{l} δ_{\mathrm{aes\_key}} = \mathrm{KDF}(\text{"δ\_aes\_key"} \mid s)\\ δ_{\mathrm{iv}} = \mathrm{KDF}(\text{"δ\_iv"} \mid s) \end{array}$ -
Decrypt one layer of the encrypted payload $δ$:
$
\begin{array}{l} δ' = \mathrm{AES\text{-}CTR}\bigl(δ_{\mathrm{aes\_key}}, δ_{\mathrm{iv}}, δ \bigr) \end{array}$
The resulting $δ'$ is the decrypted payload for this hop and MUST be interpreted depending on the parsed node's role, determined by $B$, as described in Section 8.6.2.
-
8.6.2 Node Role Determination
As described in Section 8.6.1, the mix node obtains the routing block $B$ by decrypting one layer of the encrypted header $β$.
At this stage, the node MUST determine whether it is an intermediary or the exit based on the prefix of $B$, in accordance with the construction of $β_i$ defined in Section 8.5.2 step 3.c.:
- If the first $(tκ - 2)$ bytes of $B$ contain a nonzero-encoded address, immediately followed by a two-byte zero delay, and then $((t + 1)(r - L) + t + 2)κ$ bytes of all-zero padding, process the packet as an exit.
- Otherwise, process the packet as an intermediary.
The following subsections define the precise behavior for each case.
8.6.3 Intermediary Processing
Once the node determines its role as an intermediary following the steps in Section 8.6.2, it MUST perform the following steps to interpret routing block $B$ and decrypted payload $δ'$ obtained in Section 8.6.1:
-
Parse Routing Block
Parse the routing block $B$ according to the $β_i$, $i \neq L - 1$ construction defined in Section 8.5.2 step 3.c.:
-
Extract the first $(tκ - 2)$ bytes of $B$ as the next hop address $\mathrm{addr}$
$
\begin{array}{l} \mathrm{addr} = B_{[0\ldots(tκ - 2) - 1]} \end{array}$ -
Extract next two bytes as the mean delay $\mathrm{delay}$
$
\begin{array}{l} \mathrm{delay} = B_{[(tκ - 2)\ldots{tκ} - 1]} \end{array}$ -
Extract next $κ$ bytes as the next hop MAC $γ'$
$
\begin{array}{l} γ' = B_{[tκ\ldots(t + 1)κ - 1]} \end{array}$ -
Extract next $(r(t+1)+1)κ$ bytes as the next hop routing information $β'$
$
\begin{array}{l} β' = B_{[(t + 1)κ\ldots(r(t +1 ) + t + 2)κ - 1]} \end{array}$
If parsing fails, discard the packet and terminate processing.
-
-
Update Header Fields
Update the header fields according to the construction steps defined in Section 8.5.2:
-
Compute the next hop ephemeral public value $α'$, deriving the blinding factor $b$ from the shared secret $s$ computed in Section 8.6.1 step 1.
$
\begin{aligned} b &= H(α\ |\ s) \\ α' &= α^b \end{aligned}$ -
Use the $β'$ and $γ'$ extracted in Step 1. as the routing information and MAC respectively in the outgoing packet.
-
-
Update Payload
Use the decrypted payload $δ'$ computed in Section 8.6.1 step 5. as the payload in the outgoing packet.
-
Assemble Final Packet The final Sphinx packet is structured as defined in Section 8.3:
α = α' // 32 bytes β = β' // 576 bytes γ = γ' // 16 bytes δ = δ' // 3984 bytesSerialize $α'$ using the same format used in Section 8.5.2. The remaining fields are already fixed-length buffers and do not require further transformation.
-
Transmit Packet
-
Interpret the $\mathrm{addr}$ and $\mathrm{delay}$ extracted in Step 1. according to the encoding format used during construction in Section 8.5.2 Step 3.c.
-
Sample the actual forwarding delay from the configured delay distribution, using the decoded mean delay value as the distribution parameter.
-
After the forwarding delay elapses, transmit the serialized packet to the next hop address via a libp2p stream negotiated under the
"/mix/1.0.0"protocol identifier.
Implementations MAY reuse an existing stream to the next hop as described in Section 5.5, if doing so does not introduce any observable linkability between the packets.
-
-
Erase State
-
After transmission, erase all temporary values securely from memory, including session keys, decrypted content, and routing metadata.
-
If any error occurs—such as malformed header, invalid delay, or failed stream transmission—silently discard the packet and do not send any error response.
-
8.6.4 Exit Processing
Once the node determines its role as an exit following the steps in Section 8.6.2, it MUST perform the following steps to interpret routing block $B$ and decrypted payload $δ'$ obtained in Section 8.6.1:
-
Parse Routing Block
Parse the routing block $B$ according to the $β_i$, $i = L - 1$ construction defined in Section 8.5.2 step 3.c.:
-
Extract first $(tκ - 2)$ bytes of $B$ as the destination address $Δ$
$
\begin{array}{l} Δ = B_{[0\ldots(tκ - 2) - 1]} \end{array}$
-
-
Recover Padded Application Message
-
Verify the decrypted payload $δ'$ computed in Section 8.6.1 step 5.:
$
\begin{array}{l} δ'_{[0\ldots{κ} - 1]} \stackrel{?}{=} 0_{κ} \end{array}$
If the check fails, discard $δ'$ and terminate processing.
-
Extract rest of the bytes of $δ'$ as the padded application message $m$:
$
\begin{array}{l} m = δ'_{[κ\ldots]},\; \; \; \text{where notation } X_{[a \ldots]} \text{ denotes the substring of } X \text{ from byte offset } a \text{ to the end of the string using zero-based indexing.} \end{array}$
-
-
Extract Application Message
Interpret recovered $m$ according to the construction steps defined in Section 8.5.2 step 1.:
-
First, unpad $m$ using the deterministic padding scheme defined during construction.
-
Next, parse the unpadded message deterministically to extract:
- zero or more SURBs
- the origin protocol codec
- the serialized application message
-
Extract SURBs , and identify protocol codec , consistent with the format and extensions applied by the initiator. The application message itself MUST remain serialized.
-
If parsing fails at any stage, discard $m$ and terminate processing.
-
-
Handoff to Exit Layer
-
Hand off the serialized application message, the origin protocol codec, and destination address $Δ$ (extracted in step 1.) to the local Exit layer for further processing and delivery.
-
The Exit Layer is responsible for establishing a client-only connection and forwarding the message to the destination. Implementations MAY reuse an existing stream to the destination, if doing so does not introduce any observable linkability between forwarded messages.
-
9. Spam Protection Architecture
This section provides detailed specifications for integrating a spam protection mechanism with the Mix Protocol. As introduced in Section 6.3, Mix protocol supports a pluggable spam protection mechanism. This section defines design requirements, architectural approaches, packet structure modifications, and node responsibilities for spam protection. This section also defines the interfaces between the Mix Protocol and spam protection mechanisms.
9.1 Spam Protection Mechanism Requirements
Any spam protection mechanism integrated with the Mix Protocol MUST satisfy the following requirements:
-
Each mix node in the path MUST verify proofs before applying delay and forwarding or taking further action (in the case of exit nodes).
-
Spam protection data and verification MUST NOT enable linking or correlating packets across hops. Therefore, spam protection proofs MUST be unique per hop.
-
Spam protection proofs MUST NOT be reusable across different packets or hops. Each hop SHOULD be able to detect replayed or forged proofs (e.g., via cryptographic binding to packet-specific or hop-specific data).
-
Proofs SHOULD be bound to unique signals per message that cannot be known in advance, preventing adversaries from pre-computing large batches of proofs offline for spam attacks.
-
Verification overhead MUST be low to minimise per-hop delays. Otherwise, the spam protection mechanism would add substantial delay at each hop.
-
Proof generation and verification methods MUST preserve unlinkability of the sender and MUST NOT leak any additional metadata about the sender or node.
-
The mechanism SHOULD provide a way to punish spammers or detect misuse.
9.2 Integration Architecture
Two primary architectural approaches exist for integrating spam protection with the Mix Protocol, each with distinct trade-offs. This section elaborates on each approach and also lists down trade-offs and provides a comparison which helps in making a decision for deployments.
9.2.1 Sender-Generated Proofs
In this approach, the sender generates spam protection proofs for all hops in the mix path during Sphinx packet construction. Proofs are embedded within each hop's routing block in the encrypted header $\beta$.
9.2.1.1 How It Works
During Sphinx packet construction, as defined in Section 8.5.2, the sender follows the standard construction process (computing ephemeral secrets in step 3.a and encrypted payloads in step 3.d). After steps 3.a and 3.d, the sender additionally performs the following spam protection steps:
-
Generate and Embed Proofs: For each hop $i$ in the path:
a. The sender computes the spam protection proof cryptographically bound to $\delta_{i+1}$ (the decrypted payload that hop $i$ will compute). This binding ensures proofs are path-specific and tied to specific encrypted message content, preventing proof extraction and reuse.
b. The proof and related metadata required for verification are embedded in hop $i$'s routing block within $\beta_i$ during header construction (step 3.c)
-
Verification at Each Hop: During Sphinx processing, hop $i$ decrypts $\beta_i$ to extract the proof and metadata, decrypts $\delta$ to get $\delta'$, then verifies the proof matches $\delta'$ using the provided metadata
9.2.1.2 Advantages
- Only the sender performs the expensive proof generation. Intermediate nodes only verify proofs, which is less expensive and also reduces latency per hop.
- Each hop's proof is encrypted in a separate layer of $\beta$, making proofs unique per hop and cryptographically isolated.
- Aligned with Sphinx philosophy wherein complexity is at the sender while intermediate nodes perform lightweight operations.
9.2.1.3 Disadvantages
- Sender must generate $L$ proofs, which can be expensive.
- Each hop's routing block must include spam protection data, increasing overall header and packet size (see impact analysis below).
- Sender must know the full path before generating proofs and cannot reuse the proofs if two different paths are chosen for the same message.
- Proofs can only be verified AFTER expensive Sphinx processing operations (session key derivation, replay checking, header integrity verification, and decryption), since they are encrypted within the $\beta$ field and bound to the decrypted payload state $\delta'$. Deployments using this approach SHOULD augment with additional network-level protections (connection rate limiting, localized peer reputation) to defend against attacks that can lead to draining node's resources.
- This approach does not inherently provide Sybil resistance as intermediate nodes do not generate any proof using their credentials. A separate Sybil resistance mechanism (such as membership proofs) would be required, which would add additional data to the packet header.
9.2.1.4 Impact on Header Size
If spam protection proofs are embedded in $\beta$, the per-hop routing block size increases from $(t+1)\kappa$ bytes to $(t+1)\kappa + |\mathrm{spam_proof}|$ bytes.
The total $\beta$ size becomes: $(r(t+1) + 1)\kappa + r \times |\mathrm{spam_proof}|$ bytes, where $r$ is the maximum path length.
With the current recommended values ($r=5$, $t=6$, $\kappa=16$):
- Original header size: $624$ bytes
- Header size increase: $5 \times |\mathrm{spam_proof}|$ bytes
Note that $|\mathrm{spam_proof}|$ includes both the proof and all verification metadata.
e.g. If using RLN, where proof + metadata ~= $300$ bytes, Header size becomes: $624 + 1500 = 2124$ bytes
9.2.2 Per-Hop Generated Proofs
In this approach, each mix node generates a new spam protection proof for the next hop after verifying proof for the incoming packet.
The proof $\sigma$ is appended after the Sphinx packet, forming the wire format: SphinxPacket || σ.
The proof MAY be bound to the complete outgoing Sphinx packet $(\alpha' | \beta' | \gamma' | \delta')$.
9.2.2.1 How It Works
- The sender generates an initial spam protection proof $\sigma$ for the first hop and appends it after the Sphinx packet. The proof MAY be cryptographically bound to the Sphinx packet and include any verification metadata required by the spam protection mechanism.
- The first hop extracts and verifies $\sigma$ before processing the Sphinx packet.
- After successful verification and Sphinx processing, the hop generates a new proof $\sigma'$ for the next hop bound to the transformed packet.
- The updated packet is forwarded to the next hop.
- This process repeats at each intermediate hop until the packet reaches the exit.
9.2.2.2 Advantages
- Sender only generates one initial proof instead of $L$ proofs.
- Spam protection data has less overhead on packet header size.
- Proofs can be verified BEFORE expensive Sphinx processing operations (session key derivation, header integrity verification, and decryption). This provides better DoS protection by allowing nodes to reject invalid packets earlier, before performing costly cryptographic operations.
- If a membership-based spam protection mechanism is used (e.g., Rate Limiting Nullifiers), the same mechanism can provide Sybil resistance. Nodes must prove membership or ownership of resources at each hop, making it economically infeasible to operate large numbers of Sybil nodes.
9.2.2.3 Disadvantages
- Each node must generate fresh proofs, adding additional latency and processing cost at each hop. The proof generation latency can be overcome by doing pre-computation of proofs but the proofs would not be bound to message contents rather some other means to add uniqueness.
- When using rate-limiting mechanisms (such as RLN), intermediate nodes may exhaust their rate limits due to random path selection by multiple independent senders, causing legitimate packets to be dropped even when no individual sender is misbehaving. This creates availability bottlenecks at popular nodes and unpredictable message delivery. This can be mitigated by assigning higher rate-limits to intermediate or popular nodes(e.g. tied to reputation).
9.2.2.4 Impact on Packet Size
The proof $\sigma$ is appended after the Sphinx packet, increasing total wire size to $4608 + |\sigma|$ bytes. The internal Sphinx packet structure remains unchanged.
Note that $|\sigma|$ includes both the proof and all verification metadata. The proof size MUST be fixed for a given spam protection mechanism to ensure all packets remain indistinguishable on the wire (see Section 8.3.4).
e.g. If using RLN, where proof + metadata ~= $300$ bytes, total wire size becomes: $4608 + 300 = 4908$ bytes
9.2.3 Comparison
The following table provides a brief comparison between both integration approaches.
| Aspect | Sender-Generated Proofs | Per-Hop Generated Proofs |
|---|---|---|
| DoS protection | Weaker (verify after Sphinx decryption) | Stronger (verify before Sphinx decryption) |
| Sender burden | High (generates $L$ proofs) | Low (generates 1 proof) |
| Per-hop computational overhead | Low (verify only) | High (verify + generate) |
| Per-hop latency | Minimal (fast verification) | Higher (mitigated with pre-computation) |
| Total end-to-end latency | Lower | Higher (mitigated with pre-computation) |
| Sybil resistance | Requires separate mechanism | Can be integrated |
| Packet size increase | $L \times \mathrm{spam_proof}$ | $|\sigma|$ |
Separate specifications defining concrete spam protection mechanisms SHOULD specify recommended approaches and provide detailed integration instructions.
9.3 Node Responsibilities
In addition to the standard Sphinx processing responsibilities described in Section 8, nodes MUST implement the following spam protection responsibilities based on the chosen architectural approach.
9.3.1 For Sender-Generated Proofs
-
Sender nodes: MUST generate spam protection proofs for each hop during packet construction as described in Section 9.2.1.1. The sender MUST NOT include any identifying information in the proofs.
-
Intermediate and exit nodes: MUST verify the spam protection proof during packet processing as described in Section 9.2.1.1. Verification occurs after decrypting $\beta$ and $\delta$ during the shared preprocessing steps in Section 8.6.1. If verification fails, nodes MUST discard the packet and MAY apply penalties or rate-limiting measures.
9.3.2 For Per-Hop Generated Proofs
-
Sender nodes: MUST generate the initial spam protection proof $\sigma$ and append it after the Sphinx packet as described in Section 9.2.2.1. The proof MUST NOT contain identifying information.
-
Intermediate nodes: MUST extract and verify the incoming proof $\sigma$ BEFORE any Sphinx processing. If verification fails, nodes MUST discard the packet and MAY apply penalties or rate-limiting measures. After verification and Sphinx processing, nodes MUST generate a fresh unlinkable proof $\sigma'$ and append it to the transformed packet before forwarding.
-
Exit nodes: MUST extract and verify the incoming proof $\sigma$ before Sphinx processing. Exit nodes do not generate new proofs.
9.4 Anonymity and Security Considerations
Spam protection mechanisms MUST be carefully designed to avoid introducing correlation risks:
-
Timing side channels: Proof verification and generation time SHOULD be constant to prevent packet fingerprinting through timing analysis.
-
Proof uniqueness and unlinkability: Each hop's proof MUST be unique and unlinkable from other hops' proofs.
-
Verification failure handling: Nodes MUST handle failures in a manner such that probing attacks can be prevented.
-
Global state and coordination: Mechanisms requiring global state MUST ensure access/updates don't leak any additional information. State lookups SHOULD use privacy-preserving techniques.
-
Sybil attacks: For sender-generated proofs, Sybil resistance cannot be included and nodes can still perform Sybil attacks by colluding. For per-hop generation, membership-based methods can provide Sybil resistance.
9.5 Recommended Methods
Specific spam protection methods fall outside this specification's scope. Common strategies that MAY be adapted include:
-
PoW style approaches: Approaches like EquiHash or VDF Client puzzles that satisfy spam protection requirements can be used. These, however, do not provide Sybil resistance, which would require a separate mechanism.
-
Privacy preserving Rate-limiting: Rate limiting approaches like RLN with zero-knowledge cryptography that preserve user's privacy can be used. Requires careful design of state access patterns. May need to be augmented with staking and slashing to add Sybil resistance.
Deployments MUST evaluate each method's computational overhead, latency impact, anonymity implications, infrastructure requirements, attack resistance, Sybil resistance, pre-computation resistance, economic cost, and architectural fit.
9.6 Spam Protection Interface
This section defines the standardized interface that spam protection mechanisms MUST implement to integrate with the Mix Protocol. The interface is designed to be architecture-agnostic, supporting both sender-generated proofs and Per-Hop Generated Proofs approaches described in Section 9.2.
Initialization and configuration of spam protection mechanisms is out of scope for this interface specification. Implementations are expected to handle their own initialization, configuration management, and runtime state independently before being integrated with the Mix Protocol.
Any spam protection mechanism integrated with the Mix Protocol MUST provide implementations of the procedures defined in this section. The specific cryptographic constructions, proof systems, and verification logic are left to the mechanism's specification, but the interface signatures and the behavior defined here MUST be adhered to for interoperability.
9.6.1 Deployment Configuration
The following parameters MUST be agreed upon and configured consistently across all nodes in a deployment:
-
Proof Size: The fixed size in bytes of
encoded_proof_dataproduced byGenerateProof. This value is used by Mix Protocol implementations to calculate header sizes and payload capacity. -
Integration Architecture: The spam protection integration architecture used by the deployment. Must be one of:
SENDER_GENERATED_PER_HOP: Sender generates proofs for each hop (see Section 9.6.3.1)PER_HOP_GENERATION: Each node generates a fresh proof for the next hop (see Section 9.6.3.2)
All nodes in a deployment MUST use the same integration architecture. Nodes MUST refuse to process packets that do not conform to the deployment's configured architecture.
9.6.2 Interface Procedures
All spam protection mechanisms MUST implement the following procedures.
9.6.2.1 Proof Generation
GenerateProof(binding_data) -> encoded_proof_data
Generate a spam protection proof bound to specific packet data.
Parameters:
binding_data: The packet-specific data to which the proof MAY be cryptographically bound. For sender-generated proofs, this is $\delta_{i+1}$ (the decrypted payload that hop $i$ will see). For per-hop generated proofs, this is the complete outgoing Sphinx packet state $(\alpha', \beta', \gamma', \delta')$.
Returns:
encoded_proof_data: Serialized bytes containing the spam protection proof and any required verification metadata. This is treated as opaque data by the Mix Protocol layer.
Requirements:
- The spam protection mechanism is responsible for managing its own runtime state (e.g., current epochs, difficulty levels, merkle tree states). The Mix Protocol layer does not provide or track mechanism-specific runtime context.
- The encoding MUST produce a fixed-length output, or include a length prefix if variable-length encoding is used.
9.6.2.2 Proof Verification
VerifyProof(encoded_proof_data, binding_data) -> valid
Verify that a spam protection proof is valid and correctly bound to the provided packet data.
Parameters:
encoded_proof_data: Serialized bytes containing the spam protection proof and verification metadata, extracted from the routing block $\beta$ (for sender-generated approach) or from the packet header field $\sigma$ (for per-hop generation approach).binding_data: The packet-specific data against which the proof SHOULD be verified. For nodes verifying sender-generated proofs, this is $\delta'$ (the decrypted payload). For per-hop verification, this is the received Sphinx packet state $(\alpha', \beta', \gamma', \delta')$.
Returns:
valid: Boolean indicating whether the proof is valid.
Requirements:
- Implementations MUST handle malformed or truncated
encoded_proof_datagracefully and returnfalse. - For mechanisms that maintain global state (e.g., nullifier sets, rate-limit counters, membership trees), this procedure MUST update the internal state atomically when verification succeeds. State updates (e.g., recording nullifiers, updating rate-limit counters) and state cleanup (e.g., removing expired epochs, old nullifiers) are managed internally by the spam protection mechanism.
9.6.3 Integration Points in Sphinx Processing
The Mix Protocol invokes spam protection procedures at specific points in Sphinx packet construction and processing:
9.6.3.1 For Sender-Generated Proofs
Sender Nodes - During Packet Construction (Section 8.5.2):
After computing encrypted payloads $\delta_i$ for each hop (Step 3.d), the sender MUST:
- For each hop $i$ in path (from $i = 0$ to $L-1$):
- Call
GenerateProof(binding_data = δ_{i+1})to generateencoded_proof_datafor hop $i$ - Embed the
encoded_proof_datain hop $i$'s routing block within $\beta_i$ during header construction (Step 3.c)
- Call
Intermediate Nodes- During Packet Processing (Section 8.6.1):
After decrypting the routing block $\beta$ and payload $\delta'$ (Steps 4-5), the node MUST:
- Extract
encoded_proof_datafrom the routing block $\beta$ at the appropriate offset - Call
VerifyProof(encoded_proof_data, binding_data = δ') - If
valid = false, discard the packet and terminate processing - If
valid = true, continue with role-specific processing (intermediary or exit)
9.6.3.2 For Per-Hop Generated Proofs
Entry Nodes - During Packet Construction (Section 8.5.2):
After assembling the final Sphinx packet (Step 3.e), the entry node MUST:
- Call
GenerateProof(binding_data)wherebinding_datais the complete Sphinx packet bytes - Append
encoded_proof_dataafter the Sphinx packet and send to the first hop
Intermediate Nodes - During Packet Processing (Section 8.6):
Before any Sphinx decryption operations, intermediate nodes MUST:
- Extract
encoded_proof_datafrom the lastproofSizebytes of the received packet - Call
VerifyProof(encoded_proof_data, binding_data)wherebinding_datais the Sphinx packet bytes - If the proof is not valid, discard the packet and terminate processing immediately
- If valid, perform standard Sphinx processing, then call
GenerateProof(binding_data)with the transformed packet asbinding_data - Append the new
encoded_proof_datato the transformed Sphinx packet and forward
Exit nodes follow steps 1-3 but do not generate a new proof.
10. Security Considerations
This section describes the security guarantees and limitations of the Mix Protocol. It begins by outlining the anonymity properties provided by the core protocol when routing messages through the mix network. It then discusses the trust assumptions required at the edges of the network, particularly at the final hop. Finally, it presents an alternative trust model for destinations that support Mix Protocol directly, followed by a summary of broader limitations and areas that may be addressed in future iterations.
10.1 Security Guarantees of the Core Mix Protocol
The core Mix Protocol—comprising anonymous routing through a sequence of mix nodes using Sphinx packets—provides the following security guarantees:
- Sender anonymity: Each message is wrapped in layered encryption and routed independently, making it unlinkable to the sender even if multiple mix nodes are colluding.
- Metadata protection: All messages are fixed in size and indistinguishable on the wire. Sphinx packets reveal only the immediate next hop and delay to each mix node. No intermediate node learns its position in the path or the total pathlength.
- Traffic analysis resistance: Continuous-time mixing with randomized per-hop delays reduces the risk of timing correlation and input-output linkage.
- Per-hop confidentiality and integrity: Each hop decrypts only its assigned layer of the Sphinx packet and verifies header integrity via a per-hop MAC.
- No long-term state: All routing is stateless. Mix nodes do not maintain per-message metadata, reducing the surface for correlation attacks.
These guarantees hold only within the boundaries of the Mix Protocol. Additional trust assumptions are introduced at the edges, particularly at the final hop, where the decrypted message is handed off to the Mix Exit Layer for delivery to the destination outside the mixnet. The next subsection discusses these trust assumptions in detail.
10.2 Exit Node Trust Model
The Mix Protocol ensures strong sender anonymity and metadata protection between the Mix Entry and Exit layers. However, once a Sphinx packet is decrypted at the final hop, additional trust assumptions are introduced. The node processing the final layer of encryption is trusted to forward the correct message to the destination and return any reply using the provided reply key. This section outlines the resulting trust boundaries.
10.2.1 Message Delivery and Origin Trust
At the final hop, the decrypted Sphinx packet reveals the plaintext message and destination address. The exit node is then trusted to deliver this message to the destination application, and—if a reply is expected—to return the response using the embedded reply key.
In this model, the exit node becomes a privileged middleman. It has full visibility into the decrypted payload. Specifically, the exit node could tamper with either direction of communication without detection:
- It may alter or drop the forwarded message.
- It may fabricate a reply instead of forwarding the actual response from the destination.
This limitation is consistent with the broader mixnet trust model. While intermediate nodes are constrained by layered encryption, edge nodes—specifically the initiating and the exit nodes in the path—are inherently more privileged and operate outside the cryptographic protections of the mixnet.
In systems like Tor, such exit-level tampering is mitigated by long-lived circuits that allow endpoints to negotiate shared session keys (e.g., via TLS). A malicious exit cannot forge a valid forward message or response without access to these session secrets.
The Mix Protocol, by contrast, is stateless and message-based. Each message is routed independently, with no persistent circuit or session context. As a result, endpoints cannot correlate messages, establish session keys, or validate message origin. That is, the exit remains a necessary point of trust for message delivery and response handling.
The next subsection describes a related limitation: the exit's ability to pose as a legitimate client to the destination's origin protocol, and how that can be abused to bypass application-layer expectations.
10.2.2 Origin Protocol Trust and Client Role Abuse
In addition to the message delivery and origin trust assumption, the exit node also initiates a client-side connection to the origin protocol instance at the destination. From the destination's perspective, this appears indistinguishable from a conventional peer connection, and the exit is accepted as a legitimate peer.
As a result, any protocol-level safeguards and integrity checks are applied to the exit node as well. However, since the exit node is not a verifiable peer and may open fresh connections at will, such protections are limited in their effectiveness. A malicious exit may repeatedly initiate new connections, send well-formed fabricated messages and circumvent any peer scoring mechanisms by reconnecting. These messages are indistinguishable from legitimate peer messages from the destination's point of view.
This class of attack is distinct from basic message tampering. Even if the message content is well-formed and semantically valid, the exit's role as an unaccountable client allows it to bypass application-level assumptions about peer behavior. This results in protocol misuse, targeted disruption, or spoofed message injection that the destination cannot attribute.
Despite these limitations, this model is compatible with legacy protocols and destinations that do not support the Mix Protocol. It allows applications to preserve sender anonymity without requiring any participation from the recipient.
However, in scenarios that demand stronger end-to-end guarantees—such as verifiable message delivery, origin authentication, or control over client access—it may be beneficial for the destination itself to operate a Mix instance. This alternative model is described in the next subsection.
10.3 Destination as Final Hop
In some deployments, it may be desirable for the destination node to participate in the Mix Protocol directly. In this model, the destination operates its own Mix instance and is selected as the final node in the mix path. The decrypted message is then delivered by the Mix Exit Layer directly to the destination's local origin protocol instance, without relying on a separate exit node.
From a security standpoint, this model provides end-to-end integrity guarantees. It removes the trust assumption on an external exit. The message is decrypted and delivered entirely within the destination node, eliminating the risk of tampering during the final delivery step. The response, if used, is also encrypted and returned by the destination itself, avoiding reliance on a third-party node to apply the reply key.
This model also avoids client role abuse. Since the Mix Exit Layer delivers the message locally, the destination need not accept arbitrary inbound connections from external clients. This removes the risk of an adversarial exit posing as a peer and injecting protocol-compliant but unauthorized messages.
This approach does require the destination to support the Mix Protocol. However, this requirement can be minimized by supporting a lightweight mode in which the destination only sends and receives messages via Mix, without participating in message routing for other nodes. This is similar to the model adopted by Waku, where edge nodes are not required to relay traffic but still interact with the network. In practice, this tradeoff is often acceptable.
The core Mix Protocol does not mandate destination participation. However, implementations MAY support this model as an optional mode for use in deployments that require stronger end-to-end security guarantees. The discovery mechanism MAY include a flag to advertise support for routing versus receive-only participation. Additional details on discovery configurations are out of scope for this specification.
This trust model is not required for interoperability, but is recommended when assessing deployment-specific threat models, especially in protocols that require message integrity or authenticated replies.
10.4 Known Protocol Limitations
The Mix Protocol provides strong sender anonymity and metadata protection guarantees within the mix network. However, it does not address all classes of network-level disruption or application-layer abuse. This section outlines known limitations that deployments MUST consider when evaluating system resilience and reliability.
10.4.1 Undetectable Node Misbehavior
The Mix Protocol in its current version does not include mechanisms to detect or attribute misbehavior by mix nodes. Since Sphinx packets are unlinkable and routing is stateless, malicious or faulty nodes may delay, drop, or selectively forward packets without detection.
This behavior is indistinguishable from benign network failure. There is no native support for feedback, acknowledgment, or proof-of-relay. As a result, unreliable nodes cannot be penalized or excluded based on observed reliability.
Future versions may explore accountability mechanisms. For now, deployments MAY improve robustness by sending each packet along multiple paths as defined in [Section X.X], but MUST treat message loss as a possibility.
10.4.2 No Built-in Retry or Acknowledgment
The Mix protocol does not support retransmission, delivery acknowledgments, or automated fallback logic. Each message is sent once and routed independently through the mixnet. If a message is lost or a node becomes unavailable, recovery is the responsibility of the top-level application.
Single-Use Reply Blocks (SURBs) (defined in Section[X.X]) enable destinations to send responses back to the sender via a fresh mix path. However, SURBs are optional, and their usage for acknowledgments or retries must be coordinated by the application.
Applications using the Mix Protocol MUST treat delivery as probabilistic. To improve reliability, the sender MAY:
- Use parallel transmission across
Ddisjoint paths. - Estimate end-to-end delay bounds based on chosen per-hop delays (defined in Section 6.2), and retry using different paths if a response is not received within the expected window.
These strategies MUST be implemented at the origin protocol layer or through Mix integration logic and are not enforced by the Mix Protocol itself.
10.4.3 No Sybil Resistance
The Mix Protocol does not include any built-in defenses against Sybil attacks. All nodes that support the protocol and are discoverable via peer discovery are equally eligible for path selection. An adversary that operates a large number of Sybil nodes may be selected into mix paths more often than expected, increasing the likelihood of partial or full path compromise.
In the worst case, if an adversary controls a significant fraction of nodes (e.g., one-third of the network), the probability that a given path includes only adversarial nodes increases sharply. This raises the risk of deanonymization through end-to-end traffic correlation or timing analysis.
Deployments concerned with Sybil resistance MAY implement passive defenses such as minimum path length constraints. More advanced mitigations such as stake-based participation or resource proofs typically require some form of trusted setup or blockchain-based coordination.
Such defenses are out of scope in the current version of the Mix Protocol, but are critical to ensuring anonymity at scale and may be explored in future iterations.
10.4.4 Vulnerability to Denial-of-Service Attacks
The Mix Protocol does not provide built-in defenses against denial-of-service (DoS) attacks targeting mix nodes. A malicious mix node may generate a high volume of valid Sphinx packets to exhaust computational, memory, or bandwidth resources along random paths through the network.
This risk stems from the protocol's stateless and sender-anonymous design. Mix nodes process each packet independently and cannot distinguish honest users from attackers. There is no mechanism to attribute packets, limit per-sender usage, or apply network-wide fairness constraints.
Mix nodes remain vulnerable to volumetric attacks even when destinations are protected.
While the Mix Protocol includes safeguards such as layered encryption, per-hop integrity checks, and fixed-size headers, these primarily defend against tagging attacks and structurally invalid or malformed traffic. The Sphinx packet format also enforces a maximum path length $(L \leq r)$, which prevents infinite loops or excessively long paths being embedded. However, these protections do not prevent adversaries from injecting large volumes of short, well-formed messages to exhaust mix node resources.
DoS protection—such as admission control, rate-limiting, or resource-bound access—can be plugged in as explained in Spam protection. Any such mechanism MUST preserve sender unlinkability and SHOULD be evaluated carefully to avoid introducing correlation risks.
Defending against large-scale DoS attacks is considered a deployment-level responsibility and is out of scope for this specification.
Multi-message_id Burn RLN
| Field | Value |
|---|---|
| Name | Multi-message_id burn feature RLN |
| Slug | 141 |
| Status | raw |
| Category | Standards Track |
| Editor | Ugur Sen [email protected] |
Timeline
- 2026-01-21 —
70f3cfb— chore: mdbook font fix (#266)
Abstract
This document specifies multi-message_id burn RLN which the users
can use their multiple message rights at once unlike previous versions of RLN
that require a separate execution per message_id.
Motivation
RLN is a decentralized rate-limiting mechanism designed for anonymous networks.
In RLNv2, the latest version of the protocol, users can apply arbitrary rate limits
by defining a specific limit over the message_id.
However, this version does not support the simultaneous exercise
of multiple messaging rights under a single message_id.
In other words, if a user needs to consume multiple message_id units,
they must compute separate proofs for each one.
This lack of flexibility creates an imbalance: users sending signals
of significantly different sizes still consume only one message_id per proof.
While computing multiple proofs is a trivial workaround,
it is neither computationally efficient nor manageable for high-throughput applications.
Multiple burning refers to the mechanism where a fixed number of message_id units are processed
within the circuit to generate multiple corresponding nullifiers inside a single cryptographic proof.
This multiple burning feature may unlock the usage of RLN for big signals
such as large messages or complex transactions, by validating their resource consumption in a single proof.
Alternatively, multiple burning could be realized by defining a separate circuit
for each possible number of message_id units to be consumed.
While such an approach would allow precise specialization, it would significantly increase
operational complexity by requiring the management, deployment, and verification
of multiple circuit variants.
To avoid this complexity, this document adopts a single, fixed-size but flexible
circuit design, where a bounded number of message_id units can be selectively
burned using selector bits.
This approach preserves the simplicity of a single
circuit while enabling efficient multi-burn proofs within a single execution.
This document specifies the mechanism that allows users to burn multiple message_id units
at once by slightly modifying the existing RLNv2 circuit.
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Recap of RLNv2
Since the multi-message_id RLN is achieved by modifying the existing RLNv2 protocol, it is helpful to first recap RLNv2. Note that this modification only affects the signaling section; the remaining sections—registration, verification, and slashing—remain identical to RLNv2.
RLNv2 Registration
RLN-Diff introduces per-user rate limits. Therefore, id_commitment must depend on
user_message_limit, where
0 ≤ user_message_limit ≤ message_limit.
The user submits the same identity_secret_hash as in
32/RLN-V1, i.e.
poseidonHash(identity_secret), together with user_message_limit to a server or
smart contract.
The verifier computes
rate_commitment = poseidonHash(identity_secret_hash, user_message_limit),
which is inserted as a leaf in the membership Merkle tree.
RLNv2 Signalling
For proof generation, the user need to submit the following fields to the circuit:
{
identity_secret: identity_secret_hash,
path_elements: Merkle_proof.path_elements,
identity_path_index: Merkle_proof.indices,
x: signal_hash,
message_id: message_id,
external_nullifier: external_nullifier,
user_message_limit: message_limit
}
Calculating output
The output [y, internal_nullifier] is calculated in the following way:
a_0 = identity_secret_hash;
a_1 = poseidonHash([a0, external_nullifier, message_id]);
y = a_0 + x * a_1;
internal_nullifier = poseidonHash([a_1]);
RLNv2 Verification/slashing
Verification and slashing in both subprotocols remain the same as in 32/RLN-V1.
The only difference that may arise is the message_limit check in RLN-Same,
since it is now a public input of the Circuit.
Multi-message_id Burn RLN (Multi-burn RLN)
The multi-burn protocol follows previous versions by comprising registration, signaling, and verification/slashing sections.
Since the registration and verification/slashing mechanisms remain unchanged, this section focuses exclusively on the modifications to the signaling process.
Multi-burn RLN Signalling
The multi-burn RLN signalling section consists of the proving of the circuit as follows:
Circuit parameters
Public Inputs
xexternal_nullifierselector_used []
Private Inputs
identity_secret_hashpath_elementsidentity_path_indexmessage_id []user_message_limit
Outputs
y []rootinternal_nullifiers []
The output (root, y [], internal_nullifiers []) is calculated in the following way:
a_0 = identity_secret_hash;
a_1i = poseidonHash([a0, external_nullifier, message_id [i]]);
y_i = a_0 + x * a_1i;
internal_nullifiers_i = poseidonHash([a_1i]);
where 0 < i ≤ max_out, max_out is a new parameter that is fixed for a application.
max_out is arranged the requirements of the application.
To define this fixed number makes the circuit is flexiable with a single circuit that is maintable.
Since the user is free to burn arbitrary number of message_id at once up to max_out.
Note that within a given epoch, the external_nullifier MUST be identical for all messages
as shown in NULL (unused) output section,
as it is computed deterministically from the epoch value and the rln_identifier as follows:
external_nullifier = poseidonHash([epoch, rln_identifier]);
NULL (unused) outputs
Since the number of used message_id values MAY be less than max_out, the difference
j = max_out - i, where 0 ≤ j ≤ max_out − 1, denotes the number of unused output slots.
These j outputs are referred to as NULL outputs. NULL outputs carry no semantic meaning and
MUST be identical to one another in order to unambiguously indicate that they correspond to
unused message_id slots and do not represent valid proofs.
To compute NULL outputs, the circuit makes use of a selector bit array selector_used [],
where selector_used[i] = 1 denotes a used message_id slot and selector_used[i] = 0 denotes
an unused slot.
The message_id values MUST NOT be checked in the circuit incrementally (e.g., 1, 2, 3, ...),
independently of whether a slot is used or unused.
For the best practice the application MAY pass the message_id values incrementally and
tracks unused message_id values across executions to ensure that subsequent executions
continue from the last assigned message_id without reuse or skipping.
The circuit computes the corresponding intermediate values for all slots according to the RLNv2 equations.
For each slot k, the final outputs are masked using the selector bits as
follows:
a_0 = identity_secret_hash;
a_1i = poseidonHash([a0, external_nullifier, message_id [i]]);
y_i = selector_used[i] * (a_0 + x * a_1i);
internal_nullifiers_i = selector_used[i] * poseidonHash([a_1i]);
Since multiplication by zero yields the additive identity in the field,
all unused slots (selector_used[k] = 0) result in
y[k] = 0 and internal_nullifiers[k] = 0, which are interpreted as
NULL outputs and carry no semantic meaning.
As a consequence, the presence of valid-looking message_id values in unused
slots does not result in additional burns, as their corresponding outputs are
fully masked and ignored during verification.
Moreover, message_id values that are provided to the circuit but correspond to unused slots (selector_used[k] = 0)
are not considered consumed and MAY be reused in subsequent proofs in which the corresponding selector bit is set to 1.
Copyright
Copyright and related rights waived via CC0
References
NOISE-X3DH-DOUBLE-RATCHET
| Field | Value |
|---|---|
| Name | Secure 1-to-1 channel setup using X3DH and the double ratchet |
| Slug | 108 |
| Status | raw |
| Category | Standards Track |
| Editor | Ramses Fernandez [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-04-04 —
517b639— Update the RFCs: Vac Raw RFC (#143) - 2024-10-03 —
c655980— Eth secpm splitted (#91)
Motivation
The need for secure communications has become paramount. This specification outlines a protocol describing a secure 1-to-1 comunication channel between 2 users. The main components are the X3DH key establishment mechanism, combined with the double ratchet. The aim of this combination of schemes is providing a protocol with both forward secrecy and post-compromise security.
Theory
The specification is based on the noise protocol framework. It corresponds to the double ratchet scheme combined with the X3DH algorithm, which will be used to initialize the former. We chose to express the protocol in noise to be be able to use the noise streamlined implementation and proving features. The X3DH algorithm provides both authentication and forward secrecy, as stated in the X3DH specification.
This protocol will consist of several stages:
- Key setting for X3DH: this step will produce prekey bundles for Bob which will be fed into X3DH. It will also allow Alice to generate the keys required to run the X3DH algorithm correctly.
- Execution of X3DH: This step will output
a common secret key
SKtogether with an additional data vectorAD. Both will be used in the double ratchet algorithm initialization. - Execution of the double ratchet algorithm
for forward secure, authenticated communications,
using the common secret key
SK, obtained from X3DH, as a root key.
The protocol assumes the following requirements:
- Alice knows Bob’s Ethereum address.
- Bob is willing to participate in the protocol, and publishes his public key.
- Bob’s ownership of his public key is verifiable,
- Alice wants to send message M to Bob.
- An eavesdropper cannot read M’s content even if she is storing it or relaying it.
Syntax
Cryptographic suite
The following cryptographic functions MUST be used:
X488as Diffie-Hellman functionDH.SHA256as KDF.AES256-GCMas AEAD algorithm.SHA512as hash function.XEd448for digital signatures.
X3DH initialization
This scheme MUST work on the curve curve448. The X3DH algorithm corresponds to the IX pattern in Noise.
Bob and Alice MUST define personal key pairs
(ik_B, IK_B) and (ik_A, IK_A) respectively where:
- The key
ikmust be kept secret, - and the key
IKis public.
Bob MUST generate new keys using
(ik_B, IK_B) = GENERATE_KEYPAIR(curve = curve448).
Bob MUST also generate a public key pair
(spk_B, SPK_B) = GENERATE_KEYPAIR(curve = curve448).
SPK is a public key generated and stored at medium-term.
Both signed prekey and the certificate MUST
undergo periodic replacement.
After replacing the key,
Bob keeps the old private key of SPK
for some interval, dependant on the implementation.
This allows Bob to decrypt delayed messages.
Bob MUST sign SPK for authentication:
SigSPK = XEd448(ik, Encode(SPK))
A final step requires the definition of
prekey_bundle = (IK, SPK, SigSPK, OPK_i)
One-time keys OPK MUST be generated as
(opk_B, OPK_B) = GENERATE_KEYPAIR(curve = curve448).
Before sending an initial message to Bob,
Alice MUST generate an AD: AD = Encode(IK_A) || Encode(IK_B).
Alice MUST generate ephemeral key pairs
(ek, EK) = GENERATE_KEYPAIR(curve = curve448).
The function Encode() transforms a
curve448 public key into a byte sequence.
This is specified in the RFC 7748
on elliptic curves for security.
One MUST consider q = 2^446 - 13818066809895115352007386748515426880336692474882178609894547503885
for digital signatures with (XEd448_sign, XEd448_verify):
XEd448_sign((ik, IK), message):
Z = randbytes(64)
r = SHA512(2^456 - 2 || ik || message || Z )
R = (r * convert_mont(5)) % q
h = SHA512(R || IK || M)
s = (r + h * ik) % q
return (R || s)
XEd448_verify(u, message, (R || s)):
if (R.y >= 2^448) or (s >= 2^446): return FALSE
h = (SHA512(R || 156326 || message)) % q
R_check = s * convert_mont(5) - h * 156326
if R == R_check: return TRUE
return FALSE
convert_mont(u):
u_masked = u % mod 2^448
inv = ((1 - u_masked)^(2^448 - 2^224 - 3)) % (2^448 - 2^224 - 1)
P.y = ((1 + u_masked) * inv)) % (2^448 - 2^224 - 1)
P.s = 0
return P
Use of X3DH
This specification combines the double ratchet with X3DH using the following data as initialization for the former:
- The
SKoutput from X3DH becomes theSKinput of the double ratchet. See section 3.3 of Signal Specification for a detailed description. - The
ADoutput from X3DH becomes theADinput of the double ratchet. See sections 3.4 and 3.5 of Signal Specification for a detailed description. - Bob’s signed prekey
SigSPKBfrom X3DH is used as Bob’s initial ratchet public key of the double ratchet.
X3DH has three phases:
- Bob publishes his identity key and prekeys to a server, a network, or dedicated smart contract.
- Alice fetches a prekey bundle from the server, and uses it to send an initial message to Bob.
- Bob receives and processes Alice's initial message.
Alice MUST perform the following computations:
dh1 = DH(IK_A, SPK_B, curve = curve448)
dh2 = DH(EK_A, IK_B, curve = curve448)
dh3 = DH(EK_A, SPK_B)
SK = KDF(dh1 || dh2 || dh3)
Alice MUST send to Bob a message containing:
IK_A, EK_A.- An identifier to Bob's prekeys used.
- A message encrypted with AES256-GCM using
ADandSK.
Upon reception of the initial message, Bob MUST:
- Perform the same computations above with the
DH()function. - Derive
SKand constructAD. - Decrypt the initial message encrypted with
AES256-GCM. - If decryption fails, abort the protocol.
Initialization of the double datchet
In this stage Bob and Alice have generated key pairs
and agreed a shared secret SK using X3DH.
Alice calls RatchetInitAlice() defined below:
RatchetInitAlice(SK, IK_B):
state.DHs = GENERATE_KEYPAIR(curve = curve448)
state.DHr = IK_B
state.RK, state.CKs = HKDF(SK, DH(state.DHs, state.DHr))
state.CKr = None
state.Ns, state.Nr, state.PN = 0
state.MKSKIPPED = {}
The HKDF function MUST be the proposal by
Krawczyk and Eronen.
In this proposal chaining_key and input_key_material
MUST be replaced with SK and the output of DH respectively.
Similarly, Bob calls the function RatchetInitBob() defined below:
RatchetInitBob(SK, (ik_B,IK_B)):
state.DHs = (ik_B, IK_B)
state.Dhr = None
state.RK = SK
state.CKs, state.CKr = None
state.Ns, state.Nr, state.PN = 0
state.MKSKIPPED = {}
Encryption
This function performs the symmetric key ratchet.
RatchetEncrypt(state, plaintext, AD):
state.CKs, mk = HMAC-SHA256(state.CKs)
header = HEADER(state.DHs, state.PN, state.Ns)
state.Ns = state.Ns + 1
return header, AES256-GCM_Enc(mk, plaintext, AD || header)
The HEADER function creates a new message header
containing the public key from the key pair output of the DHfunction.
It outputs the previous chain length pn,
and the message number n.
The returned header object contains ratchet public key
dh and integers pn and n.
Decryption
The function RatchetDecrypt() decrypts incoming messages:
RatchetDecrypt(state, header, ciphertext, AD):
plaintext = TrySkippedMessageKeys(state, header, ciphertext, AD)
if plaintext != None:
return plaintext
if header.dh != state.DHr:
SkipMessageKeys(state, header.pn)
DHRatchet(state, header)
SkipMessageKeys(state, header.n)
state.CKr, mk = HMAC-SHA256(state.CKr)
state.Nr = state.Nr + 1
return AES256-GCM_Dec(mk, ciphertext, AD || header)
Auxiliary functions follow:
DHRatchet(state, header):
state.PN = state.Ns
state.Ns = state.Nr = 0
state.DHr = header.dh
state.RK, state.CKr = HKDF(state.RK, DH(state.DHs, state.DHr))
state.DHs = GENERATE_KEYPAIR(curve = curve448)
state.RK, state.CKs = HKDF(state.RK, DH(state.DHs, state.DHr))
SkipMessageKeys(state, until):
if state.NR + MAX_SKIP < until:
raise Error
if state.CKr != none:
while state.Nr < until:
state.CKr, mk = HMAC-SHA256(state.CKr)
state.MKSKIPPED[state.DHr, state.Nr] = mk
state.Nr = state.Nr + 1
TrySkippedMessageKey(state, header, ciphertext, AD):
if (header.dh, header.n) in state.MKSKIPPED:
mk = state.MKSKIPPED[header.dh, header.n]
delete state.MKSKIPPED[header.dh, header.n]
return AES256-GCM_Dec(mk, ciphertext, AD || header)
else: return None
Information retrieval
Static data
Some data, such as the key pairs (ik, IK) for Alice and Bob,
MAY NOT be regenerated after a period of time.
Therefore the prekey bundle MAY be stored in long-term
storage solutions, such as a dedicated smart contract
which outputs such a key pair when receiving an Ethereum wallet
address.
Storing static data is done using a dedicated
smart contract PublicKeyStorage which associates
the Ethereum wallet address of a user with his public key.
This mapping is done by PublicKeyStorage
using a publicKeys function, or a setPublicKey function.
This mapping is done if the user passed an authorization process.
A user who wants to retrieve a public key associated
with a specific wallet address calls a function getPublicKey.
The user provides the wallet address as the only
input parameter for getPublicKey.
The function outputs the associated public key
from the smart contract.
Ephemeral data
Storing ephemeral data on Ethereum MAY be done using a combination of on-chain and off-chain solutions. This approach provides an efficient solution to the problem of storing updatable data in Ethereum.
- Ethereum stores a reference or a hash that points to the off-chain data.
- Off-chain solutions can include systems like IPFS, traditional cloud storage solutions, or decentralized storage networks such as a Swarm.
In any case, the user stores the associated IPFS hash, URL or reference in Ethereum.
The fact of a user not updating the ephemeral information can be understood as Bob not willing to participate in any communication.
Copyright
Copyright and related rights waived via CC0.
References
RLN DoS Protection for Mixnet
| Field | Value |
|---|---|
| Name | RLN DoS Protection for Mixnet |
| Slug | 144 |
| Status | raw |
| Category | Standards Track |
| Editor | Prem Prathi [email protected] |
| Contributors |
Abstract
This document defines a spam and sybil protection protocol for libp2p mix based mixnets. The protocol specifies how Rate Limiting Nullifiers (RLN) can be integrated into libp2p mix. RLN allows mix nodes to detect and drop spam without identifying legitimate users, addressing spam attacks. RLN requires membership for mix nodes to send or forward messages, addressing the sybil attack vector. RLN satisfies the spam protection requirements defined in the libp2p mix protocol.
Background / Rationale / Motivation
Mixnets provide strong privacy guarantees by routing messages through multiple mix nodes using layered encryption and per-hop delays to obscure both routing paths and timing correlations. In order to have a production-ready mixnet using the libp2p mix, two critical vulnerabilities must be addressed:
- Spam attacks: An attacker can generate well-formed sphinx packets targeting mix nodes and can exhaust their resources. In case of mixnets, it is easy to attack a later hop in the mix path by choosing different first hop nodes. An attacker with minimal resources can launch spam/DoS attacks against individual mix nodes. By targeting all mix nodes in this manner, the attacker can render the entire mixnet unusable.
- Sybil attacks: Adversaries operating multiple node identities can increase the probability of path compromise, enabling deanonymization through traffic correlation or timing analysis.
The libp2p mix protocol provides an extension for integrating spam protection mechanisms. This specification proposes to use Rate Limiting Nullifiers (RLN) as the spam prevention and sybil protection mechanism. This approach introduces some trade-offs such as additional per-hop latency for proof generation which are discussed in the Tradeoffs section.
Terminology
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Node Roles
Mix protocol defines 3 roles for the nodes in the mix network - sender, exit, intermediary.
- A sender node is the originator node of a message, i.e a node that wishes to originate/send messages using the mix network.
- An exit node is responsible for delivering messages to the destination protocol.
- An intermediary node is responsible for forwarding a mix packet to the next mix node in the path.
Message
Message is the actual sphinx packet including headers and encrypted payload that is either originated or forwarded by a mix node.
Messaging Rate
The messaging rate is defined as the number of messages that can be sent/forwarded per fixed unit of time, termed an epoch.
Since we're using this as shorthand for the maximum allowable rate, this is also known as the rate limit.
The length of each epoch is constant and defined as the period.
We define an epoch as $\lceil$ unix_time / period $\rceil$.
For example, if unix_time is 1644810116 and we set period to 30, then epoch is $\lceil$ (unix_time/period) $\rceil$ = 54827004.
NOTE: The
epochrefers to the epoch in RLN and not Unix epoch. This means that no more messages than the registered rate limit can be sent per epoch, where the epoch length (period) is up to the application.
See section System Parameters for details on the period parameter.
Approach
Overview
The protocol implements RLN using a per-hop generated proof approach, where each node in the mix path generates and verifies proofs. This enables network-wide spam protection while preserving user privacy.
Each mix node MUST have an RLN group membership in order to send or forward messages in the mixnet. Each mix node in the path (except the sender) verifies the incoming RLN proof before processing the message. After verification, each node generates and attaches a new RLN proof before forwarding the message to the next hop.
To effectively detect spam, mix nodes SHOULD identify when a node exceeds its messaging rate by reusing the same nullifier across multiple messages within an epoch (known as "double signalling"). Since a message does not traverse all the mix nodes in the network, a spammer could exploit different paths to avoid detection by any single mix node. To address this, intermediary and exit nodes SHOULD participate in a coordination layer that indicates already seen messaging metadata across the mix nodes. This enables all participating mix nodes to detect double signalling across different paths, derive the spammer's private key, and initiate slashing.
Rationale
RLN is well-suited for spam and sybil protection in libp2p mix based mixnets due to the following properties:
-
Sybil Resistance:
- Requiring membership for each mix node creates friction to participate in the mixnet to send or forward messages
- Operating multiple identities becomes costly, mitigating sybil attacks that could compromise mix path selection
-
Privacy-Preserving Spam Protection:
- Uses zero-knowledge proofs to enforce rate limits without revealing sender identities
- Ties spam protection proof to the message content, making proofs non-reusable across messages
- Enables economic deterrence through slashing without compromising anonymity
-
Network-Level Benefits:
- RLN enables setting a deterministic messaging rate for the mixnet, which translates to predictable bandwidth requirements (messages per epoch × sphinx packet size).
- This makes it easier to provision and estimate resource usage for nodes participating in the mixnet.
- The rate limit creates a baseline traffic level that, when combined with cover traffic, helps maintain k-anonymity even during periods of low organic traffic.
Setup
Each mix node has an RLN key pair consisting of a secret key sk and public key pk as defined in RLN.
The secret key sk MUST be persisted securely by the mix node.
A mixnet that is spam-protected requires all mix nodes in it to form an RLN group.
- Mix nodes MUST be registered to the RLN group to be able to send or forward messages.
- Registration MAY be moderated through a smart contract deployed on a blockchain.
Note: The criteria for membership is out of scope of the spec and should be implementation-specific (e.g requiring stake)
The group membership data MUST be synchronized initially so that the mix node has the latest Merkle root in order to generate or verify RLN proofs. See Group Synchronization for details on maintaining synchronization.
Intermediary and exit mix nodes SHOULD subscribe to the coordination layer (defined below) in order to detect rate limit violations collaboratively. This ensures that mix nodes can detect spam and trigger slashing.
Sending and forwarding messages
In order to send/forward messages via mixnet, a mix node MUST include the RateLimitProof in the sphinx packet as $\sigma$.
Proof Generation
When generating an RLN proof, the node MUST:
- Use its secret key
skand the currentepoch - Obtain the current Merkle root and
path_elementsfrom the synchronized membership tree - Generate a keccak256 hash of all components of the outgoing sphinx packet (α', β', γ', δ') and set it as the proof signal. This prevents proof reuse across different messages.
Sender nodes:
- generate an RLN proof for the initial sphinx packet
- attach the proof to the packet before sending to the next hop
Intermediary and Exit nodes:
MUST do the following for every incoming mix packet:
- verify the incoming packet's RLN proof (see Message validation)
- process the sphinx packet according to the mix protocol
- generate a NEW RLN proof for the outgoing packet
- attach the new proof before forwarding to the next hop
Group Synchronization
Proof generation relies on the knowledge of Merkle tree root merkle_root and path_elements (the authentication path in the Merkle proof as defined in RLN) which both require access to the membership Merkle tree.
Proof verification also requires knowledge of the merkle_root to validate that the proof was generated against a valid membership tree state.
The RLN membership group MUST be synchronized across all mix nodes to ensure the latest Merkle root is used for RLN proof generation and verification.
Stale roots may cause legitimate proofs to be rejected.
Using an old root can allow inference about the index of the user's pk in the membership tree hence compromising user privacy and breaking message unlinkability.
In order to accommodate network delays, nodes MUST maintain a window of recent valid roots (see acceptable_root_window_size in System Parameters).
We recommend 5 for acceptable_root_window_size.
Coordination Layer
The coordination layer enables network-wide spam detection by preventing rate limit violations through nullifier reuse detection. The coordination layer SHOULD be used to broadcast messaging metadata. When a node detects spam, it can reconstruct the spammer's secret key using the shared key shares and initiate slashing.
Intermediary and exit nodes that participate in the coordination layer MUST both subscribe to receive metadata and broadcast metadata from messages they process. Sender-only nodes need not participate in this coordination layer as they only originate messages and do not forward or validate messages from others.
The coordination layer MUST have its own spam and sybil protection mechanism in order to prevent from these attacks. We recommend using WAKU-RLN-RELAY In this case, the Messaging Metadata MUST be encoded as the Waku Message payload. We recommend using the public Waku Network with a content topic agreed by all mix nodes.
Message validation
A mix node MUST validate a received message using the below checks, discard the message and stop further checks or processing on failure.
- If the
epochin the received message differs from the mix node's currentepochby more thanmax_epoch_gap. - If the
merkle_rootis NOT in theacceptable_root_window_sizepast roots of the mix node. - If the zero-knowledge proof
proofis valid. It does so by running the zk verification algorithm as explained in RLN.
If all checks pass, the node proceeds to spam detection before processing the message.
Spam detection and Slashing
To enable local spam detection and slashing, mix nodes MUST store the messaging metadata in a local cache. This includes metadata from:
- messages processed locally by the mix layer
- messages received via the coordination layer
The cache SHOULD be cleared for epoch data older than max_epoch_gap.
To identify spam messages, the node checks whether a message with an identical nullifier is present in the epoch's cache.
- If no entry exists for this
nullifier, the node stores the messaging metadata in the cache and proceeds to process the message normally. - If an entry exists and its
share_xandshare_ycomponents are different from the incoming message, then proceed with slashing. The mix node uses theshare_xandshare_yof the new message and the shares from the local cache to reconstruct theskof the message owner. Theskthen MUST be used to delete the spammer from the group and withdraw its staked funds. The node MUST discard the message and MUST NOT forward it. - If the
share_xandshare_yfields in the local cache are identical to the incoming message, then the message is a duplicate and MUST be discarded.
After successfully validating a message, intermediary and exit nodes SHOULD broadcast the message's metadata using the coordination layer to enable network-wide spam detection. The broadcast on the coordination layer MAY be batched atleast once per epoch to reduce constant traffic on coordination layer.
Wire Format Specification / Syntax
Spam protection proof
The following RateLimitProof MUST be added to the sphinx packet as $\sigma$ as explained in sending.
syntax = "proto3";
message RateLimitProof {
bytes proof = 1;
bytes merkle_root = 2;
bytes epoch = 3;
bytes share_x = 4;
bytes share_y = 5;
bytes nullifier = 6;
}
RateLimitProof
Below is the description of the fields of RateLimitProof and their types.
| Parameter | Type | Description |
|---|---|---|
proof | array of 128 bytes compressed | the zkSNARK proof as explained in the Sending process |
merkle_root | array of 32 bytes in little-endian order | the root of membership group Merkle tree at the time of sending the message |
epoch | array of 32 bytes | the current epoch at time of sending the message |
share_x and share_y | array of 32 bytes each | Shamir secret shares of the user's secret identity key sk . share_x is the hash of the message. share_y is calculated using Shamir secret sharing scheme |
nullifier | array of 32 bytes | internal nullifier derived from epoch and node's sk as explained in RLN construct |
Messaging Metadata
Messaging metadata is metadata which is broadcasted via coordination layer and cached by mix nodes locally. This helps identify duplicate signalling in order to detect spam.
syntax = "proto3";
message ExternalNullifier {
bytes internal_nullifier = 1;
repeated bytes x_shares = 2;
repeated bytes y_shares = 3;
}
message MessagingMetadata {
repeated ExternalNullifier nullifiers = 1;
}
System Parameters
The system parameters are summarized in the following table.
| Parameter | Description |
|---|---|
period | the length of epoch in seconds |
staked_fund | the amount of funds to be staked by mix nodes at the registration |
max_epoch_gap | the maximum allowed gap between the epoch of a mix node and the incoming message |
acceptable_root_window_size | the maximum number of past Merkle roots to store |
Security/Privacy Considerations
Known Attack Vectors and Mitigations
Sybil Attacks
- Attack: Adversary operates multiple node identities to increase path compromise probability
- Limitation: Well-funded adversary can still acquire multiple memberships
- Mitigation: Membership registration can consider other criteria along with stake to reduce chance of sybil identities.
Coordination Layer Attacks
- Attack: Flood coordination layer with spam metadata to create DoS
- Mitigation: Coordination layer MUST implement its own spam protection (line 156)
Timing Attacks
- Attack: Correlate message timing across hops to deanonymize users
- Mitigation: Mix protocol's per-hop delays provide timing obfuscation
- Note: RLN metadata broadcast may create additional timing side-channels requiring analysis
Privacy Considerations
Nullifier Linkability
- Concern: Nullifiers are broadcast via coordination layer, potentially enabling traffic analysis
- Analysis: Nullifiers are derived from epoch and secret key, changing per epoch
- Limitation: Within an epoch, multiple messages from same node share nullifier metadata structure
Out of Scope
The following are explicitly out of scope for this specification and left to implementations:
- Specific membership criteria and stake amounts
- Coordination layer protocol selection and configuration
- Blockchain selection for RLN group management
Tradeoffs
Additional Latency due to proof generation in every hop
Per-hop RLN proof generation introduces additional latency at each mix node in the path:
- Proof generation time: Typically
100-500msper hop depending on hardware capabilities - End-to-end impact: For a
3-hoppath, this adds300-1500msto total message delivery time - Comparison: This is significant compared to the mix protocol's per-hop delay
- Mitigation: See Future Work for potential optimizations using pre-computed proofs
This latency needs to be considered while deciding the approach to be used.
Membership registration friction
Requiring RLN group membership for all mix nodes creates barriers to network participation:
- Stake requirement: Nodes MUST stake funds to join, limiting casual participation
- Registration overhead: On-chain registration adds complexity and potential costs (gas fees)
- Benefit: This friction is intentional and necessary for sybil resistance
The appropriate stake amount MUST balance accessibility against attack economics (see System Parameters).
Cost of ZK Proof Generation
Zero-knowledge proof generation imposes computational costs on mix nodes. Proof generation is CPU-intensive, requiring modern processors. May be prohibitive for mobile or embedded devices.
Mitigation: See Future Work for potential research into using alternative proving systems.
These costs must be factored into operational expenses and node requirements.
Future Work
In order to reduce latency introduced at each hop:
- RLN can be used with pre-computed proofs as explained here. This approach can be explored further and could potentially replace the current proposed RLN implementation.
- Research other proving systems that would generate faster ZK proofs.
Additional sybil resistance mechanisms could augment RLN by incorporating reputation-based lists similar to Tor's "directory authorities".
These help clients build circuits that are less likely to be entirely controlled by sybils through a range of techniques that limit nodes' possible influence based on trustworthiness metrics.
Copyright
Copyright and related rights waived via CC0.
References
- libp2p mix protocol
- Rate Limiting Nullifiers (RLN)
- Rate Limiting Nullifiers v2
- RLN v1
- Waku-Relay
- RLN with precomputed proofs
- Poseidon hash implementation
RLN-INTEREP-SPEC
| Field | Value |
|---|---|
| Name | Interep as group management for RLN |
| Slug | 100 |
| Status | raw |
| Editor | Aaryamann Challani [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-05-27 —
99be3b9— Move Raw Specs (#37) - 2024-02-01 —
860bae2— Update rln-interep-spec.md - 2024-02-01 —
3f722d9— Update and rename README.md to rln-interep-spec.md - 2024-01-30 —
ea62398— Create README.md
Abstract
This spec integrates Interep into the RLN spec. Interep is a group management protocol that allows for the creation of groups of users and the management of their membership. It is used to manage the membership of the RLN group.
Interep ties in web2 identities with reputation, and sorts the users into groups based on their reputation score. For example, a GitHub user with over 100 followers is considered to have "gold" reputation.
Interep uses Semaphore under the hood to allow anonymous signaling of membership in a group. Therefore, a user with a "gold" reputation can prove the existence of their membership without revealing their identity.
RLN is used for spam prevention, and Interep is used for group management.
By using Interep with RLN, we allow users to join RLN membership groups without the need for on-chain financial stake.
Motivation
To have Sybil-Resistant group management, there are implementations of RLN which make use of financial stake on-chain. However, this is not ideal because it reduces the barrier of entry for honest participants.
In this case, honest participants will most likely have a web2 identity accessible to them, which can be used for joining an Interep reputation group. By modifying the RLN spec to use Interep, we can have Sybil-Resistant group management without the need for on-chain financial stake.
Since RLN and Interep both use Semaphore-style credentials, it is possible to use the same set of credentials for both.
Functional Operation
Using Interep with RLN involves the following steps -
- Generate Semaphore credentials
- Verify reputation and join Interep group
- Join RLN membership group via interaction with Smart Contract, by passing a proof of membership to the Interep group
1. Generate Semaphore credentials
Semaphore credentials are generated in a standard way, depicted in the Semaphore documentation.
2. Verify reputation and join Interep group
Using the Interep app deployed on Goerli, the user can check their reputation tier and join the corresponding group. This results in a transaction to the Interep contract, which adds them to the group.
3. Join RLN membership group
Instead of sending funds to the RLN contract to join the membership group, the user can send a proof of membership to the Interep group. This proof is generated by the user, and is verified by the contract. The contract ensures that the user is a member of the Interep group, and then adds them to the RLN membership group.
Following is the modified signature of the register function in the RLN contract -
/// @param groupId: Id of the group.
/// @param signal: Semaphore signal.
/// @param nullifierHash: Nullifier hash.
/// @param externalNullifier: External nullifier.
/// @param proof: Zero-knowledge proof.
/// @param idCommitment: ID Commitment of the member.
function register(
uint256 groupId,
bytes32 signal,
uint256 nullifierHash,
uint256 externalNullifier,
uint256[8] calldata proof,
uint256 idCommitment
)
Verification of messages
Messages are verified the same way as in the RLN spec.
Slashing
The slashing mechanism is the same as in the RLN spec. It is important to note that the slashing may not have the intended effect on the user, since the only consequence is that they cannot send messages. This is due to the fact that the user can send a identity commitment in the registration to the RLN contract, which is different than the one used in the Interep group.
Proof of Concept
A proof of concept is available at vacp2p/rln-interp-contract which integrates Interep with RLN.
Security Considerations
- As mentioned in Slashing, the slashing mechanism may not have the intended effect on the user.
- This spec inherits the security considerations of the RLN spec.
- This spec inherits the security considerations of Interep.
- A user may make multiple registrations using the same Interep proofs but different identity commitments. The way to mitigate this is to check if the nullifier hash has been detected previously in proof verification.
References
- RLN spec
- Interep
- Semaphore
- Decentralized cloudflare using Interep
- Interep contracts
- RLN contract
- RLNP2P
RLN-STEALTH-COMMITMENTS
| Field | Value |
|---|---|
| Name | RLN Stealth Commitment Usage |
| Slug | 102 |
| Status | raw |
| Category | Standards Track |
| Editor | Aaryamann Challani [email protected] |
| Contributors | Jimmy Debe [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-08-05 —
eb25cd0— chore: replace email addresses (#86) - 2024-04-15 —
0b0e00f— feat(rln-stealth-commitments): add initial tech writeup (#23)
Abstract
This specification describes the usage of stealth commitments to add prospective users to a network-governed 32/RLN-V1 membership set.
Motivation
When 32/RLN-V1 is enforced in 10/Waku2, all users are required to register to a membership set. The membership set will store user identities allowing the secure interaction within an application. Forcing a user to do an on-chain transaction to join a membership set is an onboarding friction, and some projects may be opposed to this method. To improve the user experience, stealth commitments can be used by a counterparty to register identities on the user's behalf, while maintaining the user's anonymity.
This document specifies a privacy-preserving mechanism,
allowing a counterparty to utilize 32/RLN-V1
to register an identityCommitment on-chain.
Counterparties will be able to register members
to a RLN membership set without exposing the user's private keys.
Background
The 32/RLN-V1 protocol,
consists of a smart contract that stores a idenitityCommitment
in a membership set.
In order for a user to join the membership set,
the user is required to make a transaction on the blockchain.
A set of public keys is used to compute a stealth commitment for a user,
as described in ERC-5564.
This specification is an implementation of the
ERC-5564 scheme,
tailored to the curve that is used in the 32/RLN-V1 protocol.
This can be used in a couple of ways in applications:
- Applications can add users to the 32/RLN-V1 membership set in a batch.
- Users of the application can register other users to the 32/RLN-V1 membership set.
This is useful when the prospective user does not have access to funds on the network that 32/RLN-V1 is deployed on.
Wire Format Specification
The two parties, the requester and the receiver, MUST exchange the following information:
message Request {
// The spending public key of the requester
bytes spending_public_key = 1;
// The viewing public key of the requester
bytes viewing_public_key = 2;
}
Generate Stealth Commitment
The application or user SHOULD generate a stealth_commitment
after a request to do so is received.
This commitment MAY be inserted into the corresponding application membership set.
Once the membership set is updated, the receiver SHOULD exchange the following as a response to the request:
message Response {
// The used to check if the stealth_commitment belongs to the requester
bytes view_tag = 2;
// The stealth commitment for the requester
bytes stealth_commitment = 3;
// The ephemeral public key used to generate the commitment
bytes ephemeral_public_key = 4;
}
The receiver MUST generate an ephemeral_public_key,
view_tag and stealth_commitment.
This will be used to check the stealth commitment
used to register to the membership set,
and the user MUST be able to check ownership with their viewing_public_key.
Implementation Suggestions
An implementation of the Stealth Address scheme is available in the erc-5564-bn254 repository, which also includes a test to generate a stealth commitment for a given user.
Security/Privacy Considerations
This specification inherits the security and privacy considerations of the Stealth Address scheme.
Copyright
Copyright and related rights waived via CC0.
References
RLN-V2
| Field | Value |
|---|---|
| Name | Rate Limit Nullifier V2 |
| Slug | 106 |
| Status | raw |
| Editor | Rasul Ibragimov [email protected] |
| Contributors | Lev Soukhanov [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
f01d5b9— chore: fix links (#260) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2024-09-13 —
3ab314d— Fix Files for Linting (#94) - 2024-05-27 —
99be3b9— Move Raw Specs (#37) - 2024-02-01 —
8342636— Update and rename RLN-V2.md to rln-v2.md - 2024-01-27 —
d7e84b4— Create RLN-V2.md
Abstract
The protocol specified in this document is an improvement of 32/RLN-V1, being more general construct, that allows to set various limits for an epoch (it's 1 message per epoch in 32/RLN-V1) while remaining almost as simple as it predecessor. Moreover, it allows to set different rate-limits for different RLN app users based on some public data, e.g. stake or reputation.
Motivation
The main goal of this RFC is to generalize 32/RLN-V1 and expand its applications. There are two different subprotocols based on this protocol:
- RLN-Same - RLN with the same rate-limit for all users;
- RLN-Diff - RLN that allows to set different rate-limits for different users.
It is important to note that by using a large epoch limit value,
users will be able to remain anonymous,
because their internal_nullifiers will not be repeated until they exceed the limit.
Flow
As in 32/RLN-V1, the general flow can be described by three steps:
- Registration
- Signaling
- Verification and slashing
The two sub-protocols have different flows, and hence are defined separately.
Important note
All terms and parameters used remain the same as in 32/RLN-V1, more details here
RLN-Same flow
Registration
The registration process in the RLN-Same subprotocol does not differ from 32/RLN-V1.
Signalling
For proof generation, the user needs to submit the following fields to the circuit:
{
identity_secret: identity_secret_hash,
path_elements: Merkle_proof.path_elements,
identity_path_index: Merkle_proof.indices,
x: signal_hash,
message_id: message_id,
external_nullifier: external_nullifier,
message_limit: message_limit
}
Calculating output
The following fields are needed for proof output calculation:
{
identity_secret_hash: bigint,
external_nullifier: bigint,
message_id: bigint,
x: bigint,
}
The output [y, internal_nullifier] is calculated in the following way:
a_0 = identity_secret_hash
a_1 = poseidonHash([a0, external_nullifier, message_id])
y = a_0 + x * a_1
internal_nullifier = poseidonHash([a_1])
RLN-Diff flow
Registration
id_commitment in 32/RLN-V1 is equal to poseidonHash(identity_secret).
The goal of RLN-Diff is to set different rate-limits for different users.
It follows that id_commitment must somehow depend
on the user_message_limit parameter,
where 0 <= user_message_limit <= message_limit.
There are few ways to do that:
- Sending
identity_secret_hash=poseidonHash(identity_secret, userMessageLimit)and zk proof thatuser_message_limitis valid (is in the right range). This approach requires zkSNARK verification, which is an expensive operation on the blockchain. - Sending the same
identity_secret_hashas in 32/RLN-V1 (poseidonHash(identity_secret)) and a user_message_limit publicly to a server or smart-contract whererate_commitment=poseidonHash(identity_secret_hash, userMessageLimit)is calculated. The leaves in the membership Merkle tree would be the rate_commitments of the users. This approach requires additional hashing in the Circuit, but it eliminates the need for zk proof verification for the registration.
Both methods are correct, and the choice of the method is left to the implementer. It is recommended to use second method for the reasons already described. The following flow description will also be based on the second method.
Signalling
For proof generation, the user need to submit the following fields to the circuit:
{
identity_secret: identity_secret_hash,
path_elements: Merkle_proof.path_elements,
identity_path_index: Merkle_proof.indices,
x: signal_hash,
message_id: message_id,
external_nullifier: external_nullifier,
user_message_limit: message_limit
}
Calculating output
The Output is calculated in the same way as the RLN-Same sub-protocol.
Verification and slashing
Verification and slashing in both subprotocols remain the same as in 32/RLN-V1.
The only difference that may arise is the message_limit check in RLN-Same,
since it is now a public input of the Circuit.
ZK Circuits specification
The design of the 32/RLN-V1 circuits is different from the circuits of this protocol. RLN-v2 requires additional algebraic constraints. The membership proof and Shamir's Secret Sharing constraints remain unchanged.
The ZK Circuit is implemented using a Groth-16 ZK-SNARK, using the circomlib library. Both schemes contain compile-time constants/system parameters:
- DEPTH - depth of membership Merkle tree
- LIMIT_BIT_SIZE - bit size of
limitnumbers, e.g. for the 16 - maximumlimitnumber is 65535.
The main difference of the protocol is that instead of a new polynomial
(a new value a_1) for a new epoch, a new polynomial is generated for each message.
The user assigns an identifier to each message;
the main requirement is that this identifier be in the range from 1 to limit.
This is proven using range constraints.
RLN-Same circuit
Circuit parameters
Public Inputs
xexternal_nullifiermessage_limit- limit per epoch
Private Inputs
identity_secret_hashpath_elementsidentity_path_indexmessage_id
Outputs
yrootinternal_nullifier
RLN-Diff circuit
In the RLN-Diff scheme, instead of the public parameter message_limit,
a parameter is used that is set for each user during registration (user_message_limit);
the message_id value is compared to it in the same way
as it is compared to message_limit in the case of RLN-Same.
Circuit parameters
Public Inputs
xexternal_nullifier
Private Inputs
identity_secret_hashpath_elementsidentity_path_indexmessage_iduser_message_limit
Outputs
yrootinternal_nullifier
Appendix A: Security considerations
Although there are changes in the circuits, this spec inherits all the security considerations of 32/RLN-V1.
Copyright
Copyright and related rights waived via CC0.
References
SDS
| Field | Value |
|---|---|
| Name | Scalable Data Sync protocol for distributed logs |
| Slug | 109 |
| Status | raw |
| Category | Standards Track |
| Editor | Hanno Cornelius [email protected] |
| Contributors | Akhil Peddireddy [email protected] |
Timeline
- 2026-01-19 —
f24e567— Chore/updates mdbook (#262) - 2026-01-16 —
89f2ea8— Chore/mdbook updates (#258) - 2025-12-22 —
0f1855e— Chore/fix headers (#239) - 2025-12-22 —
b1a5783— Chore/mdbook updates (#237) - 2025-12-18 —
d03e699— ci: add mdBook configuration (#233) - 2025-10-24 —
6980237— Fix Linting Errors (#204) - 2025-10-13 —
171e934— docs: add SDS-Repair extension (#176) - 2025-10-02 —
6672c5b— docs: update lamport timestamps to uint64, pegged to current time (#196) - 2025-09-15 —
b1da703— fix: use milliseconds for Lamport timestamp initialization (#179) - 2025-08-22 —
3505da6— sds lint fix (#177) - 2025-08-19 —
536d31b— docs: re-add sender ID to messages (#170) - 2025-03-07 —
8ee2a6d— docs: add optional retrieval hint to causal history in sds (#130) - 2025-02-20 —
235c1d5— docs: clarify receiving sync messages (#131) - 2025-02-18 —
7182459— docs: update sds sync message requirements (#129) - 2025-01-28 —
7a01711— fix(sds): remove optional from causal history field in Message protobuf (#123) - 2024-12-17 —
08b363d— Update SDS.md: Remove Errors (#115) - 2024-11-28 —
bee78c4— docs: add SDS protocol for scalable e2e reliability (#108)
Abstract
This specification introduces the Scalable Data Sync (SDS) protocol to achieve end-to-end reliability when consolidating distributed logs in a decentralized manner. The protocol is designed for a peer-to-peer (p2p) topology where an append-only log is maintained by each member of a group of nodes who may individually append new entries to their local log at any time and is interested in merging new entries from other nodes in real-time or close to real-time while maintaining a consistent order. The outcome of the log consolidation procedure is that all nodes in the group eventually reflect in their own logs the same entries in the same order. The protocol aims to scale to very large groups.
Motivation
A common application that fits this model is a p2p group chat (or group communication), where the participants act as log nodes and the group conversation is modelled as the consolidated logs maintained on each node. The problem of end-to-end reliability can then be stated as ensuring that all participants eventually see the same sequence of messages in the same causal order, despite the challenges of network latency, message loss, and scalability present in any communications transport layer. The rest of this document will assume the terminology of a group communication: log nodes being the participants in the group chat and the logged entries being the messages exchanged between participants.
Design Assumptions
We make the following simplifying assumptions for a proposed reliability protocol:
- Broadcast routing: Messages are broadcast disseminated by the underlying transport. The selected transport takes care of routing messages to all participants of the communication.
- Store nodes: There are high-availability caches (a.k.a. Store nodes) from which missed messages can be retrieved. These caches maintain the full history of all messages that have been broadcast. This is an optional element in the protocol design, but improves scalability by reducing direct interactions between participants.
- Message ID: Each message has a globally unique, immutable ID (or hash). Messages can be requested from the high-availability caches or other participants using the corresponding message ID.
- Participant ID: Each participant has a globally unique, immutable ID visible to other participants in the communication.
- Sender ID: The Participant ID of the original sender of a message, often coupled with a Message ID.
Wire protocol
The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Message
Messages MUST adhere to the following meta structure:
syntax = "proto3";
message HistoryEntry {
string message_id = 1; // Unique identifier of the SDS message, as defined in `Message`
optional bytes retrieval_hint = 2; // Optional information to help remote parties retrieve this SDS message; For example, A Waku deterministic message hash or routing payload hash
optional string sender_id = 3; // Participant ID of original message sender. Only populated if using optional SDS Repair extension
}
message Message {
string sender_id = 1; // Participant ID of the message sender
string message_id = 2; // Unique identifier of the message
string channel_id = 3; // Identifier of the channel to which the message belongs
optional uint64 lamport_timestamp = 10; // Logical timestamp for causal ordering in channel
repeated HistoryEntry causal_history = 11; // List of preceding message IDs that this message causally depends on. Generally 2 or 3 message IDs are included.
optional bytes bloom_filter = 12; // Bloom filter representing received message IDs in channel
repeated HistoryEntry repair_request = 13; // Capped list of history entries missing from sender's causal history. Only populated if using the optional SDS Repair extension.
optional bytes content = 20; // Actual content of the message
}
The sending participant MUST include its own globally unique identifier in the sender_id field.
In addition, it MUST include a globally unique identifier for the message in the message_id field,
likely based on a message hash.
The channel_id field MUST be set to the identifier of the channel of group communication
that is being synchronized.
For simple group communications without individual channels,
the channel_id SHOULD be set to 0.
The lamport_timestamp, causal_history and
bloom_filter fields MUST be set according to the protocol steps
set out below.
These fields MAY be left unset in the case of ephemeral messages.
The message content MAY be left empty for periodic sync messages,
otherwise it MUST contain the application-level content
Note: Close readers may notice that, outside of filtering messages originating from the sender itself, the
sender_idfield is not used for much. Its importance is expected to increase once a p2p retrieval mechanism is added to SDS, as is planned for the protocol.
Participant state
Each participant MUST maintain:
- A Lamport timestamp for each channel of communication, initialized to current epoch time in millisecond resolution. The Lamport timestamp is increased as described in the protocol steps to maintain a logical ordering of events while staying close to the current epoch time. This allows the messages from new joiners to be correctly ordered with other recent messages, without these new participants first having to synchronize past messages to discover the current Lamport timestamp.
- A bloom filter for received message IDs per channel. The bloom filter SHOULD be rolled over and recomputed once it reaches a predefined capacity of message IDs. Furthermore, it SHOULD be designed to minimize false positives through an optimal selection of size and hash functions.
- A buffer for unacknowledged outgoing messages
- A buffer for incoming messages with unmet causal dependencies
- A local log (or history) for each channel, containing all message IDs in the communication channel, ordered by Lamport timestamp.
Messages in the unacknowledged outgoing buffer can be in one of three states:
- Unacknowledged - there has been no acknowledgement of message receipt by any participant in the channel
- Possibly acknowledged - there has been ambiguous indication that the message has been possibly received by at least one participant in the channel
- Acknowledged - there has been sufficient indication that the message has been received by at least some of the participants in the channel. This state will also remove the message from the outgoing buffer.
Protocol Steps
For each channel of communication,
participants MUST follow these protocol steps to populate and interpret
the lamport_timestamp, causal_history and bloom_filter fields.
Send Message
Before broadcasting a message:
- the participant MUST set its local Lamport timestamp
to the maximum between the current value +
1and the current epoch time in milliseconds. In other words the local Lamport timestamp is set tomax(timeNowInMs, current_lamport_timestamp + 1). - the participant MUST include the increased Lamport timestamp in the message's
lamport_timestampfield. - the participant MUST determine the preceding few message IDs in the local history
and include these in an ordered list in the
causal_historyfield. The number of message IDs to include in thecausal_historydepends on the application. We recommend a causal history of two message IDs. - the participant MAY include a
retrieval_hintin theHistoryEntryfor each message ID in thecausal_historyfield. This is an application-specific field to facilitate retrieval of messages, e.g. from high-availability caches. - the participant MUST include the current
bloom_filterstate in the broadcast message.
After broadcasting a message, the message MUST be added to the participant’s buffer of unacknowledged outgoing messages.
Receive Message
Upon receiving a message,
- the participant SHOULD ignore the message if it has a
sender_idmatching its own. - the participant MAY deduplicate the message by comparing its
message_idto previously received message IDs. - the participant MUST review the ACK status of messages in its unacknowledged outgoing buffer using the received message's causal history and bloom filter.
- if the message has a populated
contentfield, the participant MUST include the received message ID in its local bloom filter. - the participant MUST verify that all causal dependencies are met
for the received message.
Dependencies are met if the message IDs in the
causal_historyof the received message appear in the local history of the receiving participant.
If all dependencies are met and the message has a populated content field,
the participant MUST deliver the message.
If dependencies are unmet,
the participant MUST add the message to the incoming buffer of messages
with unmet causal dependencies.
Deliver Message
Triggered by the Receive Message procedure.
If the received message’s Lamport timestamp is greater than the participant's local Lamport timestamp, the participant MUST update its local Lamport timestamp to match the received message. The participant MUST insert the message ID into its local log, based on Lamport timestamp. If one or more message IDs with the same Lamport timestamp already exists, the participant MUST follow the Resolve Conflicts procedure.
Resolve Conflicts
Triggered by the Deliver Message procedure.
The participant MUST order messages with the same Lamport timestamp in ascending order of message ID. If the message ID is implemented as a hash of the message, this means the message with the lowest hash would precede other messages with the same Lamport timestamp in the local log.
Review ACK Status
Triggered by the Receive Message procedure.
For each message in the unacknowledged outgoing buffer,
based on the received bloom_filter and causal_history:
- the participant MUST mark all messages in the received
causal_historyas acknowledged. - the participant MUST mark all messages included in the
bloom_filteras possibly acknowledged. If a message appears as possibly acknowledged in multiple received bloom filters, the participant MAY mark it as acknowledged based on probabilistic grounds, taking into account the bloom filter size and hash number.
Periodic Incoming Buffer Sweep
The participant MUST periodically check causal dependencies for each message in the incoming buffer. For each message in the incoming buffer:
- the participant MAY attempt to retrieve missing dependencies from the Store node
(high-availability cache) or other peers.
It MAY use the application-specific
retrieval_hintin theHistoryEntryto facilitate retrieval. - if all dependencies of a message are met, the participant MUST proceed to deliver the message.
If a message's causal dependencies have failed to be met after a predetermined amount of time, the participant MAY mark them as irretrievably lost.
Periodic Outgoing Buffer Sweep
The participant MUST rebroadcast unacknowledged outgoing messages after a set period. The participant SHOULD use distinct resend periods for unacknowledged and possibly acknowledged messages, prioritizing unacknowledged messages.
Periodic Sync Message
For each channel of communication, participants SHOULD periodically send sync messages to maintain state. These sync messages:
- MUST be sent with empty content
- MUST include a Lamport timestamp increased to
max(timeNowInMs, current_lamport_timestamp + 1), wheretimeNowInMsis the current epoch time in milliseconds. - MUST include causal history and bloom filter according to regular message rules
- MUST NOT be added to the unacknowledged outgoing buffer
- MUST NOT be included in causal histories of subsequent messages
- MUST NOT be included in bloom filters
- MUST NOT be added to the local log
Since sync messages are not persisted, they MAY have non-unique message IDs without impacting the protocol. To avoid network activity bursts in large groups, a participant MAY choose to only send periodic sync messages if no other messages have been broadcast in the channel after a random backoff period.
Participants MUST process the causal history and bloom filter of these sync messages following the same steps as regular messages, but MUST NOT persist the sync messages themselves.
Ephemeral Messages
Participants MAY choose to send short-lived messages for which no synchronization or reliability is required. These messages are termed ephemeral.
Ephemeral messages SHOULD be sent with lamport_timestamp, causal_history, and
bloom_filter unset.
Ephemeral messages SHOULD NOT be added to the unacknowledged outgoing buffer
after broadcast.
Upon reception,
ephemeral messages SHOULD be delivered immediately without buffering for causal dependencies
or including in the local log.
SDS Repair (SDS-R)
SDS Repair (SDS-R) is an optional extension module for SDS, allowing participants in a communication to collectively repair any gaps in causal history (missing messages) preferably over a limited time window. Since SDS-R acts as coordinated rebroadcasting of missing messages, which involves all participants of the communication, it is most appropriate in a limited use case for repairing relatively recent missed dependencies. It is not meant to replace mechanisms for long-term consistency, such as peer-to-peer syncing or the use of a high-availability centralised cache (Store node).
SDS-R message fields
SDS-R adds the following fields to SDS messages:
sender_idinHistoryEntry: the original message sender's participant ID. This is used to determine the group of participants who will respond to a repair request.repair_requestinMessage: a capped list of history entries missing for the message sender and for which it's requesting a repair.
SDS-R participant state
SDS-R adds the following to each participant state:
-
Outgoing repair request buffer: a list of locally missing
HistoryEntrys each mapped to a future request timestamp,T_req, after which this participant will request a repair if at that point the missing dependency has not been repaired yet.T_reqis computed as a pseudorandom backoff from the timestamp when the dependency was detected missing. DeterminingT_reqis described below. We RECOMMEND that the outgoing repair request buffer be chronologically ordered in ascending order ofT_req. -
Incoming repair request buffer: a list of locally available
HistoryEntrys that were requested for repair by a remote participant AND for which this participant might be an eligible responder, each mapped to a future response timestamp,T_resp, after which this participant will rebroadcast the corresponding requestedMessageif at that point no other participant had rebroadcast theMessage.T_respis computed as a pseudorandom backoff from the timestamp when the repair was first requested. DeterminingT_respis described below. We describe below how a participant can determine if they're an eligible responder for a specific repair request. -
Augmented local history log: for each message ID kept in the local log for which the participant could be a repair responder, the full SDS
Messagemust be cached rather than just the message ID, in case this participant is called upon to rebroadcast the message. We describe below how a participant can determine if they're an eligible responder for a specific message.
Note: The required state can likely be significantly reduced in future by simply requiring that a responding participant should reconstruct the original Message when rebroadcasting, rather than the simpler, but heavier,
requirement of caching the entire received Message content in local history.
SDS-R global state
For a specific channel (that is, within a specific SDS-controlled communication) the following SDS-R configuration state SHOULD be common for all participants in the conversation:
T_min: the minimum time period to wait before a missing causal entry can be repaired. We RECOMMEND a value of at least 30 seconds.T_max: the maximum time period over which missing causal entries can be repaired. We RECOMMEND a value of between 120 and 600 seconds.
Furthermore, to avoid a broadcast storm with multiple participants responding to a repair request,
participants in a single channel MAY be divided into discrete response groups.
Participants will only respond to a repair request if they are in the response group for that request.
The global num_response_groups variable configures the number of response groups for this communication.
Its use is described below.
A reasonable default value for num_response_groups is one response group for every 128 participants.
In other words, if the (roughly) expected number of participants is expressed as num_participants, then
num_response_groups = num_participants div 128 + 1.
In other words, if there are fewer than 128 participants in a communication,
they will all belong to the same response group.
We RECOMMEND that the global state variables T_min, T_max and num_response_groups
be set statically for a specific SDS-R application,
based on expected number of group participants and volume of traffic.
Note: Future versions of this protocol will recommend dynamic global SDS-R variables, based on the current number of participants.
SDS-R send message
SDS-R adds the following steps when sending a message:
Before broadcasting a message,
- the participant SHOULD populate the
repair_requestfield in the message with eligible entries from the outgoing repair request buffer. An entry is eligible to be included in arepair_requestif its corresponding request timestamp,T_req, has expired (in other words,T_req <= current_time). The maximum number of repair request entries to include is up to the application. We RECOMMEND that this quota be filled by the eligible entries from the outgoing repair request buffer with the lowestT_req. We RECOMMEND a maximum of 3 entries. If there are no eligible entries in the buffer, this optional field MUST be left unset.
SDS-R receive message
On receiving a message,
- the participant MUST remove entries matching the received message ID from its outgoing repair request buffer. This ensures that the participant does not request repairs for dependencies that have now been met.
- the participant MUST remove entries matching the received message ID from its incoming repair request buffer. This ensures that the participant does not respond to repair requests that another participant has already responded to.
- the participant SHOULD add any unmet causal dependencies to its outgoing repair request buffer against a unique
T_reqtimestamp for that entry. It MUST compute theT_reqfor each such HistoryEntry according to the steps outlined in Determine T_req. - for each item in the
repair_requestfield:- the participant MUST remove entries matching the repair message ID from its own outgoing repair request buffer. This limits the number of participants that will request a common missing dependency.
- if the participant has the requested
Messagein its local history and is an eligible responder for the repair request, it SHOULD add the request to its incoming repair request buffer against a uniqueT_resptimestamp for that entry. It MUST compute theT_respfor each such repair request according to the steps outlined in Determine T_resp. It MUST determine if it's an eligible responder for a repair request according to the steps outlined in Determine response group.
Determine T_req
A participant determines the repair request timestamp, T_req,
for a missing HistoryEntry as follows:
T_req = current_time + hash(participant_id, message_id) % (T_max - T_min) + T_min
where current_time is the current timestamp,
participant_id is the participant's own participant ID
(not the sender_id in the missing HistoryEntry),
message_id is the missing HistoryEntry's message ID,
and T_min and T_max are as set out in SDS-R global state.
This allows T_req to be pseudorandomly and linearly distributed as a backoff of between T_min and T_max from current time.
Note: placing
T_reqvalues on an exponential backoff curve will likely be more appropriate and is left for a future improvement.
Determine T_resp
A participant determines the repair response timestamp, T_resp,
for a HistoryEntry that it could repair as follows:
distance = hash(participant_id) XOR hash(sender_id)
T_resp = current_time + distance*hash(message_id) % T_max
where current_time is the current timestamp,
participant_id is the participant's own (local) participant ID,
sender_id is the requested HistoryEntry sender ID,
message_id is the requested HistoryEntry message ID,
and T_max is as set out in SDS-R global state.
We first calculate the logical distance between the local participant_id and
the original sender_id.
If this participant is the original sender, the distance will be 0.
It should then be clear that the original participant will have a response backoff time of 0,
making it the most likely responder.
The T_resp values for other eligible participants will be pseudorandomly and
linearly distributed as a backoff of up to T_max from current time.
Note: placing
T_respvalues on an exponential backoff curve will likely be more appropriate and is left for a future improvement.
Determine response group
Given a message with sender_id and message_id,
a participant with participant_id is in the response group for that message if
hash(participant_id, message_id) % num_response_groups == hash(sender_id, message_id) % num_response_groups
where num_response_groups is as set out in SDS-R global state.
This ensures that a participant will always be in the response group for its own published messages.
It also allows participants to determine immediately on first reception of a message or
a history entry if they are in the associated response group.
SDS-R incoming repair request buffer sweep
An SDS-R participant MUST periodically check if there are any incoming requests in the incoming repair request buffer* that is due for a response.
For each item in the buffer,
the participant SHOULD broadcast the corresponding Message from local history
if its corresponding response timestamp, T_resp, has expired
(in other words, T_resp <= current_time).
SDS-R Periodic Sync Message
If the participant is due to send a periodic sync message, it SHOULD send the message according to SDS-R send message if there are any eligible items in the outgoing repair request buffer, regardless of whether other participants have also recently broadcast a Periodic Sync message.
Copyright
Copyright and related rights waived via CC0.
Zerokit API
| Field | Value |
|---|---|
| Name | Zerokit API |
| Slug | 142 |
| Status | raw |
| Category | Standards Track |
| Editor | Vinh Trinh [email protected] |
Timeline
- 2026-01-21 —
70f3cfb— chore: mdbook font fix (#266)
Abstract
This document specifies the Zerokit API, an implementation of the RLN-V2 protocol. The specification covers the unified interface exposed through native Rust, C-compatible Foreign Function Interface (FFI) bindings, and WebAssembly (WASM) bindings.
Motivation
The main goal of this RFC is to define the API contract, serialization formats, and architectural guidance for integrating the Zerokit library across all supported platforms. Zerokit is the reference implementation of the RLN-V2 protocol.
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Important Note
All terms and parameters used remain the same as in RLN-V2 and RLN-V1.
Architecture Overview
Zerokit follows a layered architecture where the core RLN logic is implemented once in Rust and exposed through platform-specific bindings. The protocol layer handles zero-knowledge proof generation and verification, Merkle tree operations, and cryptographic primitives. This core is wrapped by three interface layers: native Rust for direct library integration, FFI for C-compatible bindings consumed by languages (such as C and Nim), and WASM for browser and Node.js environments. All three interfaces maintain functional parity and share identical serialization formats for inputs and outputs.
┌─────────────────────────────────────────────────────┐
│ Application Layer │
└──────────┬───────────────┬───────────────┬──────────┘
│ │ │
┌──────▼───────┐ ┌─────▼─────┐ ┌───────▼─────┐
│ FFI API │ │ WASM API │ │ Rust API │
│ (C/Nim/..) │ │ (Browser) │ │ (Native) │
└──────┬───────┘ └─────┬─────┘ └───────┬─────┘
└───────────────┼───────────────┘
│
┌─────────▼─────────┐
│ RLN Protocol │
│ (Rust Core) │
└───────────────────┘
Supported Features
Zerokit provides compile-time feature flags that select the Merkle tree storage backend, configure the RLN operational mode (e.g., stateful vs. stateless), and enable or disable parallel execution.
Merkle Tree Backends
fullmerkletree allocates the complete tree structure in memory.
This backend provides the fastest performance but consumes the most memory.
optimalmerkletree uses sparse HashMap storage that only allocates nodes as needed.
This backend balances performance and memory efficiency.
pmtree persists the tree to disk using a sled database.
This backend enables state durability across process restarts.
Operational Modes
stateless disables the internal Merkle tree.
Applications MUST provide the Merkle root and
membership proof externally when generating proofs.
When stateless is not enabled,
the library operates in Stateful mode and
requires one of the Merkle tree backends.
Parallelization
parallel enables rayon-based parallel computation for
proof generation and tree operations.
This flag SHOULD be enabled for end-user clients where fastest individual proof generation time is required. For server-side proof services handling multiple concurrent requests, this flag SHOULD be disabled and applications SHOULD use dedicated worker threads per proof instead. The worker thread approach provides significantly higher throughput for concurrent proof generation.
The API
Overview
The API exposes functional interfaces with strongly-typed parameters. All three platform bindings share the same function signatures, differing only in language-specific conventions. Function signatures documented below are from the Rust perspective.
- Rust: https://github.com/vacp2p/zerokit/blob/master/rln/src/public.rs
- FFI: https://github.com/vacp2p/zerokit/tree/master/rln/src/ffi
- WASM: https://github.com/vacp2p/zerokit/tree/master/rln-wasm
Error Handling
Error handling differs across platform bindings.
For native Rust,
functions return Result<T, RLNError> where RLNError is an enum
representing specific error conditions.
The enum variants provide type-safe error handling and
pattern matching capabilities.
For WASM and FFI bindings, errors are returned as human-readable string messages. This simplifies cross-language error propagation at the cost of type safety. Applications consuming these bindings SHOULD parse error strings or use error message prefixes to distinguish error types when needed.
Initialization
Functions with the same name but different signatures are conditional compilation variants. This means that multiple definitions exist in the source code, but only one variant is compiled and available at runtime based on the enabled feature flags.
RLN::new(tree_depth, tree_config) - Available in Rust, FFI | Stateful mode
- Creates a new RLN instance by loading circuit resources from the default folder.
- The
tree_configparameter accepts multiple types via theTreeConfigInputtrait: a JSON string, a direct config object (withpmtreefeature), or an empty string for defaults.
RLN::new() - Available in Rust, FFI | Stateless mode
- Creates a new stateless RLN instance by loading circuit resources from the default folder.
RLN::new_with_params(tree_depth, zkey_data, graph_data, tree_config) - Available in Rust, FFI | Stateful mode
- Creates a new RLN instance with pre-loaded circuit parameters passed as byte vectors.
- The
tree_configparameter accepts multiple types via theTreeConfigInputtrait.
RLN::new_with_params(zkey_data, graph_data) - Available in Rust, FFI | Stateless mode
- Creates a new stateless RLN instance with pre-loaded circuit parameters.
RLN::new_with_params(zkey_data) - Available in WASM | Stateless mode
- Creates a new stateless RLN instance for WASM with pre-loaded zkey data.
- Graph data is not required as witness calculation is handled externally in WASM environments (e.g., using witness_calculator.js).
Key Generation
keygen()
- Generates a random identity keypair returning
(identity_secret, id_commitment).
seeded_keygen(seed)
- Generates a deterministic identity keypair from a seed returning
(identity_secret, id_commitment).
extended_keygen()
- Generates a random extended identity keypair returning
(identity_trapdoor, identity_nullifier, identity_secret, id_commitment).
extended_seeded_keygen(seed)
- Generates a deterministic extended identity keypair from a seed returning
(identity_trapdoor, identity_nullifier, identity_secret, id_commitment).
Merkle Tree Management
All tree management functions are only available when stateless feature is NOT enabled.
set_tree(tree_depth)
- Initializes the internal Merkle tree with the specified depth.
- Leaves are set to the default zero value.
set_leaf(index, leaf)
- Sets a leaf value at the specified index.
get_leaf(index)
- Returns the leaf value at the specified index.
set_leaves_from(index, leaves)
- Sets multiple leaves starting from the specified index.
- Updates
next_indextomax(next_index, index + n). - If
nleaves are passed, they will be set at positionsindex,index+1, ...,index+n-1.
init_tree_with_leaves(leaves)
- Resets the tree state to default and initializes it with the provided leaves starting from index 0.
- Resets the internal
next_indexto 0 before setting the leaves.
atomic_operation(index, leaves, indices)
- Atomically inserts leaves starting from index and removes leaves at the specified indices.
- Updates
next_indextomax(next_index, index + n)wherenis the number of leaves inserted.
set_next_leaf(leaf)
- Sets a leaf at the next available index and increments
next_index. - The leaf is set at the current
next_indexvalue, thennext_indexis incremented.
delete_leaf(index)
- Sets the leaf at the specified index to the default zero value.
- Does not change the internal
next_indexvalue.
leaves_set()
- Returns the number of leaves that have been set in the tree.
get_root()
- Returns the current Merkle tree root.
get_subtree_root(level, index)
- Returns the root of a subtree at the specified level and index.
get_merkle_proof(index)
- Returns the Merkle proof for the leaf at the specified index as
(path_elements, identity_path_index).
get_empty_leaves_indices()
- Returns indices of leaves set to zero up to the final leaf that was set.
set_metadata(metadata)
- Stores arbitrary metadata in the RLN object for application use.
- This metadata is not used by the RLN module.
get_metadata()
- Returns the metadata stored in the RLN object.
flush()
- Closes the connection to the Merkle tree database.
- Should be called before dropping the RLN object when using persistent storage.
Witness Construction
RLNWitnessInput::new(identity_secret, user_message_limit, message_id, path_elements, identity_path_index, x, external_nullifier)
- Constructs a witness input for proof generation.
- Validates that
message_id <= user_message_limitandpath_elementsandidentity_path_indexhave the same length.
Witness Calculation
For native Rust** environments, witness calculation is handled internally by the proof generation functions.
The circuit witness is computed from the RLNWitnessInput and passed to the zero-knowledge proof system.
For WASM environments, witness calculation must be performed externally using a JavaScript witness calculator. The workflow is:
- Create a
WasmRLNWitnessInputwith the required parameters - Export to JSON format using
toBigIntJson()method - Pass the JSON to an external JavaScript witness calculator
- Use the calculated witness with
generate_rln_proof_with_witness
The witness calculator computes all intermediate values required by the RLN circuit.
Proof Generation
generate_zk_proof(witness) - Available in Rust, FFI
- Generates a Groth16 zkSNARK proof from a witness.
- Extract proof values separately using
proof_values_from_witness.
generate_rln_proof(witness) - Available in Rust, FFI
- Generates a complete RLN proof returning both the zkSNARK proof and proof values as
(proof, proof_values). - Combines proof generation and proof values extraction.
generate_rln_proof_with_witness(calculated_witness, witness)
- Generates an RLN proof using a pre-calculated witness from an external witness calculator.
- The
calculated_witnessshould be aVec<BigInt>obtained from the external witness calculator. - Returns
(proof, proof_values). - This is the primary proof generation method for WASM where witness calculation is handled by JavaScript.
Proof Verification
verify_zk_proof(proof, proof_values)
- Verifies only the zkSNARK proof without root or signal validation.
- Returns
trueif the proof is valid.
verify_rln_proof(proof, proof_values, x) - Stateful mode
- Verifies the proof against the internal Merkle tree root and validates that
xmatches the proof signal. - Returns an error if verification fails (invalid proof, invalid root, or invalid signal).
verify_with_roots(proof, proof_values, x, roots)
- Verifies the proof against a set of acceptable roots and validates the signal.
- If the roots slice is empty, root verification is skipped.
- Returns an error if verification fails.
Slashing
recover_id_secret(proof_values_1, proof_values_2)
- Recovers the identity secret from two proof values that share the same external nullifier.
- Used to detect and penalize rate limit violations.
Hash Utilities
poseidon_hash(inputs)
- Computes the Poseidon hash of the input field elements.
hash_to_field_le(input)
- Hashes arbitrary bytes to a field element using little-endian byte order.
hash_to_field_be(input)
- Hashes arbitrary bytes to a field element using big-endian byte order.
Serialization Utilities
rln_witness_to_bytes_le / rln_witness_to_bytes_be
- Serializes an RLN witness to bytes.
bytes_le_to_rln_witness / bytes_be_to_rln_witness
- Deserializes bytes to an RLN witness.
rln_proof_to_bytes_le / rln_proof_to_bytes_be
- Serializes an RLN proof to bytes.
bytes_le_to_rln_proof / bytes_be_to_rln_proof
- Deserializes bytes to an RLN proof.
rln_proof_values_to_bytes_le / rln_proof_values_to_bytes_be
- Serializes proof values to bytes.
bytes_le_to_rln_proof_values / bytes_be_to_rln_proof_values
- Deserializes bytes to proof values.
fr_to_bytes_le / fr_to_bytes_be
- Serializes a field element to 32 bytes.
bytes_le_to_fr / bytes_be_to_fr
- Deserializes 32 bytes to a field element.
vec_fr_to_bytes_le / vec_fr_to_bytes_be
- Serializes a vector of field elements to bytes.
bytes_le_to_vec_fr / bytes_be_to_vec_fr
- Deserializes bytes to a vector of field elements.
WASM-Specific Notes
WASM bindings wrap the Rust API with JavaScript-compatible types. Key differences:
- Field elements are wrapped as
WasmFrwithfromBytesLE,fromBytesBE,toBytesLE,toBytesBEmethods. - Vectors of field elements use
VecWasmFrwithpush,get,lengthmethods. - Identity generation uses
Identity.generate()andIdentity.generateSeeded(seed)static methods. - Extended identity uses
ExtendedIdentity.generate()andExtendedIdentity.generateSeeded(seed). - Witness input uses
WasmRLNWitnessInputconstructor andtoBigIntJson()for witness calculator integration. - Proof generation requires external witness calculation via
generateRLNProofWithWitness(calculatedWitness, witness). - When
parallelfeature is enabled, callinitThreadPool()to initialize the thread pool. - Errors are returned as JavaScript strings that can be caught via try-catch blocks.
FFI-Specific Notes
FFI bindings use C-compatible types with the ffi_ prefix. Key differences:
- Field elements are wrapped as
CFrwith corresponding conversion functions. - Results use
CResultorCBoolResultstructs withokanderrfields. - Errors are returned as C-compatible strings in the
errfield of result structs. - Memory must be explicitly freed using
ffi_*_freefunctions. - Vectors use
repr_c::Vecwithffi_vec_*helper functions. - Configuration is passed via file path to a JSON configuration file.
Usage Patterns
This section describes common deployment scenarios and the recommended API combinations for each.
Stateful with Changing Root
Applies when membership changes over time with members joining and slashing continuously.
Applications MUST maintain a sliding window of recent roots externally.
When members are added or removed via set_leaf, delete_leaf, or atomic_operation,
capture the new root using get_root and append it to the history buffer.
Verify incoming proofs using verify_with_roots with the root history buffer,
accepting proofs valid against any recent root.
The window size depends on network propagation delays and epoch duration.
Stateful with Fixed Root
Applies when membership is established once and remains static during an operation period.
Initialize the tree using init_tree_with_leaves with the complete membership set.
No root history is required.
Verify proofs using verify_rln_proof which checks against the internal tree root directly.
Stateless
Applies when membership state is managed externally, such as by a smart contract or relay network.
Enable the stateless feature flag.
Obtain Merkle proofs and valid roots from the external source.
Pass externally provided path_elements and identity_path_index to RLNWitnessInput::new.
Verify using verify_with_roots with externally provided roots.
WASM Browser Integration
WASM environments require external witness calculation.
Use WasmRLNWitnessInput::toBigIntJson() to export the witness for
JavaScript witness calculators,
then pass the result to generateRLNProofWithWitness.
When parallel feature is enabled,
call initThreadPool() before proof operations.
This requires COOP/COEP headers for SharedArrayBuffer support.
Epoch and Rate Limit Configuration
The external nullifier is computed as poseidon_hash([epoch, rln_identifier]).
The rln_identifier is a field element that uniquely identifies your application (e.g., a hash of your app name).
All values that will be hashed MUST be represented as field elements.
For converting arbitrary data to field elements,
use hash_to_field_le or hash_to_field_be functions which internally use Poseidon hash.
Each application SHOULD use a unique rln_identifier to
prevent cross-application nullifier collisions.
The user_message_limit in the rate commitment determines messages allowed per epoch. The message_id must be less than user_message_limit and
should increment with each message.
Applications MUST persist the message_id counter to avoid violations after restarts.
Security/Privacy Considerations
The security of Zerokit depends on the correct implementation of the RLN-V2 protocol and the underlying zero-knowledge proof system. Applications MUST ensure that:
- Identity secrets are kept confidential and never transmitted or logged
- The
message_idcounter is properly persisted to prevent accidental rate limit violations - External nullifiers are constructed correctly to prevent cross-application attacks
- Merkle tree roots are validated when using stateless mode
- Circuit parameters (zkey and graph data) are obtained from trusted sources
When using the parallel feature in WASM,
applications MUST serve content with appropriate COOP/COEP headers to
enable SharedArrayBuffer support securely.
The slashing mechanism exposes identity secrets when rate limits are violated. Applications SHOULD educate users about this risk and implement safeguards to prevent accidental violations.
References
Normative
- RLN-V1 Specification - Rate Limit Nullifier V1 protocol
Informative
- Zerokit GitHub Repository - Reference implementation
- RLN-V2 Specification - Rate Limit Nullifier V2 protocol
- Sled Database - Embedded database for persistent Merkle tree storage
- Witness Calculator - JavaScript witness calculator for WASM environments
Copyright
Copyright and related rights waived via CC0.
TEMPLATE
| Field | Value |
|---|---|
| Name | RFC Template |
| Slug | 72 |
| Status | raw/draft/stable/deprecated |
| Category | Standards Track/Informational/Best Current Practice |
| Editor | Daniel Kaiser [email protected] |
(Info, remove this section)
This section contains meta info about writing LIPs. This section (including its subsections) MUST be removed.
COSS explains the Logos LIP process.
Tags
The tags metadata SHOULD contain a list of tags if applicable.
Currently identified tags comprise
waku/core-protocolfor Waku protocol definitions (e.g. store, relay, light push),waku/applicationfor applications built on top of Waku protocol (e.g. eth-dm, toy-chat),
Abstract
Background / Rationale / Motivation
This section serves as an introduction providing background information and a motivation/rationale for why the specified protocol is useful.
Theory / Semantics
A standard track RFC in stable status MUST feature this section.
A standard track RFC in raw or draft status SHOULD feature this section.
This section SHOULD explain in detail how the proposed protocol works.
It may touch on the wire format where necessary for the explanation.
This section MAY also specify endpoint behaviour when receiving specific messages,
e.g. the behaviour of certain caches etc.
Wire Format Specification / Syntax
A standard track RFC in stable status MUST feature this section.
A standard track RFC in raw or draft status SHOULD feature this section.
This section SHOULD not contain explanations of semantics and
focus on concisely defining the wire format.
Implementations MUST adhere to these exact formats to interoperate with other implementations.
It is fine, if parts of the previous section that touch on the wire format are repeated.
The purpose of this section is having a concise definition
of what an implementation sends and accepts.
Parts that are not specified here are considered implementation details.
Implementors are free to decide on how to implement these details.
An optional implementation suggestions section may provide suggestions
on how to approach implementation details, and,
if available, point to existing implementations for reference.
Implementation Suggestions (optional)
(Further Optional Sections)
Security/Privacy Considerations
A standard track RFC in stable status MUST feature this section.
A standard track RFC in raw or draft status SHOULD feature this section.
Informational LIPs (in any state) may feature this section.
If there are none, this section MUST explicitly state that fact.
This section MAY contain additional relevant information,
e.g. an explanation as to why there are no security consideration
for the respective document.
Copyright
Copyright and related rights waived via CC0.
References
References MAY be subdivided into normative and informative.
normative
A list of references that MUST be read to fully understand and/or implement this protocol. See RFC3967 Section 1.1.
informative
A list of additional references.