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.

All Stable Draft Raw Deprecated Deleted
All components Messaging Blockchain Storage IFT-TS
All time Latest Last 90 days
Loading RFC index...
Click a column to sort

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

  1. Open a pull request against the repo.
  2. Add or update the RFC in the appropriate component folder.
  3. 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.

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.

All Stable Draft Raw Deprecated Deleted
All time Latest Last 90 days
Loading RFC index...
Click a column to sort

Waku Standards - Core

Core Waku protocol specifications, including messaging, peer discovery, and network primitives.

10/WAKU2

FieldValue
NameWaku v2
Slug10
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsSanaz Taheri [email protected], Hanno Cornelius [email protected], Reeshav Khan [email protected], Daniel Kaiser [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-1534aa3f3 — Fix links 10/WAKU2 (#153)
  • 2025-04-09cafa04f — 10/WAKU2: Update (#125)
  • 2024-11-20ff87c84 — Update Waku Links (#104)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-018e14d58 — Update waku2.md
  • 2024-02-016cf68fd — Update waku2.md
  • 2024-02-016734b16 — Update waku2.md
  • 2024-01-31356649a — Update and rename WAKU2.md to waku2.md
  • 2024-01-27550238c — Rename README.md to WAKU2.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-26d6651b7 — Update README.md
  • 2024-01-256e98666 — Rename README.md to README.md
  • 2024-01-259b740d8 — Rename waku/10/README.md to waku/specs/standards/core/10-WAKU2/README.md
  • 2024-01-24330c35b — 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:

  1. /vac/waku/relay/2.0.0
  2. /vac/waku/store-query/3.0.0
  3. /vac/waku/filter/2.0.0-beta1
  4. /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:

There are also more experimental libp2p protocols such as:

  1. /vac/waku/waku-rln-relay/2.0.0-alpha1
  2. /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:

/ipfs/ping/1.0.0

for liveness checks between peers, or to keep peer-to-peer connections alive.

/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.

Overview of how protocols interact in Waku.

  1. We have six nodes, A-F. The protocols initially mounted are indicated as such. The PubSub topics pubtopic1 and pubtopic2 is 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.

  2. Node A creates a WakuMessage msg1 with a ContentTopic contentTopic1. See 14/WAKU2-MESSAGE for more details. If WakuMessage version is set to 1, we use the 6/WAKU1 compatible data field with encryption. See 7/WAKU-DATA for more details.

  3. Node F requests to get messages filtered by PubSub topic pubtopic1 and ContentTopic contentTopic1. 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.

  4. Node A publishes msg1 on pubtopic1 and 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.

  5. Node D saves msg1 for possible later retrieval by other nodes. See 13/WAKU2-STORE.

  6. Node D also pushes msg1 to F, as it has previously subscribed F to this filter. See 12/WAKU2-FILTER.

  7. At a later time, Node E comes online. It then requests messages matching pubtopic1 and contentTopic1 from 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.

Specnim-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:

To get compatibility with Waku Legacy:

For an interoperable keep-alive mechanism:

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 and related rights waived via CC0.

References

  1. libp2p specs

  2. 6/WAKU1

  3. Whisper spec (EIP627)

  4. Waku v2 plan

  5. 30/ADAPTIVE-NODES

  6. Protocol Identifiers

  7. 14/WAKU2-MESSAGE

  8. 26/WAKU-PAYLOAD

  9. 23/WAKU2-TOPICS

  10. 27/WAKU2-PEERS

  11. bi-directional binary stream

  12. Protobuf varint encoding

  13. 11/WAKU2-RELAY spec

  14. 17/WAKU2-RLN-RELAY

  15. EIP-1459

  16. Ambient peer discovery

  17. Node Discovery v5

  18. WAKU2-ENR

  19. EIP-778 ENR (Ethereum Node Records)

  20. 13/WAKU2-STORE spec

  21. 21/WAKU2-FT-STORE

  22. 12/WAKU2-FILTER

  23. 19/WAKU2-LIGHTPUSH

  24. 7/WAKU-DATA

  25. 15/WAKU-BRIDGE

  26. k-anonymity

  27. GossipSub v1.1

  28. nim-waku (Nim)

  29. go-waku (Go)

  30. js-waku (NodeJS and Browser)

  31. 8/WAKU-MAIL

  32. 9/WAKU-RPC

  33. 16/WAKU2-RPC

  34. 18/WAKU2-SWAP spec

  35. 21/WAKU2-FAULT-TOLERANT-STORE

11/WAKU2-RELAY

FieldValue
NameWaku v2 Relay
Slug11
Statusstable
EditorHanno Cornelius [email protected]
ContributorsOskar Thorén [email protected], Sanaz Taheri [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-01b346ad2 — Update relay.md
  • 2024-02-010904a8b — Update and rename RELAY.md to relay.md
  • 2024-01-278ff46fa — Rename WAKU2-RELAY.md to RELAY.md
  • 2024-01-274c4591c — Rename README.md to WAKU2-RELAY.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-256874961 — 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 Message to its publisher. This feature also implies the unlinkability of the publisher to its published topic ID as the Message embodies 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-RELAY protocol 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 of 11/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 TopicDescriptor is not currently used by 11/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:

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 subscribe field MUST contain a boolean, where true indicates subscribe and false indicates unsubscribe to a topic.

  • The topicid field MUST contain the pubsub topic.

Note: The topicid refering to pubsub topic and topicId refering 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-RELAY follows the StrictNoSign policy as described in libp2p PubSub specs. As the result of the StrictNoSign policy, Messages should be built without the from, signature and key fields 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 which Messages belong to that publisher by analyzing its traffic. The possibility of such inference may get higher when the data field is also not encrypted by the upper-level protocols.
  • Subscriber-Topic Unlinkability: To preserve subscriber-topic unlinkability, it is recommended by 10/WAKU2 to use a single PubSub topic in the 11/WAKU2-RELAY protocol. 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 that 11/WAKU2-RELAY supports 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-RELAY protocol, 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-RELAY aims 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 in 17/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-RELAY in 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 and related rights waived via CC0.

References

  1. 10/WAKU2

  2. 14/WAKU2-MESSAGE

  3. 17/WAKU-RLN

  4. GossipSub v1.0

  5. GossipSub v1.1

  6. K-anonimity

  7. libp2p concepts: Publish/Subscribe

  8. libp2p protocol negotiation

  9. Partitioned topics

  10. Protocol Buffers

  11. PubSub interface for libp2p (r2, 2019-02-01)

  12. Waku v1 spec

  13. Whisper spec (EIP627)

12/WAKU2-FILTER

FieldValue
NameWaku v2 Filter
Slug12
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected], Sanaz Taheri [email protected], Ebube Ud [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-03-25e8a3f8a — 12/WAKU2-FILTER: Update (#119)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-01e4d8f27 — Update and rename FILTER.md to filter.md
  • 2024-01-27046a3b7 — Rename WAKU2-FILTER.md to FILTER.md
  • 2024-01-2757124a7 — Rename README.md to WAKU2-FILTER.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25940d795 — Rename waku/12/README.md to waku/rfcs/standards/core/12/README.md
  • 2024-01-22420adf1 — 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 and related rights waived via CC0.

Previous versions

References

Informative

  1. Message Filtering (Wikipedia)
  2. Libp2p PubSub spec - topic validation

12/WAKU2-FILTER

FieldValue
NameWaku v2 Filter
Slug12
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected], Sanaz Taheri [email protected], Ebube Ud [email protected]

Timeline

  • 2026-01-21a00f16e — chore: mdbook fixes (#265)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-03-25e8a3f8a — 12/WAKU2-FILTER: Update (#119)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-05d41f106 — Update filter.md
  • 2024-02-058436a31 — Update and rename README.md to filter.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25420a51b — Rename waku/rfcs/core/12/previous-versions00/README.md to waku/rfcs/standards/core/12/previous-versions00/README.md
  • 2024-01-25755fea9 — Rename waku/12/previous-versions/00/README.md to waku/rfcs/core/12/previous-versions00/README.md
  • 2024-01-22420adf1 — 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 and related rights waived via CC0.

References

  1. Message Filtering (Wikipedia)

  2. Libp2p PubSub spec - topic validation

13/WAKU2-STORE

FieldValue
NameWaku Store Query
Slug13
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-151b8b2ac — Add missing status to 13/WAKU-STORE (#149)
  • 2025-02-03a60a2c4 — 13/WAKU-STORE: Update (#124)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-01755be94 — Update and rename STORE.md to store.md
  • 2024-01-273baed07 — Rename README.md to STORE.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-2551e2879 — 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 timestamp field MUST be populated with the Unix epoch time, at which the message was generated in nanoseconds. If at the time of storage the timestamp deviates 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 ephemeral field MUST be set to false.

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:

  1. Content filtered queries and
  2. 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_topic is in the request content_topics set and it was published on a matching pubsub_topic OR the request content_topics and pubsub_topic fields are unset
  • its timestamp is larger or equal than the request start_time OR the request start_time is unset
  • its timestamp is smaller than the request end_time OR the request end_time is 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_cursor is set, the store service node SHOULD populate the messages field with matching entries following the pagination_cursor (exclusive).
  • if the pagination_cursor is unset, the store service node SHOULD populate the messages field 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_cursor in the StoreQueryResponse with the message hash key of the last entry included in the response.

In response to a backward StoreQueryRequest:

  • if the pagination_cursor is set, the store service node SHOULD populate the messages field with matching entries preceding the pagination_cursor (exclusive).
  • if the pagination_cursor is unset, the store service node SHOULD populate the messages field 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_cursor in the StoreQueryResponse with 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 the WAKU2-STORE protocol 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 the WAKU2-STORE protocol does not provide anonymity for historical queries, as the querying node needs to directly connect to another node in the WAKU2-STORE protocol 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 places m' 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 and related rights waived via CC0.

Previous versions

References

  1. 14/WAKU2-MESSAGE
  2. protocol buffers v3
  3. Open timestamps

13/WAKU2-STORE

FieldValue
NameWaku v2 Store
Slug13
Statusdraft
EditorSimon-Pierre Vivier [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected], Hanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-02-03a60a2c4 — 13/WAKU-STORE: Update (#124)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-01755be94 — Update and rename STORE.md to store.md
  • 2024-01-273baed07 — Rename README.md to STORE.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-2551e2879 — 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 a WakuMessage. The hash is computed over the concatenation of contentTopic and payload fields of a WakuMessage (see 14/WAKU2-MESSAGE).
  • receiverTime: the UNIX time in nanoseconds at which the WakuMessage is received by the receiving node.
  • senderTime: the UNIX time in nanoseconds at which the WakuMessage is generated by its sender.
  • pubsubTopic: the pubsub topic on which the WakuMessage is received.

PagingInfo

PagingInfo holds the information required for pagination. It consists of the following components.

  • pageSize: A positive integer indicating the number of queried WakuMessages in a HistoryQuery (or retrieved WakuMessages in a HistoryResponse).
  • cursor: holds the Index of a WakuMessage.
  • direction: indicates the direction of paging which can be either FORWARD or BACKWARD.

ContentFilter

ContentFilter carries the information required for filtering historical messages.

  • contentTopic represents the content topic of the queried historical WakuMessage. This field maps to the contentTopic field of the 14/WAKU2-MESSAGE.

HistoryQuery

RPC call to query historical messages.

  • The pubsubTopic field MUST indicate the pubsub topic of the historical messages to be retrieved. This field denotes the pubsub topic on which WakuMessages are published. This field maps to topicIDs field of Message in 11/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 historical WakuMessage regardless of the pubsub topics on which they are published.
  • The contentFilters field 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 historical WakuMessage regardless of their content topics.
  • PagingInfo holds the information required for pagination.
    Its pageSize field indicates the number of WakuMessages to be included in the corresponding HistoryResponse. It is RECOMMENDED that the queried node defines a maximum page size internally. If the querying node leaves the pageSize unspecified, or if the pageSize exceeds the maximum page size, the queried node SHOULD auto-paginate the HistoryResponse to no more than the configured maximum page size. This allows mitigation of long response time for HistoryQuery. In the forward pagination request, the messages field of the HistoryResponse SHALL contain, at maximum, the pageSize amount of WakuMessage whose Index values are larger than the given cursor (and vise versa for the backward pagination). Note that the cursor of a HistoryQuery MAY be empty (e.g., for the initial query), as such, and depending on whether the direction is BACKWARD or FORWARD the last or the first pageSize WakuMessage SHALL 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 messages field MUST contain the messages found, these are 14/WAKU2-MESSAGE types.
  • PagingInfo holds the paging information based on which the querying node can resume its further history queries. The pageSize indicates the number of returned Waku messages (i.e., the number of messages included in the messages field of HistoryResponse). The direction is the same direction as in the corresponding HistoryQuery. In the forward pagination, the cursor holds the Index of the last message in the HistoryResponse messages (and the first message in the backward paging). Regardless of the paging direction, the retrieved messages are 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 returned cursor inside its next HistoryQuery to retrieve the next page of the 14/WAKU2-MESSAGE.
    The cursor obtained from one node SHOULD NOT be used in a request to another node because the result may be different.
  • The error field contains information about any error that has occurred while processing the corresponding HistoryQuery. NONE stands for no error. This is also the default value. INVALID_CURSOR means that the cursor field of HistoryQuery does not match with the Index of any of the WakuMessage persisted 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 the 13/WAKU2-STORE protocol 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 the 13/WAKU2-STORE protocol does not provide anonymity for historical queries, as the querying node needs to directly connect to another node in the 13/WAKU2-STORE protocol 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 places m' 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 and related rights waived via CC0.

References

  1. 14/WAKU2-MESSAGE
  2. protocol buffers v3
  3. 11/WAKU2-RELAY
  4. Open timestamps

14/WAKU2-MESSAGE

FieldValue
NameWaku v2 Message
Slug14
Statusstable
CategoryStandards Track
EditorHanno Cornelius [email protected]
ContributorsSanaz Taheri [email protected], Aaryamann Challani [email protected], Lorenzo Delgado [email protected], Abhimanyu Rawat [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-30d5a9240 — chore: removed archived (#283)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-098052808 — 14/WAKU2-MESSAGE: Move to Stable (#120)
  • 2024-11-20ff87c84 — Update Waku Links (#104)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-018e70159 — Update and rename MESSAGE.md to message.md
  • 2024-01-2788df5d8 — Rename README.md to MESSAGE.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-259cd48a8 — 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 payload attribute MUST contain the message data payload to be sent.

  • The content_topic attribute MUST specify a string identifier that can be used for content-based filtering, as described in 23/WAKU2-TOPICS.

  • The meta attribute, 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 version attribute, if present, contains a version number to discriminate different types of payload encryption. If omitted, the value SHOULD be interpreted as version 0.

  • The timestamp attribute, 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_proof attribute, if present, contains a rate limit proof encoded as per 17/WAKU2-RLN-RELAY.

  • The ephemeral attribute, 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 to true, the message SHOULD be interpreted as ephemeral. If, instead, the attribute is omitted or set to false, 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 topic field SHOULD be mapped to Waku message's content_topic attribute.
  • Whisper/6/WAKU1 data field SHOULD be mapped to Waku message's payload attribute.

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 and related rights waived via CC0.

References

15/WAKU-BRIDGE

FieldValue
NameWaku Bridge
Slug15
Statusdraft
EditorHanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-01-28c3d5fe6 — 15/WAKU2-BRIDGE: Update (#113)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-01d637b10 — Update and rename BRIDGE.md to bridge.md
  • 2024-01-274bf2f6e — Rename README.md to BRIDGE.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25f883f26 — 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 and related rights waived via CC0.

References

17/WAKU2-RLN-RELAY

FieldValue
NameWaku v2 RLN Relay
Slug17
Statusdraft
EditorAlvaro Revuelta [email protected]
ContributorsOskar Thorén [email protected], Aaryamann Challani [email protected], Sanaz Taheri [email protected], Hanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-11-20776c1b7 — rfc-index: Update (#110)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-06-065064ded — Update 17/WAKU2-RLN-RELAY: Proof Size (#44)
  • 2024-05-287b443c1 — 17/WAKU2-RLN-RELAY: Update (#32)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-01244ea55 — Update and rename RLN-RELAY.md to rln-relay.md
  • 2024-01-277bcefac — Rename README.md to RLN-RELAY.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-261ed5919 — Update README.md
  • 2024-01-254b3b4e3 — 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 epoch refers to the epoch in RLN and not Unix epoch. This means a message can only be sent every period, where the period is 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 sk is 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_fund and is a system parameter. The peer who has the secret key sk associated with a registered pk would be able to withdraw a portion reward_portion of the staked fund by providing valid proof.

reward_portion is also a system parameter.

NOTE: Initially sk is 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 per epoch.

An overview of registration is illustrated in Figure 1.

Figure 1: Registration.

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_root contains the root of the Merkle tree.
  • The epoch represents the current epoch.
  • The nullifier is an internal nullifier acting as a fingerprint that allows specifying whether two messages are published by the same peer during the same epoch.
  • The nullifier is a deterministic value derived from sk and epoch therefore any two messages issued by the same peer (i.e., using the same sk) for the same epoch are guaranteed to have identical nullifiers.
  • The share_x and share_y can be seen as partial disclosure of peer's sk for the intended epoch. They are derived deterministically from peer's sk and current epoch using 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 proof field is a zero-knowledge proof signifying that:
  1. 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 root merkle_root.
  2. share_x and share_y are correctly computed.
  3. The nullifier is 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., sk and authPath. The authPath is a subset of Merkle tree nodes by which a peer can prove the inclusion of its pk in 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:

  1. 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.

  2. For synchronizing the state of slashed pks, disseminate such information through a pubsubTopic to 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.

  1. If such a message exists and its share_x and share_y components are different from the incoming message, then slashing takes place. That is, the peer uses the share_x and share_y of the new message and the share'_x and share'_y of the old record to reconstruct the sk of the message owner. The sk then MAY be used to delete the spammer from the group and withdraw a portion reward_portion of its staked funds.
  2. If the share_x and share_y fields of the previously relayed message are identical to the incoming message, then the message is a duplicate and MUST be discarded.
  3. If none is found, then the message gets relayed.

An overview of the routing procedure and slashing is provided in Figure 2.

Figure 2: Publishing, Routing and Slashing workflow.


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.

ParameterTypeDescription
proofarray of 256 bytes uncompressed or 128 bytes compressedthe zkSNARK proof as explained in the Publishing process
merkle_rootarray of 32 bytes in little-endian orderthe root of membership group Merkle tree at the time of publishing the message
share_x and share_yarray of 32 bytes eachShamir 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
nullifierarray of 32 bytesinternal nullifier derived from epoch and peer's sk as explained in RLN construct

The system parameters are summarized in the following table, and the RECOMMENDED values for a subset of them are presented next.

ParameterDescription
periodthe length of epoch in seconds
staked_fundthe amount of funds to be staked by peers at the registration
reward_portionthe percentage of staked_fund to be rewarded to the slashers
max_epoch_gapthe maximum allowed gap between the epoch of a routing peer and the incoming message
acceptable_root_window_sizeThe 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 and related rights waived via CC0.

References

  1. 11/WAKU2-RELAY
  2. 64/WAKU-NETWORK
  3. RLN
  4. 14/WAKU2-MESSAGE
  5. RLN documentation
  6. Public inputs to the RLN circuit
  7. Shamir secret sharing scheme used in RLN
  8. RLN internal nullifier

19/WAKU2-LIGHTPUSH

FieldValue
NameWaku v2 Light Push
Slug19
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsDaniel Kaiser [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-11-20ff87c84 — Update Waku Links (#104)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-01c88680a — Update and rename LIGHTPUSH.md to lightpush.md
  • 2024-01-27f9efd29 — Rename README.md to LIGHTPUSH.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25c90013b — 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 and related rights waived via CC0.

References

31/WAKU2-ENR

FieldValue
NameWaku v2 usage of ENR
Slug31
Statusdraft
EditorFranck Royer [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-10-16e4f5f28 — 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 secp256k1 value MUST be present on the record; secp256k1 is defined in EIP-778 and contains the compressed secp256k1 public key.
  • The node's peer id SHOULD be deduced from the secp256k1 value.
  • 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 (and tcp6, udp6, ip6 for IPv6) are enough to convey all necessary information;
  • To save space, multiaddrs key 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:

keyvalue
tcp10101
udp20202
tcp630303
udp640404
ip1.2.3.4
ip61234:5600:101:1::142
secp256k1Alice's compressed secp256k1 public key, 33 bytes
multiaddrslen1 | /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,
  • len1 is the length of /dns4/example.com/tcp/443/wss byte representation,
  • len2 is the length of /dns4/quic.examle.com/tcp/443/quic byte representation.
  • len3 is the length of /ip4/1.2.3.4/tcp/55555/p2p/QmRelay byte representation. Notice that the /p2p-circuit component is not stored, but, since circuit relay addresses are the only one containing a p2p component, 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-circuit component.

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:

keyvalue
tcp10101
ip1.2.3.4
secp256k1Bob'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 1 indicate true and bits set to 0 indicate false for the relevant flags.
  • The flag values already defined are set out below, with bit 7 the most significant bit and bit 0 the least significant bit.
bit 7bit 6bit 5bit 4bit 3bit 2bit 1bit 0
undefundefundefsynclightpushfilterstorerelay
  • In the scheme above, the flags sync, lightpush, filter, store and relay correlates with support for protocols with the same name. If a protocol is not supported, the corresponding field MUST be set to false. Indicating positive support for any specific protocol is OPTIONAL, though it MAY be required by the relevant application or discovery process.
  • Flags marked as undef is not yet defined. These SHOULD be set to false by default.

Key Usage

  • A Waku node MAY choose to populate the waku2 field for enhanced discovery capabilities, such as indicating supported protocols. Such a node MAY indicate support for any specific protocol by setting the corresponding flag to true.
  • Waku nodes that want to participate in Node Discovery Protocol v5 [4], however, MUST implement the waku2 key with at least one flag set to true.
  • 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 waku2 field as required by the application.

Copyright and related rights waived via CC0.

References

33/WAKU2-DISCV5

FieldValue
NameWaku v2 Discv5 Ambient Peer Discovery
Slug33
Statusdraft
EditorDaniel Kaiser [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-295971166 — Update discv5.md (#139)
  • 2024-11-2087d4ff7 — Workflow Fix: markdown-lint (#111)
  • 2024-11-20dcc579c — Update WAKU2-PEER-EXCHANGE: Move to draft (#7)
  • 2024-11-20ff87c84 — Update Waku Links (#104)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-0138d68ce — Update discv5.md
  • 2024-02-01b8f8d20 — Update and rename DISCV5.md to discv5.md
  • 2024-01-27c6ef447 — Rename README.md to DISCV5.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25037474d — 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

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-id selectable using a compile-time flag as in this feature branch of nim-eth/discv5.
  • can be forked followed by changing the protocol-id string 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

  1. 10/WAKU2
  2. 34/WAKU2-PEER-EXCHANGE
  3. 11/WAKU2-RELAY
  4. WAKU2-ENR
  5. Node Discovery Protocol v5 (discv5)
  6. discv5 semantics.
  7. discv5 wire protocol
  8. discv5 topic discovery
  9. libp2p AutoNAT protocol
  10. EIP-1459
  11. GossipSub
  12. Waku discv5 roadmap discussion
  13. discovery efficiency estimation
  14. implementation: Nim
  15. implementation: Go

Copyright and related rights waived via CC0.

34/WAKU2-PEER-EXCHANGE

FieldValue
NameWaku2 Peer Exchange
Slug34
Statusdraft
CategoryStandards Track
EditorHanno Cornelius [email protected]
ContributorsDaniel Kaiser [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-12-072b297d5 — Update peer-exchange.md to fix a build error (#114)
  • 2024-11-2087d4ff7 — Workflow Fix: markdown-lint (#111)
  • 2024-11-20dcc579c — 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.

Figure 1: The responder provides a list of ENRs to the requester. These ENRs contain the information necessary for connecting to the respective peers.

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 and related rights waived via CC0.

References

36/WAKU2-BINDINGS-API

FieldValue
NameWaku v2 C Bindings API
Slug36
Statusdraft
EditorRichard Ramos [email protected]
ContributorsFranck Royer [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-28cb56103 — Update bindings-api.md
  • 2024-02-01e9469d0 — Update and rename BINDINGS-API.md to bindings-api.md
  • 2024-01-2776e514a — Rename README.md to BINDINGS-API.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-256bd686d — 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 - Success
  • 1 - Error
  • 2 - 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_encode can 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 with 0x prefix,
  • signature: Message signature (optional), hex encoded with 0x prefix,
  • 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 of ContentFilter being 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 of ContentFilter to 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 in PagingOptions format.

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 in JsonMessage format.
  • pagingOption: Paging information in PagingOptions format 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: true if paging forward, false if paging backward

Index type

interface Index {
    digest: string;
    receiverTime: number;
    senderTime: number;
    pubsubTopic: string;
  }

Fields:

  • digest: Hash of the message at this Index.
  • receiverTime: UNIX timestamp in nanoseconds at which the message at this Index was received.
  • senderTime: UNIX timestamp in nanoseconds at which the message is generated by its sender.
  • pubsubTopic: The pubsub topic of the message at this Index.

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, only message is 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
    }
  }
}
typeevent Type
messageJsonMessageEvent

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 in JsonMessage format.

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

  1. 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. Default 0.0.0.0.
  • port: Libp2p TCP listening port. Default 60000. Use 0 for random.
  • advertiseAddr: External address to advertise to other nodes. Can be ip4, ip6 or dns4, dns6. If null, 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. Default 20.
  • relay: Enable relay protocol. Default true.
  • relayTopics: Array of pubsub topics that WakuRelay will automatically subscribe to when the node starts Default []
  • gossipSubParameters: custom gossipsub parameters. See GossipSubParameters section for defaults
  • minPeersToPublish: The minimum number of peers required on a topic to allow broadcasting a message. Default 0.
  • legacyFilter: Enable Legacy Filter protocol. Default false.
  • discV5: Enable DiscoveryV5. Default false
  • discV5BootstrapNodes: Array of bootstrap nodes ENR
  • discV5UDPPort: UDP port for DiscoveryV5 Default 9000
  • store: Enable store protocol to persist message history Default false
  • databaseURL: url connection string. Accepts SQLite and PostgreSQL connection strings Default: sqlite3://store.db
  • storeRetentionMaxMessages: max number of messages to store in the database. Default 10000
  • storeRetentionTimeSeconds: max number of seconds that a message will be persisted in the database. Default 2592000 (30d)
  • websocket: custom websocket support parameters. See Websocket section for defaults
  • dns4DomainName: 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. Default 6
  • dLow: lower bound on the number of peers we keep in a GossipSub topic mesh Default 5
  • dHigh: upper bound on the number of peers we keep in a GossipSub topic mesh. Default 12
  • dScore: affects how peers are selected when pruning a mesh due to over subscription. Default 4
  • dOut: sets the quota for the number of outbound connections to maintain in a topic mesh. Default 2
  • historyLength: controls the size of the message cache used for gossip. Default 5
  • historyGossip: controls how many cached message ids we will advertise in IHAVE gossip messages. Default 3
  • dLazy: affects how many peers we will emit gossip to at each heartbeat. Default 6
  • gossipFactor: affects how many peers we will emit gossip to at each heartbeat. Default 0.25
  • gossipRetransmission: controls how many times we will allow a peer to request the same message id through IWANT gossip before we start ignoring them. Default 3
  • heartbeatInitialDelayMs: short delay in milliseconds before the heartbeat timer begins after the router is initialized. Default 100 milliseconds
  • heartbeatIntervalSeconds: controls the time between heartbeats. Default 1 second
  • slowHeartbeatWarning: duration threshold for heartbeat processing before emitting a warning. Default 0.1
  • fanoutTTLSeconds: controls how long we keep track of the fanout state. Default 60 seconds
  • prunePeers: controls the number of peers to include in prune Peer eXchange. Default 16
  • pruneBackoffSeconds: controls the backoff time for pruned peers. Default 60 seconds
  • unsubscribeBackoffSeconds: controls the backoff time to use when unsuscribing from a topic. Default 10 seconds
  • connectors: number of active connection attempts for peers obtained through PX. Default 8
  • maxPendingConnections: maximum number of pending connections for peers attempted through px. Default 128
  • connectionTimeoutSeconds: timeout in seconds for connection attempts. Default 30 seconds
  • directConnectTicks: the number of heartbeat ticks for attempting to reconnect direct peers that are not currently connected. Default 300
  • directConnectInitialDelaySeconds: initial delay before opening connections to direct peers. Default 1 second
  • opportunisticGraftTicks: number of heartbeat ticks for attempting to improve the mesh with opportunistic grafting. Default 60
  • opportunisticGraftPeers: the number of peers to opportunistically graft. Default 2
  • graftFloodThresholdSeconds: 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. Default 10 seconds
  • maxIHaveLength: 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. Default 5000
  • maxIHaveMessages: max number of IHAVE messages to accept from a peer within a heartbeat. Default 10
  • iWantFollowupTimeSeconds: Time to wait for a message requested through IWANT following an IHAVE advertisement. Default 3 seconds
  • seenMessagesTTLSeconds: configures when a previously seen message ID can be forgotten about. Default 120 seconds

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 Default false
  • host: listening address for websocket connections Default 0.0.0.0
  • port: TCP listening port for websocket connection (0 for random, binding to 443 requires root access) Default 60001, if secure websockets support is enabled, the default is 6443“
  • secure: enable secure websockets support Default false
  • certPath: secure websocket certificate path
  • keyPath: secure websocket key path

waku_new

extern int waku_new(char* jsonConfig, WakuCallBack onErrCb){}

Instantiates a Waku node.

Parameters

  1. char* jsonConfig: JSON string containing the options used to initialize a waku node. Type JsonConfig. It can be NULL to use defaults.
  2. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

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

  1. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

waku_stop

extern int waku_stop(WakuCallBack onErrCb){}

Stops a Waku node.

Parameters

  1. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

waku_peerid

extern int waku_peerid(WakuCallBack onOkCb, WakuCallBack onErrCb){}

Get the peer ID of the waku node.

Parameters

  1. WakuCallBack onOkCb: callback to be executed if the function is succesful
  2. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive the base58 encoded peer ID, for example QmWjHKUrXDHPCwoWXpUZ77E8o6UbAoTTZwf1AD1tDC4KNP
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

waku_listen_addresses

extern int waku_listen_addresses(WakuCallBack onOkCb, WakuCallBack onErrCb){}

Get the multiaddresses the Waku node is listening to.

Parameters

  1. WakuCallBack onOkCb: callback to be executed if the function is succesful
  2. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive a json array of multiaddresses. The multiaddresses are strings. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb and onErrCb callback

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

  1. char* address: A multiaddress (with peer id) to reach the peer being added.
  2. char* protocolId: A protocol we expect the peer to support.
  3. WakuCallBack onOkCb: callback to be executed if the function is succesful
  4. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive the base 58 peer ID of the peer that was added.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

waku_connect

extern int waku_connect(char* address, int timeoutMs, WakuCallBack onErrCb){}

Dial peer using a multiaddress.

Parameters

  1. char* address: A multiaddress to reach the peer being dialed.
  2. 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. Use 0 for no timeout.
  3. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

waku_connect_peerid

extern int waku_connect_peerid(char* peerId, int timeoutMs, WakuCallBack onErrCb){}

Dial peer using its peer ID.

Parameters

  1. char* peerID: Peer ID to dial. The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect.
  2. 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. Use 0 for no timeout.
  3. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

waku_disconnect

extern int waku_disconnect(char* peerId, WakuCallBack onErrCb){}

Disconnect a peer using its peerID

Parameters

  1. char* peerID: Peer ID to disconnect.
  2. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

waku_peer_cnt

extern int waku_peer_cnt(WakuCallBack onOkCb, WakuCallBack onErrCb){}

Get number of connected peers.

Parameters

  1. WakuCallBack onOkCb: callback to be executed if the function is succesful
  2. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive the number of connected peers.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

waku_peers

extern int waku_peers(WakuCallBack onOkCb, WakuCallBack onErrCb){}

Retrieve the list of peers known by the Waku node.

Parameters

  1. WakuCallBack onOkCb: callback to be executed if the function is succesful
  2. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* applicationName
  2. unsigned int applicationVersion
  3. char* contentTopicName
  4. char* encoding: depending on the payload, use proto, rlp or rfc26
  5. 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. onOkCb will 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 onOkCb callback

waku_pubsub_topic

extern int waku_pubsub_topic(char* name, char* encoding, WakuCallBack onOkCb){}

Create a pubsub topic string according to RFC 23.

Parameters

  1. char* name
  2. char* encoding: depending on the payload, use proto, rlp or rfc26
  3. 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. onOkCb will 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 onOkCb callback

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

  1. 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. onOkCb will get populated with /waku/2/default-waku/proto
  • 1 - The operation failed for any reason.
  • 2 - The function is missing the onOkCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* pubsubTopic: pubsub topic on which to publish the message. If NULL, it uses the default pubsub topic.
  3. 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. Use 0 for no timeout.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack 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. onOkCb will get populated with the message ID
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* pubsubTopic: Pubsub topic to verify. If NULL, it verifies the number of peers in the default pubsub topic.
  2. WakuCallBack onOkCb: callback to be executed if the function is succesful
  3. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive a string boolean indicating whether there are enough peers, i.e. true or false
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

waku_relay_subscribe

extern int waku_relay_subscribe(char* topic, WakuCallBack onErrCb){}

Subscribe to a Waku Relay pubsub topic to receive messages.

Parameters

  1. char* topic: Pubsub topic to subscribe to. If NULL, it subscribes to the default pubsub topic.
  2. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

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

  1. char* pusubTopic: Pubsub topic to unsubscribe from. If NULL, unsubscribes from the default pubsub topic.
  2. 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 onErrCb callback

waku_relay_topics

extern int waku_relay_topics(WakuCallBack onOkCb, WakuCallBack onErrCb)

Get the list of subscribed pubsub topics in Waku Relay.

Parameters

  1. WakuCallBack onOkCb: callback to be executed if the function is succesful
  2. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive a json array of pubsub topics i.e ["pubsubTopic1", "pubsubTopic2"]
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* filterJSON: JSON string containing the FilterSubscription to subscribe to.
  2. char* peerID: Peer ID to subscribe to. The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer. Use NULL to automatically select a node.
  3. 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. Use 0 for no timeout.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive the subscription details, for example:
{
  "peerID": "....",
  "pubsubTopic": "...",
  "contentTopics": [...]
}
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* peerID: Peer ID to check for an active subscription The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer.
  2. 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. Use 0 for no timeout.
  3. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

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

  1. char* filterJSON: JSON string containing the FilterSubscription criteria to unsubscribe from
  2. char* peerID: Peer ID to unsubscribe from The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer.
  3. 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. Use 0 for no timeout.
  4. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* peerID: Peer ID to unsubscribe from The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer. Use NULL to unsubscribe from all peers with active subscriptions
  2. 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. Use 0 for no timeout.
  3. WakuCallBack onOkCb: callback to be executed if the function is succesful
  4. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* filterJSON: JSON string containing the LegacyFilterSubscription to subscribe to.
  2. char* peerID: Peer ID to subscribe to. The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer. Use NULL to automatically select a node.
  3. 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. Use 0 for no timeout.
  4. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

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

  1. char* filterJSON: JSON string containing the LegacyFilterSubscription.
  2. 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. Use 0 for no timeout.
  3. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* pubsubTopic: pubsub topic on which to publish the message. If NULL, it uses the default pubsub topic.
  3. char* peerID: Peer ID supporting the lightpush protocol. The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer. Use NULL to automatically select a node.
  4. 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. Use 0 for no timeout.
  5. WakuCallBack onOkCb: callback to be executed if the function is succesful
  6. WakuCallBack 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. onOkCb will receive the message ID
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* queryJSON: JSON string containing the StoreQuery.
  2. char* peerID: Peer ID supporting the store protocol. The peer must be already known. It must have been added before with waku_add_peer or previously dialed with waku_connect_peer.
  3. 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. Use 0 for no timeout.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive a StoreResponse.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* queryJSON: JSON string containing the StoreQuery.
  2. 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. Use 0 for no timeout.
  3. WakuCallBack onOkCb: callback to be executed if the function is succesful
  4. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will receive a StoreResponse.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* symmetricKey: hex encoded secret key to be used for encryption.
  3. char* optionalSigningKey: hex encoded private key to be used to sign the message.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack 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. onOkCb will receive the encrypted waku message which can be broadcasted with relay or lightpush protocol publish functions.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* publicKey: hex encoded public key to be used for encryption.
  3. char* optionalSigningKey: hex encoded private key to be used to sign the message.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack 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. onOkCb will receive the encrypted waku message which can be broadcasted with relay or lightpush protocol publish functions.
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* symmetricKey: 32 byte symmetric key hex encoded.
  3. WakuCallBack onOkCb: callback to be executed if the function is succesful
  4. WakuCallBack 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. onOkCb will receive the decoded payload as a DecodedPayload.
{
  "pubkey": "0x......",
  "signature": "0x....",
  "data": "...",
  "padding": "..."
}
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* messageJson: JSON string containing the Waku Message as JsonMessage.
  2. char* privateKey: secp256k1 private key hex encoded.
  3. WakuCallBack onOkCb: callback to be executed if the function is succesful
  4. WakuCallBack 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. onOkCb will receive the decoded payload as a DecodedPayload.
{
  "pubkey": "0x......",
  "signature": "0x....",
  "data": "...",
  "padding": "..."
}
  • 1 - The operation failed for any reason. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* url: URL containing a discoverable ENR tree
  2. char* nameserver: The nameserver to resolve the ENR tree url. If NULL or empty, it will automatically use the default system dns.
  3. 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. Use 0 for no timeout.
  4. WakuCallBack onOkCb: callback to be executed if the function is succesful
  5. WakuCallBack onErrCb: callback to be executed if the function fails

Returns

int with a status code. Possible values:

  • 0 - The operation was completed successfuly. onOkCb will 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onOkCb or onErrCb callback

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

  1. char* bootnodes: JSON array containing the bootnode ENRs i.e. ["enr:...", "enr:..."]
  2. 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. onErrCb will be executed with the reason the function execution failed.
  • 2 - The function is missing the onErrCb callback

Copyright and related rights waived via CC0.

64/WAKU2-NETWORK

FieldValue
NameWaku v2 Network
Slug64
Statusdraft
CategoryBest Current Practice
EditorHanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-02-250277fd0 — docs: update dead links in 64/Network (#133)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-07-0977029a2 — Add RLNv2 to TheWakuNetwork (#82)
  • 2024-05-10e5b859a — 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 to 1
  • <shard_number> occupying the range 0 to 7. 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:

Roles

There are two distinct roles evident in the network, those of:

  1. nodes, and
  2. 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:

  1. 12/WAKU2-FILTER to allow resource-restricted peers to subscribe to messages matching a specific content filter.
  2. 13/WAKU2-STORE to allow other peers to request historical messages from this node.
  3. 19/WAKU2-LIGHTPUSH to allow resource-restricted peers to request publishing a message to the network on their behalf.
  4. 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:

  1. 12/WAKU2-FILTER to subscribe to messages matching a specific content filter.
  2. 13/WAKU2-STORE to request historical messages matching a specific content filter.
  3. 19/WAKU2-LIGHTPUSH to request publishing a message to the network.
  4. 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 between 0 and MAX_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 payload attribute MUST contain the message data payload as crafted by the application.
  • The mandatory content_topic attribute 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 meta attribute MAY be omitted. If present, will form part of the message uniqueness vector described in 14/WAKU2-MESSAGE.
  • The optional version attribute SHOULD be set to 0. It MUST be interpreted as 0 if not present.
  • The mandatory timestamp attribute 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 ephemeral attribute MUST be set to true, if the message should not be persisted by the Waku Network.
  • The optional rate_limit_proof attribute 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:

  1. Accept - the message is considered valid and it MUST be delivered and forwarded to the network.
  2. Reject - the message is considered invalid, MUST be rejected and SHOULD trigger a gossipsub scoring penalty against the transmitting peer.
  3. 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 and related rights waived via CC0.

References

66/WAKU2-METADATA

FieldValue
NameWaku Metadata Protocol
Slug66
Statusdraft
EditorFranck Royer [email protected]
ContributorsFilip Dimitrijevic [email protected], Alvaro Revuelta [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-07-314361e29 — Add implementation recommendation for metadata (#168)
  • 2025-05-13f829b12 — waku/standards/core/66/metadata.md update (#148)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-04-17d82eacc — 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 via 11/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

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 and related rights waived via CC0.

References

Waku Standards - Application

Application-layer specifications built on top of Waku core protocols.

20/TOY-ETH-PM

FieldValue
NameToy Ethereum Private Message
Slug20
Statusdraft
EditorFranck Royer [email protected]

Timeline

  • 2026-01-30d5a9240 — chore: removed archived (#283)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-093b152e4 — 20/TOY-ETH-PM: Update (#141)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-01-3189a94a5 — Update toy-eth-pm.md
  • 2024-01-30c4ff509 — Create toy-eth-pm.md
  • 2024-01-308841f49 — Update toy-eth-pm.md
  • 2024-01-29a16a2b4 — 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:

  • B is Bob's Ethereum address (or account),
  • b is the private key of B, and is only known by Bob.
  • B' is Bob's Encryption Public Key, for which b' is the private key.
  • M is the private message that Alice sends to Bob.

The proposed protocol MUST adhere to the following design requirements:

  1. Alice knows Bob's Ethereum address
  2. Bob is willing to participate to Eth-PM, and publishes B'
  3. Bob's ownership of B' MUST be verifiable
  4. Alice wants to send message M to Bob
  5. Bob SHOULD be able to get M using 10/WAKU2
  6. Participants only have access to their Ethereum Wallet via the Web3 API
  7. Carole MUST NOT be able to read M's content, even if she is storing it or relaying it
  8. 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:

  • encryptionPublicKey is Bob's Encryption Public Key, B', in hex format, without 0x prefix.
  • bobAddress is Bob's Ethereum address, corresponding to B, in hex format, with 0x prefix.
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_key is Bob's Encryption Public Key B', not compressed,
  • eth_address is Bob's Ethereum Address B,
  • signature is 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 and related rights waived via CC0.

References

26/WAKU2-PAYLOAD

FieldValue
NameWaku Message Payload Encryption
Slug26
Statusdraft
EditorOskar Thoren [email protected]
ContributorsOskar Thoren [email protected]

Timeline

  • 2026-01-30d5a9240 — chore: removed archived (#283)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-03-31f08de10 — 26/WAKU2-PAYLOADS: Update (#136)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-01-3133cf551 — Update payload.md
  • 2024-01-3129acb80 — Rename README.md to payload.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-257bd0712 — 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:

  • flags
  • payload-length
  • payload
  • padding
  • signature

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 and related rights waived via CC0.

References

  1. 6/WAKU1
  2. 10/WAKU2 spec
  3. 14/WAKU-MESSAGE version 1
  4. 7/WAKU-DATA
  5. EIP-627: Whisper spec
  6. RLPx Transport Protocol spec (ECIES encryption)
  7. Status 5/SECURE-TRANSPORT
  8. Augmented Backus-Naur form (ABNF)
  9. Ethereum "Yellow paper": Appendix F Signing transactions
  10. authenticated encryption

53/WAKU2-X3DH

FieldValue
NameX3DH usage for Waku payload encryption
Slug53
Statusdraft
CategoryStandards Track
EditorAaryamann Challani [email protected]
ContributorsAndrea 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-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-07-01b60abdb — update waku/standards/application/53/x3dh.md (#150)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-0151567b1 — Rename X3DH.md to x3dh.md
  • 2024-01-319fd3266 — Update and rename README.md to X3DH.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-255e95a1a — Rename README.md to README.md
  • 2024-01-25555eb20 — 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:

  1. Initial key exchange: Two parties, Alice and Bob, exchange their prekey bundles, and derive a shared secret.

  2. Double Ratchet: The two parties use the shared secret to derive a new encryption key for each message they send.

  3. Chain key update: The two parties update their chain keys. The chain key is used to derive new encryption keys for future messages.

  4. 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_A and IK_B correspond 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_B is 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:

(reference wire format)

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;
}

(reference wire format)

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:

message X3DHHeader {
  // Alice's ephemeral key `EK_A`
  bytes key = 1;
  // Bob's bundle signed prekey
  bytes id = 4;
}
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

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

  1. Inherits the security considerations of X3DH and Double Ratchet.

  2. Inherits the security considerations of the Waku v2 protocol.

  3. 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

  1. This protocol does not provide message unlinkability. It is possible to link messages signed by the same keypair.

Copyright and related rights waived via CC0.

References

54/WAKU2-X3DH-SESSIONS

FieldValue
NameSession management for Waku X3DH
Slug54
Statusdraft
CategoryStandards Track
EditorAaryamann Challani [email protected]
ContributorsAndrea 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-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-24db365cb — update waku/standards/application/54/x3dh-sessions.md (#151)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-010e490d4 — Rename X3DH-sessions.md to x3dh-sessions.md
  • 2024-01-317f8b187 — Update and rename README.md to X3DH-sessions.md
  • 2024-01-27a22c2a0 — Rename README.md to README.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25484df92 — 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:

  1. Listen to peer B's Contact Code Topic to retrieve their bundle information, including a list of active devices
  2. Peer A sends their pre-key bundle on peer B's partitioned topic
  3. Peer A and peer B perform the key-exchange using the shared pre-key bundles
  4. The negotiated topic is derived from the shared secret
  5. 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

  1. Inherits all security considerations from 53/WAKU2-X3DH.

Recommendations

  1. The value of n SHOULD 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 and related rights waived via CC0.

References

Waku Standards - Legacy

Legacy Waku standards retained for reference and historical compatibility.

6/WAKU1

FieldValue
NameWaku v1
Slug6
Statusstable
EditorOskar Thorén [email protected]
ContributorsAdam Babik [email protected], Andrea Maria Piana [email protected], Dean Eigenmann [email protected], Kim De Mey [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-12be052c8 — Rename waku/standards/core/waku_legacy/6/waku1.md to waku/standards/legacy/6/waku1.md
  • 2024-02-127d83b3d — Rename waku/standards/core/6/waku1.md to waku/standards/core/waku_legacy/6/waku1.md
  • 2024-02-01161b35a — Update and rename WAKU1.md to waku1.md
  • 2024-01-274c666c6 — Create WAKU1.md
  • 2024-01-2761f7641 — 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

TermDefinition
Batch AckAn abbreviated term for Batch Acknowledgment
Light nodeA Waku node that does not forward any envelopes through the Messages packet.
EnvelopeMessages sent and received by Waku nodes. Described in ABNF spec waku-envelope
NodeSome 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:

NameInt Value
Status0
Messages1
Status Update22

The following message codes are optional, but they are reserved for specific purpose.

NameInt ValueComment
Batch Ack11
Message Response12
P2P Request Complete125
P2P Request126
P2P Message127

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:

  1. Number of packets per second
  2. 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:

peersentreceived
peer10123
peer21040

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:

  1. Waku aims to be compatible with previous and future versions.

  2. In cases where we want to break this compatibility, we do so gracefully and as a single decision point.

  3. 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/6 and waku/1 until such a time as when we are ready to gracefully drop support for shh/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:

  1. A posts envelope; B posts envelope.
  2. C picks up envelope from A and B and relays them both to Waku and Whisper.
  3. 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 hello packet 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 the status-options association list in the status and status-update packet as options are OPTIONAL and unknown option keys SHOULD be ignored. A node SHOULD NOT disconnect from a peer when receiving status-options with 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

ClientSpec supportedDetails
Status-go0.5details
Nim-waku1.0details

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 version from handshake
  • Changed RLP keys 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-options association 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-interest capacity 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-interest packet 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/6 to waku/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 and related rights waived via CC0.

Footnotes

1

Felix Lange et al. The RLPx Transport Protocol. Ethereum.

7/WAKU-DATA

FieldValue
NameWaku Envelope data field
Slug7
Statusstable
EditorOskar Thorén [email protected]
ContributorsDean Eigenmann [email protected], Kim De Mey [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-12a57d7b4 — Rename data.md to data.md
  • 2024-01-31900a3e9 — Update and rename DATA.md to data.md
  • 2024-01-27662eb12 — Rename README.md to DATA.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-2593c3896 — 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 and related rights waived via CC0.

8/WAKU-MAIL

FieldValue
NameWaku Mailserver
Slug8
Statusstable
EditorAndrea Maria Piana [email protected]
ContributorsAdam Babik [email protected], Dean Eigenmann [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-12fa77279 — Rename mail.md to mail.md
  • 2024-01-310c8e39b — Rename MAIL.md to mail.md
  • 2024-01-27de5cfa2 — Rename README.md to MAIL.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-2528e7862 — 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

VersionComment
1.0.0marked stable as it is in use.
0.2.0Add topic interest to reduce bandwidth usage
0.1.0Initial Release

Difference between wms 0.1 and wms 0.2

  • topics option

Copyright and related rights waived via CC0.

9/WAKU-RPC

FieldValue
NameWaku RPC API
Slug9
Statusstable
EditorAndrea Maria Piana [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-125eb393f — Rename waku/legacy/9/rpc.md to waku/standards/legacy/9/rpc.md
  • 2024-02-129617146 — Rename waku/standards/core/waku_legacy/9/waku2-rpc.md to waku/legacy/9/rpc.md
  • 2024-02-1275705cd — Rename waku/standards/core/9/waku2-rpc.md to waku/standards/core/waku_legacy/9/waku2-rpc.md
  • 2024-02-01e808e36 — 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

FieldDescription
jsonrpcContains the used JSON RPC version (Default: 2.0)
methodContains the JSON RPC method that is being called
paramsAn array of parameters for the request
idThe 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.

FieldTypeDescription
sigstringPublic Key that signed this message
recipientPublicKeystringThe recipients public key
ttlnumberTime-to-live in seconds
timestampnumberUnix timestamp of the message generation
topicstring4 bytes, the message topic
payloadstringDecrypted payload
paddingstringOptional padding, byte array of arbitrary length
pownumberThe proof of work value
hashstringHash 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.

FieldTypeDescription
symKeyIDstringID of the symmetric key for message decryption
privateKeyIDstringID of private (asymmetric) key for message decryption
sigstringPublic key of the signature
minPownumberMinimal PoW requirement for incoming messages
topicsarrayArray of possible topics, this can also contain partial topics
allowP2PbooleanIndicates 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 - true on success or an error on failure.

waku_setMinPoW

Sets the minimal PoW required by this node.

Parameters

  • number - The new PoW requirement.

Response

  • bool - true on success or an error on failure.

waku_markTrustedPeer

Marks a specific peer as trusted allowing it to send expired messages.

Parameters

  • string - enode of the peer.

Response

  • bool - true on 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 - true on 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 - true or false or 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 - true or false or 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 - true or false or 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:

  1. string - The ID of the function call, in case of Waku this must contain the value "messages".
  2. 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 - true or false

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

Response

  • bool - true on 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

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 encryption
  • pubKey [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 - true on success or an error on failure.

Changelog

VersionComment
1.0.0Initial release.

Copyright and related rights waived via CC0.

Waku Informational LIPs

Informational Waku documents covering guidance, examples, and supporting material.

22/TOY-CHAT

FieldValue
NameWaku v2 Toy Chat
Slug22
Statusdraft
EditorFranck Royer [email protected]
ContributorsHanno Cornelius [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-01-31722c3d2 — Rename TOY-CHAT.md to toy-chat.md
  • 2024-01-295c5ea36 — Update TOY-CHAT.md
  • 2024-01-27411e135 — 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:

  1. Dogfood Waku v2,
  2. 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 and related rights waived via CC0.

23/WAKU2-TOPICS

FieldValue
NameWaku v2 Topic Usage Recommendations
Slug23
Statusdraft
CategoryInformational
EditorOskar Thoren [email protected]
ContributorsHanno Cornelius [email protected], Daniel Kaiser [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-224df2d5f — update waku/informational/23/topics.md (#144)
  • 2025-01-02dc7497a — add usage guidelines for waku content topics (#117)
  • 2024-11-20ff87c84 — Update Waku Links (#104)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-07e63d8a0 — Update topics.md
  • 2024-01-31b8f088c — Update and rename README.md to topics.md
  • 2024-01-312b693e8 — Update README.md
  • 2024-01-29055c525 — Update README.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25a11dfed — 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:

  1. it relates to the Waku protocol domain, and
  2. 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,/proto is the only valid encoding. This makes the /proto indication obsolete. The encoding of the payload field 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 and related rights waived via CC0.

References

27/WAKU2-PEERS

FieldValue
NameWaku v2 Client Peer Management Recommendations
Slug27
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-22af7c413 — update waku/informational/27/peers.md (#145)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-01-314b77d10 — Update and rename README.md to peers.md
  • 2024-01-31e65c359 — Update README.md
  • 2024-01-314a78cac — Update README.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-257daec2f — 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:

MetadataDescription
Public keyThe public key for this peer. This is related to the libp2p Peer ID.
AddressesKnown transport layer multiaddrs for this peer.
ProtocolsThe 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.
ConnectivityTracks the peer's current connectedness state. See Peer connectivity below.
Disconnect timeThe 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:

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:

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-waku client currently implements a keep-alive mechanism every 5 minutes, in response to a TCP connection timeout of 10 minutes.

Copyright and related rights waived via CC0.

References

29/WAKU2-CONFIG

FieldValue
NameWaku v2 Client Parameter Configuration Recommendations
Slug29
Statusdraft
EditorHanno Cornelius [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-227408956 — update waku/informational/29/config.md (#146)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-01-31c506eac — Update and rename CONFIG.md to config.md
  • 2024-01-31930f84d — Update and rename README.md to CONFIG.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-25e6396b9 — 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.

ParameterPurposeRECOMMENDED value
DThe desired outbound degree of the network6
D_lowLower bound for outbound degree4
D_highUpper bound for outbound degree8
D_lazy(Optional) the outbound degree for gossip emissionD
heartbeat_intervalTime between heartbeats1 second
fanout_ttlTime-to-live for each topic's fanout state60 seconds
mcache_lenNumber of history windows in message cache5
mcache_gossipNumber of history windows to use when emitting gossip3
seen_ttlExpiry time for cache of seen message ids2 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.

ParameterDescriptionRECOMMENDED value
PruneBackoffTime after pruning a mesh peer before we consider grafting them again.1 minute
FloodPublishWhether to enable flood publishingtrue
GossipFactor% of peers to send gossip to, if we have more than D_lazy available0.25
D_scoreNumber of peers to retain by score when pruning from oversubscriptionD_low
D_outNumber 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.

ParameterDescriptionRECOMMENDED value
BackoffSlackTimeSlack time to add to prune backoff before attempting to graft again2 seconds
IWantPeerBudgetMaximum number of IWANT messages to accept from a peer within a heartbeat25
IHavePeerBudgetMaximum number of IHAVE messages to accept from a peer within a heartbeat10
IHaveMaxLengthMaximum number of messages to include in an IHAVE message5000

Copyright and related rights waived via CC0.

References

30/ADAPTIVE-NODES

FieldValue
NameAdaptive nodes
Slug30
Statusdraft
EditorOskar Thorén [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-2991c9679 — update waku/informational/30/adaptive-nodes.md (#147)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-01-31b35846a — Update and rename README.md to adaptive-nodes.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-2504036ad — 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.

Node types - a continuum

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.

Protocol selection

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.

Waku Network visualization

Legend

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

Waku Network visualization legend

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.

Node Cross Section

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

FieldValue
NameWaku v0
Slug5
Statusdeprecated
EditorOskar Thorén [email protected]
ContributorsAdam Babik [email protected], Andrea Maria Piana [email protected], Dean Eigenmann [email protected], Kim De Mey [email protected]

Timeline

  • 2026-01-30d5a9240 — chore: removed archived (#283)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-01-319770963 — Rename WAKU0.md to waku0.md
  • 2024-01-31ac8fe6d — Rename waku/rfc/deprecated/5/WAKU0.md to waku/deprecated/5/WAKU0.md
  • 2024-01-2761f7641 — 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

TermDefinition
Light nodeA Waku node that does not forward any messages.
EnvelopeMessages sent and received by Waku nodes.
NodeSome 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:

NameInt Value
Status0
Messages1
Status Update22

The following message codes are optional, but they are reserved for specific purpose.

NameInt ValueComment
Batch Ack11
Message Response12
P2P Request126
P2P Message127

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:

peersentreceived
peer10123
peer21040

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:

  1. Waku aims to be compatible with previous and future versions.

  2. In cases where we want to break this compatibility, we do so gracefully and as a single decision point.

  3. 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/6 and waku/0 until such a time as when we are ready to gracefully drop support for shh/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:

  1. A posts message; B posts message.
  2. C picks up message from A and B and relays them both to Waku and Whisper.
  3. 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 hello message 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 the status-options association list in the status and status-update packet as options are OPTIONAL and unknown option keys SHOULD be ignored. A node SHOULD NOT disconnect from a peer when receiving status-options with 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

ClientSpec supportedDetails
Status-go0.5details
Nimbus0.4details

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-options association 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-interest capacity 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-interest packet 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/6 to waku/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 and related rights waived via CC0.

Footnotes

1

Felix Lange et al. The RLPx Transport Protocol. Ethereum.

16/WAKU2-RPC

FieldValue
NameWaku v2 RPC API
Slug16
Statusdeprecated
EditorHanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-04-168b552ba — chore: mark 16/WAKU2-RPC as deprecated (#30)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-0187b56de — Update and rename RPC.md to rpc.md
  • 2024-01-279042acf — Rename README.md to RPC.md
  • 2024-01-27eef961b — remove rfs folder
  • 2024-01-258a53f24 — 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

FieldDescription
jsonrpcContains the used JSON-RPC version (Default: 2.0)
methodContains the JSON-RPC method that is being called
paramsAn array of parameters for the request
idThe 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:

FieldTypeInclusionDescription
payloadStringmandatoryThe message payload as a base64 (with padding) encoded data string
contentTopicStringoptionalMessage content topic for optional content-based filtering
versionNumberoptionalMessage version. Used to indicate type of payload encryption. Default version is 0 (no payload encryption).
timestampNumberoptionalThe 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.
ephemeralBooleanoptionalThis 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. Supported method_type values are get, post, put, delete or patch.
  • <protocol_version>: Waku version. Currently v2.
  • <api>: one of the listed APIs below, e.g. store, debug, or relay.
  • <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:

FieldTypeInclusionDescription
listenAddressesArray[String]mandatoryListening addresses of the node
enrUriStringoptionalENR 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic being published on
messageWakuMessagemandatoryThe message being relayed

Response

  • Bool - true on 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

FieldTypeInclusionDescription
topicsArray[String]mandatoryThe PubSub topics being subscribed to

Response

  • Bool - true on 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

FieldTypeInclusionDescription
topicsArray[String]mandatoryThe PubSub topics being unsubscribed from

Response

  • Bool - true on 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic to poll for the latest messages

Response

  • Array[WakuMessage] - the latest messages on the polled topic or 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:

FieldTypeInclusionDescription
privateKeyStringmandatoryPrivate key as hex encoded data string
publicKeyStringmandatoryPublic 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic being published on
messageWakuMessagemandatoryThe (unencrypted) message being relayed
symkeyStringmandatoryThe hex encoded symmetric key to use for payload encryption. This field MUST be included if symmetric key cryptography is selected

Response

  • Bool - true on 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic being published on
messageWakuMessagemandatoryThe (unencrypted) message being relayed
publicKeyStringmandatoryThe hex encoded public key to use for payload encryption. This field MUST be included if asymmetric key cryptography is selected

Response

  • Bool - true on 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic to poll for the latest messages
symkeyStringmandatoryThe hex encoded symmetric key to use for payload decryption. This field MUST be included if symmetric key cryptography is selected

Response

  • Array[WakuMessage] - the latest messages on the polled topic or 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

FieldTypeInclusionDescription
topicStringmandatoryThe PubSub topic to poll for the latest messages
privateKeyStringmandatoryThe hex encoded private key to use for payload decryption. This field MUST be included if asymmetric key cryptography is selected

Response

  • Array[WakuMessage] - the latest messages on the polled topic or 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:

FieldTypeInclusionDescription
messagesArray[WakuMessage]mandatoryArray of retrieved historical messages
pagingOptionsPagingOptionsconditionalPaging information from which to resume further historical queries

PagingOptions

pagingOptions is an Object containing the following fields:

FieldTypeInclusionDescription
pageSizeNumbermandatoryNumber of messages to retrieve per page
cursorIndexoptionalMessage 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.
forwardBoolmandatorytrue if paging forward, false if paging backward

Index

Index is an Object containing the following fields:

FieldTypeInclusionDescription
digestStringmandatoryA hash for the message at this Index
receivedTimeNumbermandatoryUNIX timestamp in nanoseconds at which the message at this Index was received

ContentFilter

ContentFilter is an Object containing the following fields:

FieldTypeInclusionDescription
contentTopicStringmandatoryThe 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

FieldTypeInclusionDescription
pubsubTopicStringoptionalThe pubsub topic on which a WakuMessage is published
contentFiltersArray[ContentFilter]optionalArray of content filters to query for historical messages
startTimeNumberoptionalThe inclusive lower bound on the timestamp of queried WakuMessages. This field holds the Unix epoch time in nanoseconds as a 64-bits integer value.
endTimeNumberoptionalThe inclusive upper bound on the timestamp of queried WakuMessages. This field holds the Unix epoch time in nanoseconds as a 64-bits integer value.
pagingOptionsPagingOptionsoptionalPagination information

Response

  • StoreResponse - the response to a query for 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:

FieldTypeInclusionDescription
contentTopicStringmandatorymessage 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

FieldTypeInclusionDescription
contentFiltersArray[ContentFilter]mandatoryArray of content filters being subscribed to
topicStringoptionalMessage topic

Response

  • Bool - true on 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

FieldTypeInclusionDescription
contentFiltersArray[ContentFilter]mandatoryArray of content filters being unsubscribed from
topicStringoptionalMessage topic

Response

  • Bool - true on 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

FieldTypeInclusionDescription
contentTopicStringmandatoryThe content topic to poll for the latest messages

Response

  • Array[WakuMessage] - the latest messages on the polled content topic or 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:

FieldTypeInclusionDescription
multiaddrStringmandatoryMultiaddress containing this peer's location and identity
protocolStringmandatoryProtocol that this peer is registered for
connectedboolmandatorytrue 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

FieldTypeInclusionDescription
peersArray[String]mandatoryArray of peer multiaddrs to connect to. Each multiaddr must contain the location and identity addresses of a peer.

Response

  • Bool - true on 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 and related rights waived via CC0.

References

  1. JSON-RPC specification
  2. LibP2P Addressing
  3. LibP2P PubSub specification - topic descriptor
  4. Waku v2 specification
  5. IETF RFC 4648 - The Base16, Base32, and Base64 Data Encodings

18/WAKU2-SWAP

FieldValue
NameWaku SWAP Accounting
Slug18
Statusdeprecated
EditorOskar Thorén [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-04-188f94e97 — docs: deprecate swap protocol (#31)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-01-310d8ad08 — Update and rename SWAP.md to swap.md
  • 2024-01-313c8410c — 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/BCooperateDefect
Cooperateb-c-c
Defectb0

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/BCooperateDefect
CooperateRS
DefectTP

With R=reward, S=Sucker's payoff, T=temptation, P=punishment

And the following holds:

  • T>R>P>S
  • 2R>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).

  1. Node A performs a handshake with B node. B node responds and both nodes communicate their payment threshold.
  2. Node A and B creates an accounting entry for the other peer, keep track of peer and current balance.
  3. Node A issues a HistoryRequest, and B responds with a HistoryResponse. Based on the number of WakuMessages in the response, both nodes update their accounting records.
  4. 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).
  5. 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 and related rights waived via CC0.

References

  1. Prisoner's Dilemma
  2. Axelrod - Evolution of Cooperation (book)
  3. Book of Swarm
  4. 13/WAKU2-STORE

21/WAKU2-FAULT-TOLERANT-STORE

FieldValue
NameWaku v2 Fault-Tolerant Store
Slug21
Statusdeleted
EditorSanaz Taheri [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-11-04cb4d0de — Update 21/WAKU2-FAULT-TOLERANT-STORE: Deleted (#181)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-01-315da8a11 — Update and rename FAULT-TOLERANT-STORE.md to fault-tolerant-store.md
  • 2024-01-27206133e — 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.
    The messages field of the corresponding HistoryResponse MUST contain historical waku messages whose timestamp is larger than or equal to the start_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. The messages field of the corresponding HistoryResponse MUST contain historical waku messages whose timestamp is less than or equal to the end_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 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.

All Stable Draft Raw Deprecated Deleted
All time Latest Last 90 days
Loading RFC index...
Click a column to sort

Blockchain Raw Specifications

Early-stage Blockchain specifications that have not yet progressed beyond raw status.

BEDROCK-ANONYMOUS-LEADERS-REWARD

FieldValue
NameBedrock Anonymous Leaders Reward Protocol
Slug85
Statusraw
CategoryStandards Track
EditorThomas Lavaur [email protected]
ContributorsDavid Rusu [email protected], Mehmet Gonen [email protected], Álvaro Castro-Castilla [email protected], Frederico Teixeira [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
VoucherA one-time random secret used to claim a block reward anonymously.
Voucher CommitmentA cryptographic commitment (zkhash) to a voucher secret.
Voucher NullifierA unique identifier derived from a voucher, prevents double claims.
Leader Claim OperationA Mantle Operation allowing a leader to claim their reward.
Reward Voucher SetA Merkle tree containing all voucher commitments.
Voucher Nullifier SetA searchable database of nullifiers for claimed vouchers.
Anonymity SetThe 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:

  1. Generate a one-time random secret $voucher \xleftarrow{$} \mathbb{F}_{p}$.

  2. Compute the commitment: voucher_cm := zkHash(b"LEAD_VOUCHER_CM_V1", voucher).

  3. Include the voucher_cm in 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:

  1. Verifying the ZK proof.

  2. Checking that voucher_nf is not already in the voucher nullifier set.

  3. Executing the reward logic:

    • Add the voucher_nf to 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_rewards by the same amount.

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

Informative

Copyright and related rights waived via CC0.

BEDROCK-ARCHITECTURE-OVERVIEW

FieldValue
NameBedrock Architecture Overview
Slug146
Statusraw
CategoryInformational
EditorDavid Rusu [email protected]
ContributorsÁlvaro Castro-Castilla [email protected], Daniel Kashepava [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-300ef87b1 — New RFC: CODEX-MANIFEST (#191)
  • 2026-01-305c123d6 — 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

TerminologyDescription
BedrockThe foundational layer of Nomos composed of Cryptarchia and Bedrock Mantle.
MantleThe minimal execution layer of Nomos for Sovereign Rollups.
CryptarchiaThe Nomos consensus protocol, a Private Proof of Stake (PPoS) protocol.
Sovereign RollupA virtual chain overlaid on top of the Nomos blockchain.
ChannelA permissioned, ordered log of messages forming a virtual chain.
InscriptionA channel message stored permanently in the ledger.
BlobA channel message with only a commitment stored on-chain; data stored in NomosDA.
NomosDAThe Data Availability layer providing temporary storage for Blob data.
Blend NetworkA 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:

  1. Clients submit transactions to the Sovereign Rollup.

  2. The Sovereign Rollup orders, executes, and bundles the transactions into a Blob.

  3. The Sovereign Rollup submits the Blob Mantle Transaction along with DA Shares to NomosDA.

  4. NomosDA begins replicating the Blob and forwards the Blob Mantle Transaction to the Nomos Mempool.

  5. A leader includes the transaction in the next block via Cryptarchia.

  6. NomosDA observes the Blob inclusion on-chain (Blob confirmed).

  7. The client observes their transaction in a Sovereign Rollup Blob included in Nomos (weak confirmation).

  8. The block finalizes after being buried by 2160 blocks.

  9. 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

Informative

Copyright and related rights waived via CC0.

BEDROCK-GENESIS-BLOCK

FieldValue
NameBedrock Genesis Block Specification
Slug90
Statusraw
CategoryStandards Track
EditorDavid Rusu [email protected]
ContributorsHong-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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
Genesis BlockThe first block in the Bedrock chain establishing the initial state.
NMOThe native token of the Nomos network.
Epoch NonceThe source of randomness for the Cryptarchia lottery.
Service ProviderA node participating in DA or Blend network services.
Ledger TransactionA transaction that modifies the token ledger state.
Mantle TransactionA 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:

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.

  1. η: the epoch nonce is taken directly from the genesis_epoch_nonce.

  2. 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.

  3. 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

Informative

Copyright and related rights waived via CC0.

BEDROCK-SERVICE-DECLARATION-PROTOCOL

FieldValue
NameBedrock Service Declaration Protocol
Slug87
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski [email protected]
ContributorsMehmet 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
SDPService Declaration Protocol for node participation in Nomos Services.
DeclarationA message confirming a validator's willingness to provide a specific service.
Service TypeThe type of service being declared (e.g., BN for Blend Network, DA for Data Availability).
Minimum StakeThe minimum amount of stake a node MUST lock to declare for a service.
SessionA fixed-length window defined per service via session_length.
Lock PeriodThe minimum time during which a declaration cannot be withdrawn.
Inactivity PeriodThe maximum time during which an activation message MUST be sent.
Retention PeriodThe time after which a declaration can be safely deleted.
Provider IDAn Ed25519 public key used to sign SDP messages and establish secure links.
ZK IDA public key used for zero-knowledge operations including rewarding.
LocatorThe network address of a validator following the multiaddr scheme.
Declaration IDA 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

  1. A node sends a declaration message for a specific service and proves it has a minimum stake.

  2. The declaration is registered on the blockchain ledger, and the node can commence its service according to the service-specific logic.

  3. After a service-specific service-providing time, the node confirms its activity.

  4. The node MUST confirm its activity with a service-specific minimum frequency; otherwise, its declaration is inactive.

  5. 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 block timestamp.
  • 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 by session_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 by session_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 by session_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 an Ed25519PublicKey.
  • 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: An Ed25519PublicKey used to sign the message by the validator.
  • locked_note_id: A NoteId used for minimum stake threshold verification.
  • zk_id: Used for zero-knowledge operations including rewarding.
  • locators: A copy of the locators from the DeclarationMessage.
  • created: The block number of the block that contained the declaration.
  • active: The latest block number for which the active message was sent (set to created by 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 the declaration_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_id is valid.
  • The declaration_id is unique.
  • The sender knows the secret behind the provider_id identifier.
  • The length of the locators list MUST NOT be longer than 8.
  • The nonce increases 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:

  1. A node sends an ActiveMessage transaction.

  2. The ActiveMessage is verified by the SDP logic:

    1. The declaration_id returns an existing DeclarationInfo.
    2. The transaction containing ActiveMessage is signed by the zk_id.
    3. The withdrawn from the DeclarationInfo is set to zero.
    4. The nonce increases monotonically.
  3. If any of these conditions fail, discard the message and stop processing.

  4. The message is processed by the service-specific activity logic alongside the active value indicating the period since the last active message was sent. The active value comes from the DeclarationInfo.

  5. If the service-specific activity logic approves the node active message, then the active field of the DeclarationInfo is 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:

  1. A node sends a WithdrawMessage transaction.

  2. The WithdrawMessage is verified by the SDP logic:

    1. The declaration_id returns an existing DeclarationInfo.
    2. The transaction containing WithdrawMessage is signed by the zk_id.
    3. The withdrawn from DeclarationInfo is set to zero.
    4. The nonce increases monotonically.
  3. If any of the above is not correct, discard the message and stop.

  4. Set the withdrawn from the DeclarationInfo to the current block height.

  5. 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:

  1. The entry is past the retention period: withdrawn + (retention_period * session_length) < current_block_height.

  2. 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 all provider_ids associated with the timestamp.
  • GetAllProviderIdSince(timestamp): Returns all provider_ids since the timestamp.
  • GetAllDeclarationInfo(timestamp): Returns all DeclarationInfo entries associated with the timestamp.
  • GetAllDeclarationInfoSince(timestamp): Returns all DeclarationInfo entries since the timestamp.
  • GetDeclarationInfo(provider_id): Returns the DeclarationInfo entry identified by the provider_id.
  • GetDeclarationInfo(declaration_id): Returns the DeclarationInfo entry identified by the declaration_id.
  • GetAllServiceParameters(timestamp): Returns all entries of the ServiceParameters store for the timestamp.
  • GetAllServiceParametersSince(timestamp): Returns all entries of the ServiceParameters store since the timestamp.
  • GetServiceParameters(service_type, timestamp): Returns the service parameter entry for a service_type at a timestamp.
  • GetMinStake(timestamp): Returns the MinStake structure at the requested timestamp.
  • GetMinStakeSince(timestamp): Returns a set of MinStake structures 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:

  • DeclarationMessage MUST be signed by both provider_id and zk_id.
  • ActiveMessage MUST be signed by zk_id.
  • WithdrawMessage MUST be signed by zk_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

Informative

Copyright and related rights waived via CC0.

BEDROCK-SERVICE-REWARD-DISTRIBUTION

FieldValue
NameBedrock v1.2 Service Reward Distribution Protocol
Slug86
Statusraw
CategoryStandards Track
EditorThomas Lavaur [email protected]
ContributorsDavid Rusu [email protected], Mehmet Gonen [email protected], Marcin Pawlowski [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
SessionA fixed number of blocks during which the validator set remains unchanged.
Service ValidatorA node participating in a service (DA or Blend Network).
Activity MessageA signed message attesting to a validator's participation.
zk_idThe zero-knowledge identity of a validator from SDP declarations.
SDP_ACTIVEA 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:

  1. 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).

  2. 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.

  3. 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_id from 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_id of 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

Informative

Copyright and related rights waived via CC0.

BEDROCK-V1-1-BLOCK-CONSTRUCTION

FieldValue
NameBedrock v1.1 Block Construction, Validation and Execution Specification
Slug93
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski [email protected]
ContributorsThomas 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
LeaderA node elected through the leader lottery to construct a new block.
Block BuilderThe leader node that constructs a new block proposal.
Block ProposerThe leader node that shares the constructed block with other network members.
Block ProposalA 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 MaturityThe assumption that transactions have had enough time to spread across the network.
ValidatorA 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.

  1. A leader is selected. The leader becomes a block builder.

  2. The block builder constructs a block proposal.

    1. The block builder selects the latest block (parent) as the reference point for the chain state update.

    2. 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.

    3. The block builder selects valid Mantle Transactions (as defined in Mantle Specification) from its mempool and includes references to them in the proposal.

    4. The block builder populates the block header of the block proposal.

  3. The block proposer sends the block proposal to the Blend network.

  4. The validators receive the block proposal.

  5. The validators validate the block proposal.

    1. They validate the block header.

    2. 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.

    3. They retrieve complete transactions from their mempool that are referred in the block.

    4. They validate each transaction included in the block.

  6. The validators execute the block proposal.

    1. They derive the new blockchain state from the previous one by executing transactions as defined in Mantle Specification.

    2. 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:

  • header is the header of the proposal; defined below: Header.
  • references is a set of 1024 references to transactions of a hash type; the size of the hash type is 32 bytes and is the transaction hash as defined in Mantle Specification - Mantle Transaction.
  • signature is the signature of the complete header using the leader_key from the ProofOfLeadership; the size of the Ed25519Signature type is 64 bytes.

Note: The length of the references list must be preserved to maintain the message's indistinguishability in the Blend protocol. Therefore, the list must be padded with zeros when necessary.

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_version is 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 to 0x01.
  • parent_block is 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 the AgedLedger and LatestLedger values necessary for validating the PoL; the size of the hash is 32 bytes.
  • slot is the consensus slot number; the size of the SlotNumber type is 8 bytes.
  • block_root is the root of the Merkle tree constructed from transaction hashes (defined in Mantle Specification - Mantle Transaction) used for constructing the references list in the transactions; the size of the hash is 32 bytes.
  • proof_of_leadership is 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_reward is a set of up to 1 reference to a reward transaction of a zkhash type; the size of the zkhash type is 32 bytes and is the transaction hash as defined in Mantle Specification - Mantle Transaction.
  • mempool_transactions is a set of up to 1024 references to transactions of a zkhash type; the size of the zkhash type 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_voucher is the voucher value used for retrieving the reward by the leader for proposal; the size of the RewardVoucher is 32 bytes.
  • entropy_contribution is the output of the PoL contribution for Cryptarchia entropy; the size of the zkhash type is 32 bytes.
  • proof is the proof confirming that the proposal is constructed by the leader; the size of the ProofOfLeadership type is 128 bytes (2 compressed G1 and 1 compressed G2 BN256 elements).
  • leader_key is the one-time Ed25519PublicKey used for signing the Proposal. This binds the content of the proposal with the ProofOfLeadership; the size of the Ed25519PublicKey type 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:

  1. Select a valid parent block referenced by ParentBlock on which they will extend the chain.

  2. Derive the required Ledger state snapshots AgedLedger and LatestLedger from the state of the chain including the last block.

  3. Select a valid unspent note winning the PoL.

  4. 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

  1. Initialize proposal metadata with the last known state of the blockchain. Set the:

    • header:
      • bedrock_version
      • parent_block
      • slot
      • block_root
      • proof_of_leadership:
        • leader_voucher
        • entropy_contribution
        • proof
        • leader_key
  2. Construct the service_reward object:

    1. 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_reward list. This transaction must be computed locally, do not disseminate this transaction.
  3. Construct the mempool_transactions object:

    1. Select Mantle transactions:

      • Choose up to 1024-len(service_reward) valid SignedMantleTx from 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).

  4. Derive references values:

    references: list[zkhash] = [mantle_txhash(tx) for tx in service_reward + mempool_transactions]
    
  5. Compute the header.block_root as the root of the Merkle tree constructed from the list(service_reward) + mempool_transactions transactions used to build references.

  6. Sign the block proposal header.

    signature = Ed25519.sign(leader_secret_key, header)
    
  7. 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:

  1. Transaction is added to the node mempool.

  2. Node sends the transaction to all its neighbors.

  3. Neighbors add the transaction to their own mempools and propagate it to their neighbors—transaction is gossiped throughout the network.

  4. Block builder selects a transaction from its local mempool, which is guaranteed to be propagated through the network due to steps 1-3.

  5. Block builder constructs a block proposal with references to selected transactions.

  6. 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.

  7. Block proposal is received by validators.

  8. Validators check their local mempools for all referenced transactions from the proposal.

  9. If any transaction is missing, the entire proposal is rejected.

  10. 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

  1. 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 * 4 reward notes distributed to the correct validators.

  2. 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:

  1. Append the leader_voucher contained in the block to the set of reward vouchers when the following epoch starts.

  2. 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

  1. 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.

  2. The verifier draws a random value for each sample r_i ← $F_p.

  3. The verifier computes:

    1. C' := Σ(i=1 to k) r_i · C^(i)

    2. v' := Σ(i=1 to k) r_i · v^(i)

    3. π' := Σ(i=1 to k) r_i · π^(i)

    4. u' := Σ(i=1 to k) r_i · u^(i) · π^(i)

  4. They test if e(C' - v' · G1 + u', G2) = e(π', τ · G2).

Proofs of Claim

  1. 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.

  2. The verifier draws one random value for each proof r_i ← $F_p.

  3. The verifier computes:

    1. π'_j := Σ(i=1 to k) r_i · π_j^(i) for j ∈ {A, B, C}.

    2. r' := Σ(i=1 to k) r_i

    3. IC := r' · Ψ_0 + Σ(j=1 to l) (Σ(i=1 to k) r_i · x_j^(i)) · Ψ_j

  4. 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

Informative

Copyright and related rights waived via CC0.

BEDROCK-V1.1-MANTLE-SPECIFICATION

FieldValue
NameBedrock v1.1 Mantle Specification
Slug98
Statusraw
CategoryInformational
EditorThomas Lavaur [email protected]
ContributorsDavid Rusu [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

TerminologyDescription
MantleThe foundational execution layer of Bedrock that connects Nomos Services for Sovereign Rollups.
Mantle TransactionA transaction containing zero or more Operations and one Ledger Transaction.
OperationAn action within a Mantle Transaction that interacts with Nomos Services.
Ledger TransactionA transaction component that manages asset transfers using a transparent UTXO model.
NoteA UTXO-like asset unit composed of a value and a public key (owner).
Locked NoteA Note serving as collateral for Service Declarations, locked until withdrawal.
ChannelA virtual chain overlaying the Cryptarchia blockchain for Rollup updates.
SDPService Declaration Protocol for node participation in Nomos Services.
ZkSignatureZero Knowledge Signature proving ownership without revealing the private key.
NMOThe native token of the Nomos network.
DAData 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 MarketCharged OnPricing Basis
Execution GasLedger Transaction and OperationsFixed per Operation
Permanent Storage GasSigned Mantle TransactionProportional to encoded size
DA Storage GasBlob OperationProportional 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:

  1. The ledger transaction is valid according to Ledger Validation.
validate_ledger_tx(ledger_tx, ledger_tx_proof, mantle_txhash(tx))
  1. There is a proof or a None value for each operation.
assert len(op_proofs) == len(ops)
  1. 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 == ...
    # ...
  1. 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:

  1. Execute the Ledger Transaction as described in Ledger Validation.

  2. Execute sequentially each Operation in ops according to its opcode.

Mantle Operations

Opcodes

OperationOpcodeDescription
CHANNEL_INSCRIBE0x00Write a message permanently onto Mantle.
CHANNEL_BLOB0x01Store a blob in DA.
CHANNEL_SET_KEYS0x02Manage the list of keys accredited to post to a channel.
RESERVED0x03 - 0x1F
SDP_DECLARE0x20Declare intention to participate as a node in a Nomos Service, locking funds as collateral.
SDP_WITHDRAW0x21Withdraw participation from a Nomos Service, unlocking your funds in the process.
SDP_ACTIVE0x22Signal that you are still an active participant of a Nomos Service.
RESERVED0x23 - 0x2F
LEADER_CLAIM0x30Claim leader reward anonymously.
RESERVED0x31 - 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:

  1. 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]
    )
  1. 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.

  1. Ensure ownership over the locked note, zk_id and provider_id.
assert ZkSignature_verify(
    txhash,
    proof.zk_sig,
    [note.public_key, declaration.zk_id]
)
assert Ed25519_verify(txhash, proof.provider_sig, provider_id)
  1. Ensure declaration does not already exist.
assert declaration_id(declaration) not in declarations
  1. Ensure it has no more than 8 locators.
assert len(declaration.locators) <= 8
  1. 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
  1. 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:

  1. 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]
  1. 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)
  1. Add this declaration to the locked note.
declare_id = declaration_id(declaration)
locked_note.declarations.add(declare_id)
  1. 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:

  1. 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
  1. Ensure that the locked note has expired.
assert locked_note.locked_until <= block_height
  1. 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.

  1. Update declaration info with nonce and withdrawn timestamp.
declare_info = declarations[withdraw.declaration]
declare_info.nonce = withdraw.nonce
declare_info.withdrawn = block_height
  1. Remove this declaration from the locked note.
locked_note = locked_notes[withdraw.locked_note_id]
locked_note.declarations.remove(withdraw.declaration)
  1. 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

  1. Add claim.voucher_nf to the voucher_nullifier_set.

  2. Increase the balance of the Mantle Transaction by the leader reward amount according to NOMOS-ANONYMOUS-LEADERS-REWARD-PROTOCOL - Leaders Reward.

  3. Reduce the leader's reward leaders_rewards value 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:

  1. Ensure all inputs are unspent.
assert all(ledger.is_unspent(note_id) for note_id in ledger_tx.inputs)
  1. 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)
  1. Ensure inputs are not locked.
# Ensure inputs are not locked
for note_id in ledger_tx.inputs:
    assert note_id not in locked_notes
  1. 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:

  1. 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)
  1. 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:

VariableValue
EXECUTION_LEDGER_TX_GAS590
EXECUTION_CHANNEL_INSCRIBE_GAS56
EXECUTION_CHANNEL_BLOB_BASE_GAS6356
EXECUTION_CHANNEL_BLOB_SIZED_GAS1600
EXECUTION_CHANNEL_SET_KEYS56
EXECUTION_SDP_DECLARE_GAS646
EXECUTION_SDP_WITHDRAW_GAS590
EXECUTION_SDP_ACTIVE_GAS590
EXECUTION_LEADER_CLAIM_GAS580

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 (the mantle_tx_hash in 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

Informative

Copyright and related rights waived via CC0.

CRYPTARCHIA-FORK-CHOICE

FieldValue
NameCryptarchia Fork Choice Rule
Slug147
Statusraw
CategoryStandards Track
EditorDavid Rusu [email protected]
ContributorsJimmy Debe [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-300ef87b1 — New RFC: CODEX-MANIFEST (#191)
  • 2026-01-29a428c03 — 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

TermDescription
$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.
CommonPrefixDepthA 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.
densityA 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:

  1. A node has successfully bootstrapped and found the honest chain.
  2. 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

  1. $\textbf{CommonPrefixDepth}(b_1, b_2) = (0, 4)$ implies that $b_2$ is ahead of $b_1$ by 4 blocks.

CommonPrefixDepth Example 1

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

CommonPrefixDepth Example 2

  1. $\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.

Density Example

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

Informative

Copyright and related rights waived via CC0.

CRYPTARCHIA-PROOF-OF-LEADERSHIP

FieldValue
NameCryptarchia Proof of Leadership Specification
Slug83
Statusraw
CategoryStandards Track
EditorThomas Lavaur [email protected]
ContributorsMehmet [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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. Draw uniformly a random seed.
  2. Construct a Merkle tree composed of slot secrets derived from the seed.
  3. 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.
  4. 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.
  5. 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:

  1. First, check if the note is winning by simulating the lottery.
  2. Prove the membership of the note identifier in an old snapshot of the Mantle Ledger, proving its age and its existence.
  3. Prove the membership of the note identifier in the most recent Mantle ledger, proving it's unspent.
  4. Prove that the note won the PoS lottery.
  5. Prove the knowledge of the slot secret for the winning slot.
  6. 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:

  1. 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.
  2. 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:

  1. 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:

  1. The public key of the note will change periodically (each time all slot secrets are consumed) for the ones participating in PoL.
  2. 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:

  1. Uniformly randomly draw a seed $r_1 \xleftarrow{$} \mathbb{F}_p$.

  2. 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.
  3. 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.

  4. 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$.

  1. To do that, they will give a Merkle path from the leaf at index $sl - sl_{start}$.
  2. 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

Proof of Leadership Circuit Diagram

Circuit Public Inputs

The prover (the leader) and the verifiers (nodes of the chain) MUST agree on these values:

  1. The slot number: $sl$.

  2. The epoch nonce: $\eta$.

    • For details see Cryptarchia v1 Protocol Specification - Epoch Nonce.
  3. 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.
  4. 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.
  5. The latest root of the note Merkle tree: $ledger_{LATEST}$.

    • Used to ensure the leadership note has not been spent.
  6. 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.
  7. 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:

  1. 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}$.
  2. 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$.
  3. 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:

  1. 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.

  2. The derivation of $sk = \text{hash}(\text{NOMOS_POL_SK_V1}||sl_{start}||R)$, as documented in Protection Against Adaptive Adversaries.

  3. The computation of the note identifier.

  4. The note identifier is in $ledger_{AGED}$ and $ledger_{LATEST}$.

  5. The computation of the lottery ticket: $ticket := \text{hash}(\text{LEAD_V1}||\eta||sl||noteID||sk)$ using Poseidon2.

  6. 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.

  7. The check that indeed $ticket < t$.

  8. 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:
VariableFormula
$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 errororder 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

Average proving time vs number of threads (100 Runs)

References

Normative

Informative

Copyright and related rights waived via CC0.

CRYPTARCHIA-TOTAL-STAKE-INFERENCE

FieldValue
NameCryptarchia Total Stake Inference
Slug94
Statusraw
CategoryStandards Track
EditorDavid Rusu [email protected]
ContributorsAlexander Mozeika [email protected], Daniel Kashepava [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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 beta give 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 k on 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 and related rights waived via CC0.

References

Normative

Informative

CRYPTARCHIA-V1-BOOTSTRAPPING-SYNCHRONIZATION

FieldValue
NameCryptarchia v1 Bootstrapping & Synchronization
Slug96
Statusraw
CategoryStandards Track
EditorYoungjoon Lee [email protected]
ContributorsDavid Rusu [email protected], Giacomo Pasini [email protected], Álvaro Castro-Castilla [email protected], Daniel Sanchez Quiros [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

ConstantNameDescriptionValue
$T_\text{offline}$Offline Grace PeriodA period during which a node can be restarted without switching to the Bootstrap rule.20 minutes
$T_\text{boot}$Prolonged Bootstrap PeriodA 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 WindowA 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 --bootstrap flag).

    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

Informative

Copyright and related rights waived via CC0.

NOMOS-BLEND-PROTOCOL

FieldValue
NameNomos Blend Protocol
Slug95
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski
ContributorsAlexander 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. Unlinkability: Block proposers cannot be linked to their proposals through network analysis
  2. 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

TermDescription
Data messageA message generated by a consensus leader containing a block proposal. Indistinguishable from other messages until fully processed.
Cover messageA message with meaningless content that creates noise for data messages to hide in. Indistinguishable from data messages.
Core nodeA Nomos node that declared willingness to participate in Blend Network through SDP. Responsible for message generation, relaying, processing, and broadcasting.
Edge nodeA Nomos node that is not a core node. Connects to core nodes to send messages.
Block proposer nodeA core or edge node generating a new data message.
Blend nodeA core node that processes a data or cover message.
BlendingCryptographically transforming and randomly delaying messages to shuffle temporal order.
BroadcastingSending a data message payload (block proposal) to all Nomos nodes.
DisseminatingRelaying messages by core nodes through the network.
Epoch648,000 slots (each 1 second), with average 21,600 blocks per epoch.
SessionTime period with same set of core nodes executing the protocol. Length follows epoch length (21,600 blocks average).
RoundPrimitive time measure (1 second) during which a node can emit a new message.
Interval30 rounds, approximating time between two consecutive block production events.
Blending tokenInformation extracted from processed messages, used as proof of processing for rewards.

Node Types

TypeDescription
Honest nodeFollows the protocol fully.
Lazy nodeDoes not follow protocol due to lack of incentives; only participates when directly beneficial.
Spammy nodeEmits more messages than protocol expects.
Unhealthy nodeEmits fewer messages than expected (may be under attack).
Malicious nodeDoes not follow protocol regardless of incentives.
Unresponsive nodeDoes not follow protocol due to technical reasons.

Adversary Types

TypeDescription
Passive adversaryCan only observe, cannot modify node behavior.
Active adversaryCan modify node behavior and observe network.
Local observerPassive 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:

  1. Clarity of Requirements: Implementers can clearly distinguish between mandatory requirements for interoperability (Part I) and optional optimizations (Part II)
  2. Protocol Evolution: The core protocol specification (Part I) can remain stable while implementation guidance (Part II) evolves with new techniques and optimizations
  3. Multiple Implementations: Different implementations can make different trade-offs in Part II while maintaining full compatibility through adherence to Part I
  4. Audit Focus: Security auditors can concentrate on the normative requirements in Part I that are critical for the protocol's privacy guarantees
  5. 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:

  1. Core nodes form a network by establishing encrypted connections with other core nodes at random
  2. 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
  3. The block proposer sends the data message to its neighbors (or connects to random core nodes if edge node)
  4. Core nodes disseminate (relay) the message to the rest of the network
  5. Core nodes generate new cover messages every round, blended with other messages
  6. 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)
  7. The blend node disseminates the processed message so next blend node can process it
  8. 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:

  1. All core nodes retrieve the set of participating core nodes from SDP protocol
  2. Each core node establishes encrypted connections with randomly selected core nodes
  3. 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:

  1. Core Quota: Limits cover message generation and blending operations for core nodes during a session
  2. 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:

  1. Public Header (H): Contains public key, proof of quota, and signature
  2. Encrypted Private Header (h): Contains blending headers for each hop, with proofs of selection
  3. Payload (P): The actual content (block proposal or cover message data)

Message Lifecycle

Messages follow a defined lifecycle through the network:

  1. Generation: Triggered by consensus lottery (data) or schedule (cover)
  2. Relaying: Nodes validate and forward messages to neighbors
  3. Processing: Designated nodes decrypt and extract next-hop information
  4. Delaying: Random delays hide timing correlations
  5. Releasing: Messages released according to delay schedule
  6. 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:

  1. Message Processing: Nodes collect blending tokens as proof of work
  2. Activity Proof: Probabilistic attestation using Hamming distance
  3. 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:

  1. All core nodes retrieve fresh set of core nodes' connectivity information from SDP protocol
  2. Each core node selects at random a set of other core nodes and connects through fully encrypted connections
  3. After all core nodes connect, a new network is formed

Detailed Bootstrapping Procedure

  1. Core node retrieves set of core nodes' information from SDP protocol at session start
  2. If number of core nodes is below minimum (32), stop and use regular broadcasting
  3. 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)
  4. Repeat until connected to minimal core peering degree (4 by default, both incoming and outgoing count)
  5. 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)
  6. 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

Connectivity Maintenance Implementation

Core nodes monitor connection quality by verifying message correctness and frequency:

  1. Count messages after successful connection-level decryption during observation window (30 rounds)
  2. If frequency exceeds maximum: mark neighbor as spammy, close connection, establish new one
  3. If frequency below minimum: mark connection as unhealthy, establish additional connection
  4. Unhealthy connections are monitored continuously and may recover
  5. If maximum connections exceeded: log situation, pause new connections until below maximum
  6. Edge nodes MUST send message immediately after connection then close; otherwise core node closes connection
  7. Messages with invalid proof of quota or signature from core node: mark as malicious, close connection
  8. 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:

  1. Retrieve set of core nodes from SDP at session start
  2. If below minimum size (32), stop and use regular broadcasting
  3. When needing to send message, select random core node
  4. Establish secure TLS connection
  5. Identify and authenticate using NDP
  6. Send message and close connection
  7. 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:

  1. Data message: Core/edge node won consensus lottery and has proof of leadership
  2. Cover message: Released at random by core node per Cover Message Schedule

Generation process:

  1. Generate keys according to Key Types and Generation Specification
    • Each key uses message-type-specific allowance (quota)
    • Correct usage proven by Proof of Quota
  2. Format payload according to Payload Formatting Specification
  3. 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
  4. Format message according to Message Formatting Specification
  5. Release message according to Releasing logic

Relaying Details

When node receives message from neighbor:

  1. Check public header:
    • Version MUST equal 0x01
    • Proof of quota MUST be valid
    • Signature MUST be valid
    • Public key MUST be unique
  2. Release message to network (Releasing section)
  3. 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:

  1. Decapsulate message per Message Encapsulation Mechanism
  2. 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
  3. 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:

  1. Select random delay: δ ∈ (1, Δ_max)
  2. Start counting rounds from r_s
  3. 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:

  1. Verify payload contains valid block proposal structure (proposal not validated yet)
  2. Extract block proposal
  3. 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:

  1. Calculate maximum cover messages: c = ⌈Q_C / β_C⌉
  2. 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
  3. 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:

  1. Message generation: Especially for cover messages (data messages rewarded through consensus)
  2. Message relaying: Motivated by connection quality monitoring (fear of losing reward)
  3. Message processing: Motivated by collecting blending tokens (activity-based reward)
  4. 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:

  1. No calculation if number of nodes from SDP below Minimal Network Size (32)
  2. Count base proofs: B = number of true activity proofs
  3. Count premium proofs: P = number of true activity proofs with minimal Hamming distance
  4. Calculate base reward: R = I / (B + P), where I = service income for session s
  5. 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

  1. 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
  2. Active Message sent after end of session s (during s+1), after transition period
    • Delay allows including tokens from transition period
  3. When session s+2 begins, Mantle distributes rewards per Service Reward Distribution Protocol
    • Delay required to calculate reward partition
  4. 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

Informative

Copyright and related rights waived via CC0.

NOMOS-CRYPTARCHIA-V1-PROTOCOL

FieldValue
NameNomos Cryptarchia v1 Protocol Specification
Slug92
Statusraw
CategoryStandards Track
EditorDavid 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

SymbolNameDescriptionValue
$f$slot activation coefficientThe 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 parameterBlock depth finality. Blocks deeper than $k$ on any given chain are considered immutable.2160 blocks
noneslot lengthThe duration of a single slot.1 second
MAX_BLOCK_SIZEmax block sizeThe maximum size of the block body (not including the header)1 MB
MAX_BLOCK_TXSmax block transactionsThe maximum number of transactions in a block1024

Notation

SymbolNameDescriptionValue
$s$slot security parameterSufficient slots such that $k$ blocks have been produced with high probability.$3\lfloor \frac{k}{f}\rfloor$
$T$the block treeThis 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 chainThe chain that a node considers to be the honest chain.$c_{loc} \in F_{T}$
$B_\text{imm}$the latest immutable blockThe latest block which was committed (finalized) by the chain maintenance.$B_\text{imm} \in \textbf{ancestors}(c_{loc})$
$sl$slot numberIndex of slot. $sl=0$ denotes the genesis slot.$sl=0,1,2,3,\dots$
$ep$epoch numberIndex 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 PhasePhase LengthDescription
Stake Distribution Snapshot$s$ slotsA 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$ slotsAfter 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$ slotsOn 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.

SymbolNameDescriptionValue
$\mathbb{C}_{\text{LEAD}}$Eligible Leader Notes CommitmentA commitment to the set of notes eligible for leadership.See Eligible Leader Notes
$\eta$Epoch NonceRandomness 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.

  1. header.version.bedrock_version = 1 Ensure bedrock version number.

  2. bytes(transactions) < MAX_BLOCK_SIZE Ensure block size is smaller than the maximum allowed block size.

  3. length(transactions) < MAX_BLOCK_TXS Ensure the number of transactions in the block is below the limit.

  4. merkle_root(transactions) = header.block_root Ensure block root is over the transaction list.

  5. header.slot > fetch_header(header.parent_block).slot Ensure the block's slot comes after the parent block's slot.

  6. 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.

  7. header.parent ∈ T Ensure the block's parent has already been accepted into the block tree.

  8. 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 with is_ancestor(B_imm, B), which checks whether $B_\text{imm}$ is an ancestor of $B$.

  9. 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
  10. A leader's proposal is valid if:

    • verify_PoL(T, parent, sl, P_LEAD, π_PoL) = True
    • verify_signature(block_id(H), σ, P_LEAD) = True Ensure 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

Informative

Copyright and related rights waived via CC0.

NOMOS-DA-NETWORK

FieldValue
NameNomosDA Network
Slug136
Statusraw
EditorDaniel 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-2551ef4cd — 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

  1. The NomosDA protocol is initiated by executors who perform data encoding as outlined in the Encoding Specification.
  2. Executors prepare and distribute each encoded data portion to its designated subnetwork (from 0 to num_subnets - 1 ).
  3. Executors might opt to perform sampling to confirm successful dispersal.
  4. Post-dispersal, executors publish the dispersed blob_id and 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

  1. Sampling is invoked based on the node's current role.
  2. The node selects sample_size random 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.
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

Copyright and related rights waived via CC0.

NOMOS-DIGITAL-SIGNATURE

FieldValue
NameNomos Digital Signature
Slug
Statusraw
CategoryStandards Track
EditorJimmy Debe [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-3099ca13a — 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

TermDescription
EdDSAEdwards-curve Digital Signature Algorithm, a signature scheme based on twisted Edwards curves.
Ed25519An instance of EdDSA using Curve25519, providing 128-bit security.
ZKSignatureA zero-knowledge signature scheme that proves knowledge of a secret key without revealing it.
ProverAn entity that generates a cryptographic proof or signature.
VerifierAn entity that validates a cryptographic proof or signature.
Public KeyThe publicly shareable component of a key pair, used for verification.
Secret KeyThe 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:

  1. The number of secret keys is equal to the number of public keys:
assert len(secret_keys) == len(public_keys)
  1. 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

Copyright and related rights waived via CC0.

NOMOS-KEY-TYPES-GENERATION

FieldValue
NameNomos Key Types and Generation
Slug84
Statusraw
CategoryStandards Track
EditorMehmet Gonen [email protected]
ContributorsMarcin 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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_id field in DeclarationInfo)
  • 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_id field in DeclarationInfo)
  • 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_id field from Nomos blockchain ledger
  • Purpose: Deriving shared secrets for message encryption
  • Lifecycle: Non-ephemeral (persistent across sessions)

Derivation Process:

  1. Retrieve NSK (Ed25519) public key from provider_id on Nomos blockchain ledger
  2. Derive X25519 curve key from Ed25519 public key
  3. 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 TypeAlgorithmStorageLifecyclePrimary UseDerived From
NQKZkSignatureNomos blockchain (zk_id)Non-ephemeralCore node proofGenerated
NSKEd25519Nomos blockchain (provider_id)Non-ephemeralAuthenticationGenerated
NEKX25519DerivedNon-ephemeralShared secret derivationNSK public key
ESKEd25519MemoryEphemeralMessage signingGenerated (PoQ-limited)
EEKX25519MemoryEphemeralMessage encryptionESK

Implementation Requirements

Implementations of this specification MUST:

  1. Generate NQK as ZkSignature and store in DeclarationInfo.zk_id
  2. Generate NSK as Ed25519 and store in DeclarationInfo.provider_id
  3. Derive NEK from NSK using Ed25519 to X25519 conversion
  4. Generate unique ESK per encapsulation, limited by PoQ
  5. Derive EEK from ESK using Ed25519 to X25519 conversion
  6. Never reuse ephemeral keys across encapsulations
  7. Verify PoQ includes valid NQK before generating ESK

Implementations SHOULD:

  1. Securely erase ephemeral keys after use
  2. Implement key generation auditing
  3. Validate all derived keys before use
  4. 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 DeclarationInfo on 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 and related rights waived via CC0.

NOMOS-MESSAGE-ENCAPSULATION

FieldValue
NameNomos Message Encapsulation Mechanism
Slug91
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski
ContributorsYoungjoon 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. $V$, version of the header, it is set to $1$.
  2. $K^{n}_i$, a public key from the set $\mathbf K^n_h$.
  3. $\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.
  4. $\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:

  1. $K^{n}_{l}$, a public key from the set $\mathbf K^n_h$.
  2. $\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.
  3. $\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}$.
  4. $\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}$.
  5. $\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:

  1. session, core_quota, leader_quota, core_root, pol_epoch_nonce, pol_t0, pol_t1, pol_ledger_aged are retrieved from the blockchain.
  2. 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:

  1. If the message contains cover traffic then:
    1. The core quota is used and the selector=0 value must be specified.
    2. The index counts the number of cover messages and must be below core_quota.
    3. The core_sk, core_path, core_path_selector are filled by the node to prove that the node is the core node.
    4. The rest of the ProofOfQuotaWitness, is filled with arbitrary data.
  2. If the message contains data then:
    1. The leader quota is used and the selector=1 value must be specified.
    2. The index counts the number of data messages and must be below leader_quota.
    3. The core_sk, core_path, core_path_selector are filled with arbitrary data.
    4. 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:

  1. $t=\beta_{max} - i + 1$
  2. $r_{t,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|1)){|K|}$
  3. $r_{t,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|2)){|\pi^{K}_{Q}|}$
  4. $r_{t,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|3)){|\sigma_{K}(\mathbf P)|}$
  5. $r_{t,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|4)){|\pi^{K,k}_{S}|}$
  6. $\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:

  1. If $i=1$ then generate a new ephemeral key pair: $(K^n_0, k^n_0) \notin \mathbf K^n_h$.
  2. 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})$.
  3. 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))$.
  4. 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.
  5. Fill the blending header $\mathbf b_1$, where $1$ refers to the top position:
    1. If $i=1$ then:
      1. Fill the proof of quota with random data: $\pi^{K^{n}0}{Q}= \text {CSPRBG}(H_\mathbf{I}(k^{n}0)){|\pi^{K}_{Q}|}$
      2. Set the last flag to 1: $\Omega=1$
    2. Else set the last flag to 0: $\Omega = 0$
    3. $\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 }$.
  6. 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:

  1. 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$.
  2. 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))$.
  3. Verify the header:
    1. 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.
    2. If the key $K^{n}_l \in \mathbf b_1$ was already seen, discard the message.
    3. If the proof $\pi^{K^{n}l,l}{Q} \in \mathbf b_1$ is incorrect, discard the message.
  4. 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}$.
  5. 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))$.
  6. Reconstruct the blend header:
    1. $r_{l,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|1)){|K|}$
    2. $r_{l,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|2)){|\pi^{K}_{Q}|}$
    3. $r_{l,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|3)){|\sigma_{K}(\mathbf P)|}$
    4. $r_{l,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|4)){|\pi^{K,k}_{S}|}$
    5. $b = { r_{l,1}, r_{l,2}, r_{l,3}, r_{l,4} }$.
  7. Encrypt the blending header: $\hat b = E_{H_\mathbf{b}(\kappa^{n,{l}}_{l})}(b)$.
  8. 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.
  9. Reconstruct the private header: $\mathbf h_{E_{l}} = {$ $…$ $\mathbf{b}{\beta{max}} = \hat b$, $}$.
  10. 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})$.
  11. The message is decapsulated.
  12. 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:

  1. Generate a new ephemeral key pair: $(K^n_0, k^n_0) \notin \mathbf K^n$.
  2. Calculate the signature of the header and the payload: $\sigma_{K^{n}0}(\mathbf{h}{E_0}| \mathbf{P}_0)$.
  3. 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)$.
  4. Shift blending headers by one down: $\mathbf h_1 = {$ $\mathbf b_1 = \empty$, ... $}$.
  5. Fill the first blending header with signature, proof, and flag.
  6. 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:

  1. Calculate shared secret: $\kappa^{n,l_3}{3}=K^n{3} \cdot p^{l_3}$
  2. Decrypt the header: $\mathbf h_{l_3} = D_{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf{h})$
  3. Verify the header (proof of selection, key novelty, proof of quota)
  4. Reconstruct the public header
  5. Decrypt the payload: $\mathbf{P}{l_3} = D{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf P)$
  6. Reconstruct the blend header with pseudo-random values
  7. Encrypt the reconstructed blend header
  8. Shift blending headers by one upward
  9. Reconstruct the private header
  10. Verify the signature
  11. Message is decapsulated
  12. 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

Informative

Copyright and related rights waived via CC0.

NOMOS-MESSAGE-FORMATTING

FieldValue
NameNomos Message Formatting Specification
Slug89
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski
ContributorsYoungjoon Lee [email protected], Alexander Mozeika [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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

Informative

Copyright and related rights waived via CC0.

NOMOS-P2P-NETWORK

FieldValue
NameNomos P2P Network Specification
Slug135
Statusdraft
Categorynetworking
EditorDaniel Sanchez-Quiros [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-25a3a5b91 — 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:

  1. Support diverse hardware: From laptops to dedicated servers across various operating systems and geographic locations
  2. Enable inclusive participation: Allow non-technical users to operate nodes with minimal configuration
  3. Maintain connectivity: Ensure nodes remain reachable even with limited connectivity or behind NAT/routers
  4. Scale efficiently: Support large-scale networks (+10k nodes) with eventual consistency
  5. 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:

  1. Connect to known bootstrap nodes
  2. Obtain initial peer list through Kademlia
  3. Establish gossipsub connections
  4. 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:

  1. Kademlia DHT for peer discovery
  2. Identify protocol for peer information exchange
  3. 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

  1. Network Stability: Prevents misconfigurations that could fragment the network
  2. Performance Optimization: Parameters are tuned for the target network size and latency requirements
  3. Security: Reduces attack surface by limiting configurable network parameters
  4. Simplicity: Eliminates need for operators to understand complex P2P tuning

Security Considerations

Network-Level Security

  1. Peer Authentication: Utilize libp2p's built-in peer identity verification
  2. Message Validation: Implement application-layer message validation
  3. Rate Limiting: Protect against spam and DoS attacks
  4. Blacklisting: Mechanism for excluding malicious peers

Privacy Considerations

  1. Traffic Analysis: Gossipsub provides some resistance to traffic analysis
  2. Metadata Leakage: Minimize identifiable information in protocol messages
  3. Connection Patterns: Randomize connection timing and patterns

Denial of Service Protection

  1. Resource Limits: Impose limits on connections and message rates
  2. Peer Scoring: Implement reputation-based peer management
  3. 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.

  1. libp2p Specifications
  2. QUIC Protocol Specification
  3. Kademlia DHT
  4. Gossipsub Protocol
  5. Identify Protocol
  6. Nomos Implementation - Reference implementation and source code
  7. Nomos Node Configuration - Example node configuration

Copyright and related rights waived via CC0.

NOMOS-PAYLOAD-FORMATTING

FieldValue
NameNomos Payload Formatting Specification
Slug97
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski [email protected]
ContributorsYoungjoon Lee [email protected], Alexander Mozeika [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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 body
  • body_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 message
  • body_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:

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:

  1. Decrypt and extract the payload
  2. Parse the 3-byte header
  3. Validate body_type is 0x00 or 0x01
  4. Validate body_length ≤ MAX_BODY_LENGTH
  5. Extract raw_message using body_length
  6. Process or discard based on body_type

References

Normative

Informative

Copyright and related rights waived via CC0.

NOMOS-PROOF-OF-QUOTA

FieldValue
NameNomos Proof of Quota Specification
Slug88
Statusraw
CategoryStandards Track
EditorMehmet Gonen [email protected]
ContributorsMarcin 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. 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.

  2. 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 partitioning
  • core_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 nodes
  • K_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 lottery
  • pol_t0, pol_t1: Proof of Leadership threshold constants
  • pol_ledger_aged: Root of Merkle tree containing eligible Proof of Leadership notes
  • key_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_id
  • core_path: Merkle authentication path for core node membership
  • core_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:

  1. 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_sk that corresponds to a declared zk_id, which is a valid SDP registry for the current session.

    • The zk_id values 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_id values 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
  2. 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:

  1. 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.

  2. 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_sk as 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:

  1. Transform key_nullifier into 32 bytes
  2. Compress proof to 128 bytes
  3. Concatenate: key_nullifier || proof
  4. 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_nullifier values during each session
  • Duplicate key_nullifier values MUST be rejected
  • Session transitions MUST clear the nullifier set

Proof Verification

  • All Merkle path verifications MUST be performed
  • The selector bit 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 zkhash function
  • 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:

  1. Deserialize proof into key_nullifier and proof components
  2. Verify proof size (160 bytes total)
  3. Check key_nullifier against session nullifier set
  4. Verify zk-SNARK proof with public inputs
  5. Add key_nullifier to session set if valid

Merkle Tree Construction

Core Nodes Merkle Tree

Specification:

  • Depth: 20 levels
  • Leaf values: zk_id of 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:

  1. Session Start:

    • Initialize empty nullifier set
    • Load current session parameters (quotas, roots)
    • Prepare session number for proofs
  2. During Session:

    • Verify incoming proofs
    • Track nullifiers in set
    • Reject duplicate nullifiers
  3. 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

Copyright and related rights waived via CC0.

NOMOSDA-CRYPTOGRAPHIC-PROTOCOL

FieldValue
NameNomosDA Cryptographic Protocol
Slug148
Statusraw
CategoryStandards Track
EditorMehmet 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-300ef87b1 — New RFC: CODEX-MANIFEST (#191)
  • 2026-01-3025ebb3a — 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

TerminologyDescription
BlobA unit of data submitted to NomosDA for availability guarantees.
ChunkA 31-byte field element in the BLS12-381 scalar field.
DA NodeA node responsible for storing and serving column data.
EncoderThe entity that transforms blob data into encoded form with proofs.
Sampling ClientA client (e.g., light node) that verifies availability by sampling columns.
KZG CommitmentA polynomial commitment using the Kate-Zaverucha-Goldberg scheme.
Reed-Solomon CodingAn erasure coding scheme used for data redundancy.
Row PolynomialA polynomial interpolated from chunks in a single row.
Combined PolynomialA random linear combination of all row polynomials.

Notations

SymbolDescription
$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:

  1. Encoding: Transform blob data into a matrix with commitments and proofs.
  2. Dispersal: Distribute columns to DA nodes for storage.
  3. 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.

Data Matrix Structure

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:

  1. Calculating row commitments.
  2. Expanding the original data using RS coding.
  3. 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$.

Extended Data Matrix

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.

Encoding Pipeline

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:

Dispersal and Verification

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$.

  1. 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})$$

  2. 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)$$

  3. 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.

  4. 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$.

Sampling

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:

  1. 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})$$

  2. 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}$$

  3. 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}$$

  4. 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:

  1. Choose a generator $g$ of a pairing-friendly elliptic curve group $G$.
  2. Select the maximum degree $d$ of the polynomials to be committed to.
  3. 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:

  1. 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})$$

  2. Form the combined polynomial:

    $$f_C(x) = \sum_{i=1}^{\ell} h_i \cdot f_i(x)$$

  3. Compute the combined evaluation:

    $$v = f_C(u) = \sum_{i=1}^{\ell} h_i \cdot v_i$$

  4. 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

Copyright and related rights waived via CC0.

NOMOSDA-REWARDING

FieldValue
NameNomosDA Rewarding
Slug149
Statusraw
CategoryStandards Track
EditorMarcin Pawlowski [email protected]
ContributorsAlexander Mozeika [email protected], Mehmet Gonen [email protected], Daniel Sanchez Quiros [email protected], Álvaro Castro-Castilla [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-300ef87b1 — New RFC: CODEX-MANIFEST (#191)
  • 2026-01-303f76dd8 — 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

TerminologyDescription
Block FinalityA period expressed in number of blocks (2160) after which a block is considered finalized, as defined by parameter $k$ in Cryptarchia.
SessionA time period during which the same set of nodes executes the protocol. Session length is two block finalization periods (4320 blocks).
Activity ProofA data structure containing binary opinion vectors about other nodes' service quality.
Active MessageA message registered on the ledger that contains a node's activity proof for a session.
Opinion ThresholdThe ratio of positive to negative opinions required for a node to be positively opinionated (default: 10).
Activity ThresholdThe number of positive opinions ($\theta = N_s/2$) a node must collect to be considered active.
DA NodeA node providing data availability service, identified by a unique ProviderId.
SDPService Declaration Protocol, used to retrieve the list of active DA nodes.

Notations

SymbolDescription
$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:

  1. 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.

  2. 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.

  3. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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).

    1. 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.

    2. 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.

  5. 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

  1. 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.

  2. 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

  1. Every node that collected above $\theta$ positive opinions receives a fixed reward as defined in Reward Calculation.

  2. 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:

  1. Retrieve $\mathcal{N}_s$, a list of active DA nodes (unique ProviderIds) for session $s$, from the SDP.

  2. 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).

  3. Order $\mathcal{N}s$ and $\mathcal{N}{s-1}$ in ascending lexicographical order by ProviderId of each node from both lists.

  4. Create for each session and independently for old ($\mathcal{N} = \mathcal{N}_{s-1}$) and new ($\mathcal{N} = \mathcal{N}_s$) blocks:

    1. positive_opinions vector 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}$.

    2. negative_opinions vector 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}$.

    3. blacklist vector 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.

  5. Send a sampling request to a node $n \in \mathcal{N}$ such that blacklist[n]==0:

    1. If the node $n$ responds:

      1. If the response is valid, then positive_opinions[n]++
      2. If the response is not valid, then:
        1. Clear positive opinions about the node: positive_opinions[n]=0
        2. Mark the node as blacklisted: blacklist[n]=1
    2. If the node does not respond, then negative_opinions[n]++

  6. 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))
    
  7. A node sets a positive opinion about itself in the current_session_opinions vector.

  8. A node sets a positive opinion about itself in the previous_session_opinions if 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 by previous_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 by current_session_opinions.

  • current_session_opinions: Opinions gathered from sampling new blocks.

Validity Rules

The Activity Proof is valid when:

  • The current_session_opinions vector is not provided (and current_session_opinions_length==0) when the DA service was not operational during that session.

  • The byte-length of the previous_session_opinions vector is:

    $$|\text{previous_session_opinions}| = \left\lceil \frac{\log_2(N_{s-1} + 1)}{8} \right\rceil$$

  • The previous_session_opinions vector is not provided (and previous_session_opinions_length==0) when the DA service was not operational during that session.

  • The byte-length of the current_session_opinions vector 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 header containing a one-byte version field fixed to 0x01 value.
  • The activity_proof as 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

Informative

Copyright and related rights waived via CC0.

P2P-HARDWARE-REQUIREMENTS

FieldValue
NameNomos p2p Network Hardware Requirements Specification
Slug137
Statusraw
Categoryinfrastructure
EditorDaniel Sanchez-Quiros [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-2534bbd7a — 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:

  1. Inclusive Participation: Allow users with limited resources to participate as Light Nodes
  2. Scalable Infrastructure: Support varying levels of network participation based on available resources
  3. Performance Optimization: Ensure adequate resources for computationally intensive operations
  4. Network Security: Maintain network integrity through properly resourced validator nodes
  5. 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:

ComponentSpecification
CPULow-power processor (smartphone/SBC capable)
Memory (RAM)512 MB
StorageMinimal (few GB)
NetworkReliable 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:

ComponentSpecification
CPU2 cores, 2 GHz modern multi-core processor
Memory (RAM)1 GB minimum
StorageSSD with 100+ GB free space, expandable
NetworkReliable 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:

ComponentSpecificationRationale
CPUSame as Basic Bedrock NodeStandard processing needs
Memory (RAM)Same as Basic Bedrock NodeStandard memory needs
StorageFast SSD, 500+ GB freeLong-term chain and blob storage
NetworkHigh bandwidth (10+ Mbps)Concurrent data serving
ConnectivityStable, accessible external IPDirect 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:

ComponentSpecificationRationale
CPUSame as Basic Bedrock NodeStandard processing needs
Memory (RAM)Same as Basic Bedrock NodeStandard memory needs
StorageSame as Basic Bedrock NodeStandard storage needs
NetworkStable connection (10+ Mbps)Multiple concurrent connections
ConnectivityStable, accessible external IPDirect 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:

ComponentSpecificationRationale
CPUVery high-performance multi-core processorZone logic execution and ZK proving
Memory (RAM)32+ GB strongly recommendedComplex Zone execution requirements
StorageSame as Basic Bedrock NodeStandard storage needs
GPUHighly recommended/often necessaryEfficient ZK proof generation
NetworkHigh 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:

  1. Basic connectivity to the Nomos network via libp2p
  2. Adequate storage for their designated role
  3. Sufficient processing power for their service level
  4. 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

  1. Secure Storage: Use encrypted storage for sensitive node data
  2. Network Security: Implement proper firewall configurations
  3. Physical Security: Secure physical access to node hardware
  4. Backup Strategies: Maintain secure backups of critical data

Performance Security

  1. Resource Monitoring: Monitor resource usage to detect anomalies
  2. Redundancy: Plan for hardware failures in critical services
  3. Isolation: Consider containerization or virtualization for service isolation
  4. 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

  1. libp2p protocol
  2. QUIC protocol

Copyright and related rights waived via CC0.

P2P-NAT-SOLUTION

FieldValue
NameNomos P2P Network NAT Solution Specification
Slug138
Statusraw
Categorynetworking
EditorAntonio 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-25cfb3b78 — 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:

  1. Automatic Operation: Works out-of-the-box without user configuration
  2. Inclusive Participation: Enables nodes on consumer hardware to participate effectively
  3. Decentralized Approach: Leverages the existing Nomos P2P network rather than centralized services
  4. Progressive Fallback: Escalates through increasingly complex protocols as needed
  5. 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-request for main network
    • /nomos-testnet/autonat/2/dial-request for public testnet
    • /nomos/autonat/2/dial-back and /nomos-testnet/autonat/2/dial-back respectively

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:

  1. AutoNAT client for NAT status detection
  2. Port mapping clients for PCP, NAT-PMP, and UPnP-IGD
  3. Identify protocol for capability advertisement
  4. 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

  1. Port Mapping Validation: Verify that requested port mappings are actually created
  2. AutoNAT Server Trust: Implement peer reputation for AutoNAT servers
  3. Gateway Communication: Secure communication with NAT devices
  4. Address Validation: Validate public addresses before advertisement

Privacy Considerations

  1. IP Address Exposure: Public nodes necessarily expose IP addresses
  2. Traffic Analysis: Monitor for patterns that could reveal node behavior
  3. Gateway Information: Minimize exposure of internal network topology

Denial of Service Protection

  1. AutoNAT Rate Limiting: Implement request throttling for AutoNAT services
  2. Port Mapping Abuse: Prevent excessive port mapping requests
  3. 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

  1. Multiaddress spec
  2. Identify protocol spec
  3. AutoNAT v2 protocol spec
  4. Circuit Relay v2 protocol spec
  5. PCP - RFC 6887
  6. NAT-PMP - RFC 6886
  7. UPnP IGD - RFC 6970

Copyright and related rights waived via CC0.

P2P-NETWORK-BOOTSTRAPPING

FieldValue
NameNomos P2P Network Bootstrapping Specification
Slug134
Statusraw
Categorynetworking
EditorDaniel 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-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-25aa8a3b0 — 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:

  1. Discover Peers – Find other active nodes in the network.
  2. Establish Connections – Securely connect to trusted peers.
  3. 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

  1. Node Initial Configuration: New nodes load pre-configured bootstrap node addresses. Addresses may be IP or DNS embedded 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/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N or

    /dns/foo.bar.net/udp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N

  2. Secure Connection: Nodes establish connections to bootstrap nodes announced addresses. Verifies network identity and protocol compatibility.

  3. 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.

  4. Network Integration: Iteratively connects to discovered peers. Gradually build peer connections.

  5. Protocol Engagement: Establishes required protocol channels (gossip/consensus/sync). Begins participating in network operations.

  6. 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

Copyright and related rights waived via CC0.

Blockchain Deprecated Specifications

Deprecated Blockchain specifications kept for archival and reference purposes.

CONSENSUS-CLARO

FieldValue
NameClaro Consensus Protocol
Slug140
Statusdeprecated
CategoryStandards Track
EditorCorey Petty [email protected]
ContributorsÁlvaro Castro-Castilla, Mark Evenson

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-02-151ddddc7 — update to tree structure (#128)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-02f52c54f — Update and rename CLARO.md to claro.md
  • 2024-01-2701e2781 — 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.

  1. 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).
  2. 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).
  3. After this sampling is finished, if there is a vote that has more than an alpha threshold, 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 threshold alpha is reached, the counter is reset to 0 instead.
  4. 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 beta threshold. However, when an honest node performs a query and does not reach the threshold alpha of responses, the counter will be set to 0.
  • The highest threat to Snowball is an adversary that keeps it from reaching the beta threshold, 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. Confidence c is a continuous and monotonically increasing function of w. (More evidence, higher confidence.)
  2. When w = 0, c = 0. (Without any evidence, confidence is minimum.)
  3. When w goes to infinity, c converges 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:

  1. Querying
  2. Computing confidence, evidence, and accumulated evidence
  3. Transition function
  4. 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:

  1. Simplify the algorithm. With this change the number of branches is reduced, and everything is expressed as a set of equations.

  2. 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:

  1. 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.

  2. 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 k of 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:

  1. 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.

  2. The confidence on the proposal exceeds our threshold for finalization.

  3. The number of rounds executed would be greater than max_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

  1. Logos

  2. On BFT Consensus Evolution: From Monolithic to DAG

  3. snow-ipfs

  4. snow* The Snow family of algorithms

  5. Move Move: a Language for Writing DAG Abstractions

  6. rdf

  7. rdfs

  8. xsd

  9. n3-w3c-notes

  10. ntp

Normative References

  1. Claro

  2. n3

  3. json-ld

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.

All Stable Draft Raw Deprecated Deleted
All time Latest Last 90 days
Loading RFC index...
Click a column to sort

Storage Raw Specifications

Early-stage Storage specifications collected before reaching draft status.

CODEX-BLOCK-EXCHANGE

FieldValue
NameCodex Block Exchange Protocol
Slug111
Statusraw
CategoryStandards Track
EditorCodex Team
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-12-12b2f3564 — Improved codex/raw/codex-block-exchange.md file (#215)
  • 2025-11-1963107d3 — 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

TermDescription
BlockFixed-length chunk of arbitrary data, uniquely identifiable
Standalone BlockSelf-contained block addressed by SHA256 hash (CID)
Dataset BlockBlock in ordered set, addressed by dataset CID + index
Block AddressUnique identifier for standalone/dataset addressing
WantListList of block requests sent by a peer
Block DeliveryTransmission of block data from one peer to another
Block PresenceIndicator of whether peer has requested block
Merkle ProofProof verifying dataset block position correctness
CodexProofCodex-specific Merkle proof format verifying a block's position within a dataset tree
StreamBidirectional libp2p communication channel between two peers for exchanging messages
Peer Context StoreInternal data structure tracking active peer connections, their WantLists, and exchange state
CIDContent Identifier - hash-based identifier for content
MulticodecSelf-describing format identifier for data encoding
MultihashSelf-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:

PropertyValueDescription
Default Block Size64 KiBStandard size for data blocks
Maximum Block Size100 MiBUpper limit for block data field
Multicodeccodex-block (0xCD02)*Format identifier
Multihashsha2-256 (0x12)Hash algorithm for addressing
Padding RequirementZero-paddingIncomplete 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):

LimitRecommended ValueDescription
Maximum Block Size100 MiBMaximum size of block data in BlockDelivery
Maximum WantList Size1000 entriesMaximum entries per WantList message
Maximum Concurrent Requests256 per peerMaximum simultaneous block requests per peer
Stream Timeout60 secondsIdle stream closure timeout
Request Timeout300 secondsMaximum time to fulfill a block request
Maximum Message Size105 MiBMaximum total message size (protobuf)
Maximum Pending Bytes10 GiBMaximum 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:

ComponentPurpose
Discovery ModuleDHT-based peer discovery for locating nodes
Local Store (Repo)Persistent block storage for local blocks
AdvertiserAnnounces block availability to the network
Network Layerlibp2p 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.x is incompatible with 2.x.x
  • Nodes MUST support only their major version
  • Cross-major-version communication requires protocol upgrade

Minor Version Compatibility:

  • Version 1.1.0 MUST be backward compatible with 1.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:

  1. Propose highest supported version first
  2. If rejected, try next lower version
  3. If all rejected, connection fails
  4. 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:

  1. The initiating node dials the peer using the protocol identifier
  2. A bidirectional stream is established
  3. Both sides can send and receive messages on this stream
  4. Messages are encoded using Protocol Buffers
  5. The stream remains open for the duration of the exchange session
  6. 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:

  1. Node A sends WantList requesting block with wantType = wantBlock
  2. Node B checks local storage, finds block
  3. Node B responds with BlockPresence confirming availability
  4. Node B includes BlockDelivery with actual block data
  5. Node A verifies CID matches SHA256(data)
  6. 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:

  1. Node A sends WantList for dataset block at specific index
  2. Node B locates block in dataset
  3. Node B generates CodexProof for block position in Merkle tree
  4. Node B delivers block with proof
  5. Node A verifies proof against treeCid
  6. Node A verifies block data integrity
  7. 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:

  1. Node A sends WantList with wantType = wantHave
  2. Node B checks local storage without loading block data
  3. Node B responds with BlockPresence only (no payload)
  4. Node A updates peer availability map
  5. 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:

  1. Node A requests block with sendDontHave = true
  2. Node B checks storage, block not found
  3. Node B sends BlockPresence with presenceDontHave
  4. Node A removes Node B from candidates for this block
  5. 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:

  1. Node A sends WantList entry with cancel = true
  2. Node B removes block request from peer's want queue
  3. Node B stops any pending block transfer for this address
  4. 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:

  1. Node A sends WantList with full = false (delta update)
  2. Node B merges entries with existing WantList for Node A
  3. Node B begins processing new requests
  4. 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:

  1. Use two-phase discovery for large blocks or paid content
  2. Use parallel requests for small blocks or time-critical data
  3. 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 blocks
  • pendingBytes: Number of bytes pending delivery
  • account: Account information for micropayments
  • payment: 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 when leaf = true)
  • index: Position of block within dataset (present when leaf = true)
  • cid: Content identifier of the block (present when leaf = 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 delivery
  • wantHave (1): Request availability information only (presence check)

Entry Fields:

  • address: The block being requested
  • priority: Request priority (currently always 0, reserved for future use)
  • cancel: If true, cancels a previous want for this block
  • wantType: Specifies whether full block or presence is desired
    • wantHave (1): Only check if peer has the block
    • wantBlock (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 = 0 for 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 requests
  • full: 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 block
  • data: Raw block data (up to 100 MiB)
  • address: The BlockAddress identifying this block
  • proof: 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 referenced
  • type: Whether the peer has the block or not
  • price: 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:

  1. Requester sends WantList with wantType = wantHave
  2. Provider responds with BlockPresence including price field
  3. Price encoded as UInt256 in wei (smallest Ethereum unit)
  4. 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 presenceDontHave for 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 presenceDontHave for 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 requestBlock caller

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, requestBlock returns 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:

  1. Mark peer as temporarily unavailable for this block
  2. Query discovery service for alternative peers
  3. Send WantList to new peers
  4. 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 and related rights waived via CC0.

References

Normative

Informative

CODEX-COMMUNITY-HISTORY

FieldValue
NameCodex Community History
Slug76
Statusraw
ContributorsJimmy Debe [email protected]

Timeline

  • 2026-01-30d5a9240 — chore: removed archived (#283)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — 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

NameDescription
Waku nodeA 10/WAKU2 node that implements 11/WAKU2-RELAY
Store nodeA 10/WAKU2 node that implements 13/WAKU2-STORE
Waku networkA group of 10/WAKU2 nodes forming a graph, connected via 11/WAKU2-RELAY
Status userA Status account that is used in a Status consumer product, such as Status Mobile or Status Desktop
Status nodeA Status client run by a Status application
Control nodeA Status node that owns the private key for a Status community
Community memberA Status user that is part of a Status community, not owning the private key of the community
Community member nodeA Status node with message archive capabilities enabled, run by a community member
Live messages14/WAKU2-MESSAGE received through the Waku network
BitTorrent clientA program implementing the BitTorrent protocol
Torrent/Torrent fileA file containing metadata about data to be downloaded by BitTorrent clients
Magnet linkA 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 encoded WakuMessageArchive's (as bit strings) concatenated in ascending order based on their time
  • index: Contains the protobuf encoded WakuMessageArchiveIndex

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.

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:

  1. Receive message archive index magnet link as described in Message archive distribution,
  2. Download the index file from the torrent, then determine which message archives to download
  3. 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:

  1. The member node receives it via live messages, by listening to the special channel.
  2. 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.)
  3. 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:

  1. 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.)
  2. 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).
  3. Download specific archives: Look into from and to fields of every WakuMessageArchiveIndexMetadata and 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 and related rights waived via CC0.

References

CODEX-DHT

FieldValue
NameCodex Discovery
Slug75
Statusraw
ContributorsJimmy Debe [email protected], Giuliano Mega [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. it uses libp2p SPRs instead of Ethereum's ENRs to identify peers and convey connection information;
  2. it extends the DHT message interface with GET_PROVIDERS/ADD_PROVIDER requests for managing provider lists;
  3. 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 and related rights waived via CC0.

References

2

This is actually stronger than necessary, but we'll refine it over time.

1

This should link to the block exchange spec once it's done.

CODEX-MANIFEST

FieldValue
NameCodex Manifest
Slug145
Statusraw
CategoryStandards Track
Tagscodex, manifest, metadata, cid
EditorJimmy Debe [email protected]
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-300ef87b1 — 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

TermDescription
ManifestA metadata structure describing a dataset stored on the Codex network.
CIDContent Identifier, a self-describing content-addressed identifier used in IPFS and Codex.
Codex TreeA Merkle tree structure computed over the blocks in a dataset. See CODEX-MERKLE-TREE.
treeCidThe CID of the root of the Codex Tree corresponding to a dataset.
BlockA fixed-size chunk of data in the dataset.
MulticodecA 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
}
AttributeTypeDescription
treeCidbytesA 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).
blockSizeuint32The size of each block for the given dataset. The default block size used in Codex is 64 KiB.
datasetSizeuint64The total size of all blocks for the original dataset.
codecuint32The Multicodec used for the CIDs of the dataset blocks. Codex uses codex-block (0xCD02).
hcodecuint32The Multicodec used for computing the multihash used in block CIDs. Codex uses sha2-256 (0x12).
versionuint32The version of CID used for the dataset blocks.
filenamestringWhen provided, it MAY be used by the client as a file name while downloading the content.
mimetypestringWhen 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

Informative

  • BEP3 - The BitTorrent Protocol Specification
  • CIDv1 - Content Identifier version 1 specification
  • Multicodec - Self-describing protocol identifiers
  • Codex Manifest Spec - Original specification

Copyright and related rights waived via CC0.

CODEX-MERKLE-TREE

FieldValue
NameCodex Merkle Tree
Slug82
Statusraw
CategoryStandards Track
EditorCodex Team
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. Injective encoding: The 10* padding strategy ensures that different byte sequences always encode to different target types
  2. Keyed compression: Using distinct keys for different node types (even/odd, bottom/other layers) prevents node substitution attacks
  3. 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: T is 160 bits
  • SHA256: T is 256 bits
  • Keccak (SHA3): T can be one of 224, 256, 384 or 512 bits
  • Poseidon: T is one or more finite field element(s) (based on the field size)
  • Monolith: T is 4 Goldilocks field elements

The hash function H can also have different types S ("Source type") of inputs. For example:

  • SHA1 / SHA256 / SHA3: S is 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: S is a pair of T-s
  • Poseidon: S is a sequence of finite field elements
  • Poseidon2 compression function: S is at most t-k field elements, where k field elements should be approximately 256 bits (in our case t=3, k=1 for BN254 field; or t=12, k=4 for the Goldilocks field; or t=24, k=8 for a ~32 bit field)
  • as an alternative, the "Jive compression mode" for binary compression can eliminate the "minus k" requirement (you can compress t into t/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 enumeration
  • S := Source[H] and T := Target[H]
  • MerklePath[H] is a record, consisting of
    • path: a sequence of T-s
    • index: a linear index (int)
    • leaf: the leaf being proved (a T)
    • size: the number of elements from which the tree was created
  • MerkleTree[H]: a binary tree of T-s; alternatively a sequence of sequences of T-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 length n as input, and produces an output (Merkle root) of type T
  • 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).

InterfaceDescriptionInputOutput
computeTree()computes the full Merkle treesequence of T-sa MerkleTree[T] data structure (a binary tree)
computeRoot()computes the Merkle root of a sequence of T-ssequence of T-sa single T
extractPath()computes a Merkle pathMerkleTree[T] and a leaf indexMerklePath[T]
checkMerkleProof()checks the validity of a Merkle path proofroot hash (a T) and MerklePath[T]a bool (ok or not)
encodeBytesInjective()converts a sequence of bytes into a sequence of T-s, injectivelyseqences of bytessequence of T-s
serializeToBytes()serializes a sequence of T-s into bytessequence of T-ssequence of bytes
deserializeFromBytes()deserializes a sequence of T-s from bytessequence of bytessequence 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 byteserror or MerkleTree[T]

Dependencies

Hash function implementations, for example:

Security/Privacy Considerations

Attack Mitigation

The specification addresses three major attack vectors:

  1. Data encoding attacks: Prevented by using injective encoding from bytes to target types with the 10* padding strategy. The strategy always adds a 0x01 byte followed by 0x00 bytes to reach a multiple of M bytes, ensuring that different byte sequences of different lengths cannot encode to the same target type.

  2. 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.

  3. 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) where key is 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 and related rights waived via CC0.

References

normative

informative

CODEX-STORE

FieldValue
NameCodex Store Module
Slug80
Statusraw
CategoryStandards Track
EditorCodex Team
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

MethodDescriptionInputOutput
getBlock(cid: Cid)Retrieve block by CIDCIDFuture[?!Block]
getBlock(treeCid: Cid, index: Natural)Retrieve block from a Merkle tree by leaf indexTree CID, indexFuture[?!Block]
getBlock(address: BlockAddress)Retrieve block via unified addressBlockAddressFuture[?!Block]
getBlockAndProof(treeCid: Cid, index: Natural)Retrieve block with Merkle proofTree CID, indexFuture[?!(Block, CodexProof)]
getCid(treeCid: Cid, index: Natural)Retrieve leaf CID from tree metadataTree CID, indexFuture[?!Cid]
getCidAndProof(treeCid: Cid, index: Natural)Retrieve leaf CID with inclusion proofTree CID, indexFuture[?!(Cid, CodexProof)]
putBlock(blk: Block, ttl: Duration)Store block with quota enforcementBlock, optional TTLFuture[?!void]
putCidAndProof(treeCid: Cid, index: Natural, blkCid: Cid, proof: CodexProof)Store leaf metadata with ref countingTree CID, index, block CID, proofFuture[?!void]
hasBlock(...)Check block existence (CID or tree leaf)CID / Tree CID + indexFuture[?!bool]
delBlock(...)Delete block/tree leaf (with ref count checks)CID / Tree CID + indexFuture[?!void]
ensureExpiry(...)Update expiry for block/tree leafCID / Tree CID + index, expiry timestampFuture[?!void]
listBlocks(blockType: BlockType)Iterate over stored blocksBlock typeFuture[?!SafeAsyncIter[Cid]]
getBlockExpirations(maxNumber, offset)Retrieve block expiry metadataPagination paramsFuture[?!SafeAsyncIter[BlockExpiration]]
blockRefCount(cid: Cid)Get block reference countCIDFuture[?!Natural]
reserve(bytes: NBytes)Reserve storage quotaBytesFuture[?!void]
release(bytes: NBytes)Release reserved quotaBytesFuture[?!void]
start()Initialize storeFuture[void]
stop()Gracefully shut down storeFuture[void]
close()Close underlying datastoresFuture[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 quota
  • blockTtl: Default TTL for stored blocks
  • postFixLen: 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:

  1. Block Cache — LruCache[Cid, Block]
    • Stores actual block data indexed by CID
    • Acts as the primary cache for block content
  2. CID/Proof Cache — LruCache[(Cid, Natural), (Cid, CodexProof)]
    • Maps (treeCid, index) to (blockCid, proof)
    • Supports direct access to block proofs keyed by treeCid and index

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 PatternData TypeDescriptionExample
repo/manifests/{XX}/{full-cid}Raw bytesManifest block datarepo/manifests/Cd/bafy...Cd → [data]
repo/blocks/{XX}/{full-cid}Raw bytesBlock datarepo/blocks/Ab/bafy...Ab → [data]
meta/ttl/{cid}BlockMetadataExpiry, size, refCountmeta/ttl/bafy... → {...}
meta/proof/{treeCid}/{index}LeafMetadataMerkle proof for leafmeta/proof/bafy.../42 → {...}
meta/totalNaturalTotal stored blocksmeta/total → 12039
meta/quota/usedNBytesUsed quotameta/quota/used → 52428800
meta/quota/reservedNBytesReserved quotameta/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.

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 BlockStore interface, 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 and related rights waived via CC0.

References

normative

informative

Storage Deprecated Specifications

Deprecated Storage specifications kept for archival and reference purposes.

CODEX-ERASUE-CODING

FieldValue
NameCodex Erasue Coding
Slug79
Statusdeprecated

Timeline

  • 2026-01-22e356a07 — Chore/add makefile (#271)
  • 2026-01-22af45aae — chore: deprecate Marketplace-related specs (#268)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — 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:

  1. Prepare the dataset for the marketplace using erasure encoding.
  2. Derive a manifest CID from the root encoded blocks
  3. Error correction by validator nodes once the storage contract begins
  4. 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 and related rights waived via CC0.

References

CODEX-MARKETPLACE

FieldValue
NameCodex Storage Marketplace
Slug77
Statusdeprecated
CategoryStandards Track
EditorCodex Team and Dmitriy Ryajov [email protected]
ContributorsMark Spanbroek [email protected], Adam Uhlíř [email protected], Eric Mastro [email protected], Jimmy Debe [email protected], Filip Dimitrijevic [email protected]

Timeline

  • 2026-01-22e356a07 — Chore/add makefile (#271)
  • 2026-01-22af45aae — chore: deprecate Marketplace-related specs (#268)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-11-19d2df7e0 — 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

TerminologyDescription
Storage Provider (SP)A node in the Codex network that provides storage services to the marketplace.
ValidatorA node that assists in identifying missing storage proofs.
ClientA node that interacts with other nodes in the Codex network to store, locate, and retrieve data.
Storage Request or RequestA request created by a client node to persist data on the Codex network.
Slot or Storage SlotA space allocated by the storage request to store a piece of the request's dataset.
Smart ContractA smart contract implementing the marketplace functionality.
TokenThe 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:

attributetypedescription
clientaddressThe Codex node requesting storage.
askAskParameters of Request.
contentContentThe dataset that will be hosted with the storage request.
expiryuint64Timeout 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.
noncebytes32Random value to differentiate from other requests of same parameters. It SHOULD be a random byte array.
pricePerBytePerSeconduint256Amount 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.
collateralPerByteuint256The 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).
proofProbabilityuint256Determines 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.
durationuint64Total duration of the storage request in seconds. It MUST NOT exceed the limit specified in the configuration config.requestDurationLimit.
slotsuint64The number of requested slots. The slots will all have the same size.
slotSizeuint64Amount of storage per slot in bytes.
maxSlotLossuint64Max slots that can be lost without data considered to be lost.
cidbytesAn 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.
merkleRootbytes32Merkle 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 StorageRequest data 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 freeSlot or withdrawFunds).

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 Cancelled if no RequestFulfilled(requestId) event is observed during the timeout specified by the value returned from the requestExpiresAt(requestId) function.
  • The request is considered Failed when the RequestFailed(requestId) event is observed.
  • The request is considered Finished after the interval specified by the value returned from the getRequestEnd(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 the Request type 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 - the Groth16Proof proof 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 by config.collateral.slashPercentage of 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 the SlotFreed(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:

  1. The SP observes the SlotFreed event and decides to repair the slot.
  2. The SP MUST reserve the slot with the reserveSlot(requestId, slotIndex) call. For more information see the [Filling Slots](###filling slots) section.
  3. 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.
  4. The SP MUST generate proof over the reconstructed data.
  5. 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 Cancelled if no RequestFulfilled(requestId) event is observed within the time indicated by the expiry request parameter. Note that a RequestCancelled event may also be emitted, but the node SHOULD NOT rely on this event to assert the request expiration, as the RequestCancelled event is not guaranteed to be emitted at the time of expiry.
  • A storage request is considered Finished when the time indicated by the value returned from the getRequestEnd(requestId) function has elapsed.
  • A node concludes that a storage request has Failed upon observing the RequestFailed(requestId) event.

For each of the states listed above, different funds are handled as follows:

  • In the Cancelled state, 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 Finished state, the full reward for hosting the slot, along with the collateral, is collected.
  • In the Failed state, 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 the mySlots() 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:

  1. SalePreparing - Find a matching availability and create a reservation
  2. SaleSlotReserving - Reserve the slot on the marketplace
  3. SaleDownloading - Stream and persist the slot's data
  4. SaleInitialProving - Wait for stable challenge and generate initial proof
  5. SaleFilling - Compute collateral and fill the slot
  6. SaleFilled - Post-filling operations and expiry updates
  7. SaleProving - Generate and submit proofs periodically
  8. SalePayout - Free slot and calculate collateral
  9. SaleFinished - Terminal success state
  10. SaleFailed - Free slot on market and transition to error
  11. SaleCancelled - Cancellation path
  12. SaleIgnored - Sale ignored (no matching availability or other conditions)
  13. SaleErrored - Terminal error state
  14. SaleUnknown - Recovery state for crash recovery
  15. 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, minPricePerBytePerSecond and until
  • Create a reservation
  • Move to SaleSlotReserving if successful
  • Move to SaleIgnored if no availability is found or if BytesOutOfBoundsError is raised because of no space available.
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
SaleSlotReserving
  • Check if the slot can be reserved
  • Move to SaleDownloading if successful
  • Move to SaleIgnored if SlotReservationNotAllowedError is raised or the slot cannot be reserved. The collateral is returned.
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on 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 SaleInitialProving if successful
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
  • Move to SaleFilled on SlotFilled event from the marketplace
SaleInitialProving
  • Wait for a stable initial challenge
  • Produce the initial proof via onProve
  • Move to SaleFilling if successful
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
SaleFilling
  • Get the slot collateral
  • Fill the slot
  • Move to SaleFilled if successful
  • Move to SaleIgnored on SlotStateMismatchError. The collateral is returned.
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on 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 onFilled hook
  • Call onExpiryUpdate to change the data expiry from expiry date to request end date
  • Move to SaleProving (or SaleProvingSimulated for simulated mode)
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
SaleProving
  • For each period: fetch challenge, call onProve, and submit proof
  • Move to SalePayout when the slot request ends
  • Re-raise SlotFreedError when the slot is freed
  • Raise SlotNotFilledError when the slot is not filled
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
SaleProvingSimulated
  • Submit invalid proofs every N periods (failEveryNProofs in 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 SaleFinished if successful
  • Move to SaleFailed on RequestFailed event from the marketplace
  • Move to SaleCancelled on cancelled timer elapsed, set to storage contract expiry
SaleFinished
  • Call onClear hook
  • Call onCleanUp hook
SaleFailed
  • Free the slot
  • Move to SaleErrored with the failure message
SaleCancelled
  • Ensure that the node hosting the slot frees the slot
  • Call onClear hook
  • Call onCleanUp hook with the current collateral
SaleIgnored
  • Call onCleanUp hook with the current collateral
SaleErrored
  • Call onClear hook
  • Call onCleanUp hook
SaleUnknown
  • Recovery entry: get the on-chain state 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 SalesAgent for 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:

  1. Unseen before seen - Items that have not been seen are dequeued first.
  2. More profitable first - Higher profitability wins. profitability is duration * pricePerSlotPerSecond.
  3. Less collateral first - The item with the smaller collateral wins.
  4. 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:

  • OnAvailabilitySaved fires after an availability update that increases one of: freeSize, duration, minPricePerBytePerSecond, or totalRemainingCollateral.
  • 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-chain active slots.
  • Delete the local reservations for slots that are not in the active list.
  • Create a new agent for each slot and assign the onCleanUp callback.
  • Start the agent in the SaleUnknown state.

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 SalesContext with dependencies and host hooks
  • Supports crash recovery via the SaleUnknown state
  • 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) and Clock
  • Provides access to Reservations
  • Provides host hooks: onStore, onProve, onExpiryUpdate, onClear, onSale
  • Shares the SlotQueue handle 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 SlotQueueItem entries (one per slotIndex) with seen = 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 CancelledError from nim-chronos and log a trace, exiting gracefully
  • Catch CatchableError, log it, and route to SaleErrored

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 reprocessSlot is 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 Availability and Reservation records 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 CancelledError is raised, the state machine logs the cancellation message and takes no further action.
  • If a CatchableError is raised, the state machine moves to errored with 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 PurchaseId derived from requestId
  • It MUST be possible to restore any purchase from its requestId after a restart
  • A purchase is considered expired when the expiry timestamp in its StorageRequest is reached before the request start, i.e, an event RequestFulfilled is emitted by the marketplace
State Machine Progression
  • New purchases MUST start in the pending state (submission flow)
  • Recovered purchases MUST start in the unknown state (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 RequestState returned by the marketplace
Failure Handling
  • On marketplace failure events, the purchase MUST immediately transition to errored without retries
  • If a CancelledError is raised, the state machine MUST log the cancellation and stop further processing
  • If a CatchableError is raised, the state machine MUST transition to errored and 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 new other than initialising internal fields; on-chain interactions are delegated to states using marketplace dependency.
  • 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 and related rights waived via CC0.

References

Normative

Informative

CODEX-PROVER

FieldValue
NameCodex Prover Module
Slug81
Statusdeprecated
CategoryStandards Track
EditorCodex Team
ContributorsFilip Dimitrijevic [email protected]

Timeline

  • 2026-01-22e356a07 — Chore/add makefile (#271)
  • 2026-01-22af45aae — chore: deprecate Marketplace-related specs (#268)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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:

  1. Sampler: Derives random sample indices from public entropy and slot commitments, then generates the proof input
  2. Prover: Produces succinct ZK proofs for valid proof inputs and verifies such proofs
  3. 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

TermDescription
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.
DatasetA set of fixed-size slots provided by possibly different storage clients.
CellSmallest circuit sampling unit (e.g., 2 KiB), its bytes are packed into field elements and hashed.
BlockNetwork transfer unit (e.g., 64 KiB) consists of multiple cells, used for transport.
SlotThe erasure-coded fragment of a dataset stored by a single storage provider. Proof requests are related to slots.
CommitmentCryptographic 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 RootPoseidon2 Merkle roots used as public commitments in the circuit. Not the SHA-256 content tree used for CIDs.
EntropyPublic randomness (e.g., blockhash) used to derive random sample indices.
WitnessPrivate zk circuit inputs.
Public InputsValues known to or shared with the verifier.
Groth16Succinct zk-SNARK proof system used by Codex.
Proof WindowThe 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 SlotsBuilder pads 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 the DataSampler is 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 cellData via the BlockStore and builder, and fetch the stored cell -> block, block -> slot, slot -> dataset Merkle paths. Note that cell -> block can be built on the fly and slot -> dataset can 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: the slot -> dataset Merkle 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:

  1. Cell hashing: recompute the cell digest from cellData using the Poseidon2 sponge (rate=2, 10* padding).
  2. Cell -> Block: verify inclusion of the cell digest in the block tree using the provided cell -> block path.
  3. Block -> Slot: verify inclusion of the block digest in the slot tree using the block -> slot path.
  4. Slot -> Dataset: verify inclusion of slotRoot in dataset tree using slotProof.
  5. 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 proof against the public inputs using the circuit's verifying key (derived from the CRS generated at setup).
  • On EVM chains, verification leverages BN254 precompiles.

Functional Requirements

Data Commitment:

  • Fetch existing slot commitments using BlockStore and SlotsBuilder: cell -> block -> slot Merkle trees for each slot in the locally stored dataset.
  • Fetch dataset commitment: slot -> dataset verification tree root.
  • Proof material: retrieve cell data (as field elements).

Sampling:

  • Checks for marketplace challenges per slot.
  • Random sampling: Derive nSamples cell indices for the slotIndex from (entropy, slotRoot).
  • For each sampled cell, fetch: cellData (as field elements) and Merkle paths (all cell -> block -> slot -> dataset)
  • Generate ProofInputs containing 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

InterfaceDescriptionInputOutput
new[T,H]Construct a DataSampler for a specific slot index.index: Natural, blockStore: BlockStore, builder: SlotsBuilder[T,H]DataSampler[T,H]
getSampleRetrieve one sampled cell and its Merkle path(s) for a given slot.cellIdx: int, slotTreeCid: Cid, slotRoot: HSample[H]
getProofInputGenerate the full proof inputs for the proving circuit (calls getSample internally).entropy: ProofChallenge, nSamples: NaturalProofInputs[H]

Prover Interfaces

InterfaceDescriptionInputOutput
newConstruct a Prover with a block store and the backend proof system.blockStore: BlockStore, backend: AnyBackend, nSamples: intProver
proveProduce a succinct proof for the given slot and entropy.slotIdx: int, manifest: Manifest, entropy: ProofChallenge(proofInputs, proof)
verifyVerify a proof against its public inputs.proof: AnyProof, proofInputs: AnyProofInputsbool

Circuit Interfaces

TemplateDescriptionParametersInputs (signals)Outputs (signals)
SampleAndProveMain 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, nSamplesentropy, dataSetRoot, slotIndex, slotRoot, nCellsPerSlot, nSlotsPerDataSet, slotProof[maxLog2NSlots], cellData[nSamples][nFieldElemsPerCell], merklePaths[nSamples][maxDepth]-
ProveSingleCellVerifies one sampled cell: hashes cellData with Poseidon2 and checks the concatenated Merkle path up to slotRoot.nFieldElemsPerCell, botDepth, maxDepthslotRoot, data[nFieldElemsPerCell], lastBits[maxDepth], indexBits[maxDepth], maskBits[maxDepth+1], merklePath[maxDepth]-
RootFromMerklePathReconstructs a Merkle root from a leaf and path using KeyedCompression.maxDepthleaf, pathBits[maxDepth], lastBits[maxDepth], maskBits[maxDepth+1], merklePath[maxDepth]recRoot
CalculateCellIndexBitsDerives the index bits for a sampled cell from (entropy, slotRoot, counter), masked by cellIndexBitMask.maxLog2Nentropy, slotRoot, counter, cellIndexBitMask[maxLog2N]indexBits[maxLog2N]

All parameters are compile-time constants that are defined when building the circuit.

Circuit Utility Templates

TemplateDescriptionParametersInputs (signals)Outputs (signals)
Poseidon2_hash_rate2Poseidon2 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.
PoseidonSpongeGeneric 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 squeezeinp[input_len]: field elements to absorb.out[output_len]: field elements squeezed.
KeyedCompressionKeyed 2->1 compression where key ∈ {0,1,2,3}.-key, inp[2]: left and right child node digests.out: parent node digest
ExtractLowerBitsExtracts the lower n bits of inp (LSB-first).n: number of low bits to extractinp: field elements to extract.out[n]: extracted bits.
Log2Checks 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.
CeilingLog2Computes 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
BinaryCompareCompares 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 BA[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 and related rights waived via CC0.

References

Normative

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

FieldValue
NameCodex Slot Builder
Slug78
Statusdeprecated
ContributorsJimmy Debe [email protected]

Timeline

  • 2026-01-22e356a07 — Chore/add makefile (#271)
  • 2026-01-22af45aae — chore: deprecate Marketplace-related specs (#268)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — 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 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

All Stable Draft Raw Deprecated Deleted
All time Latest Last 90 days
Loading RFC index...
Click a column to sort

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

FieldValue
NameConsensus-Oriented Specification System
Slug1
Statusdraft
CategoryBest Current Practice
EditorDaniel Kaiser [email protected]
ContributorsOskar 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-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-11-04dd397ad — Update Coss Date (#206)
  • 2024-10-09d5e0072 — cosmetic: fix external links in 1/COSS (#100)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-09ed2c68f — 1/COSS: New RFC Process (#4)
  • 2024-02-013eaccf9 — Update and rename COSS.md to coss.md
  • 2024-01-30990d940 — Rename COSS.md to COSS.md
  • 2024-01-276495074 — Rename vac/rfcs/01/README.md to vac/01/COSS.md
  • 2024-01-25bab16a8 — Rename README.md to README.md
  • 2024-01-25a9162f2 — 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:

Lifecycle diagram

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.

KeyValueTypeExample
shortnameshort namestring1/COSS
titlefull namestringConsensus-Oriented Specification System
statusstatusstringdraft
categorycategorystringBest Current Practice
tags0 or several tagslistwaku-application, waku-core-protocol
editoreditor name/emailstringOskar Thoren [email protected]
contributorscontributorslist- 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:

  • raw
  • draft
  • stable
  • deprecated
  • retired
  • deleted

2/MVDS

FieldValue
NameMinimum Viable Data Synchronization
Slug2
Statusstable
EditorSanaz Taheri [email protected]
ContributorsDean Eigenmann [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-06-28a5b24ac — fix_: broken image links (#81)
  • 2024-02-010253d53 — Rename MVDS.md to mvds.md
  • 2024-01-3070326d1 — Rename MVDS.md to MVDS.md
  • 2024-01-27472a7fd — Rename vac/rfcs/02/README.md to vac/02/MVDS.md
  • 2024-01-254362a7b — 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

TermDescription
PeerThe other nodes that a node is connected to.
RecordDefines a payload element of either the type OFFER, REQUEST, MESSAGE or ACK
NodeSome 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 identifiers informing the recipient that sender holds a specific message.
  • Offers: This field contains a list (can be empty) of message identifiers that the sender would like to give to the recipient.
  • Requests: This field contains a list (can be empty) of message identifiers that 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, REQUEST or MESSAGE
  • 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 MESSAGE when attempting to send it to a peer. This means an OFFER is added to the next payload and state for the given peer.
  • When a node receives an OFFER, a REQUEST is added to the next payload and state for the given peer.
  • When a node receives a REQUEST for a previously sent OFFER, the OFFER is removed from the state and the corresponding MESSAGE is added to the next payload and state for the given peer.
  • When a node receives a MESSAGE, the REQUEST is removed from the state and an ACK is added to the next payload for the given peer.
  • When a node receives an ACK, the MESSAGE is removed from the state for the given peer.
  • All records that require retransmission are added to the payload, given Send Epoch has been reached.

notification

Figure 1: Delivery without retransmissions in interactive mode.

Batch Mode

  1. When a node sends a MESSAGE, it is added to the next payload and the state for the given peer.
  2. When a node receives a MESSAGE, an ACK is added to the next payload for the corresponding peer.
  3. When a node receives an ACK, the MESSAGE is removed from the state for the given peer.
  4. All records that require retransmission are added to the payload, given Send Epoch has been reached.

notification

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 a MESSAGE.

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 and related rights waived via CC0.

Footnotes

3/REMOTE-LOG

FieldValue
NameRemote log specification
Slug3
Statusdraft
EditorOskar Thorén [email protected]
ContributorsDean Eigenmann [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-013fd8b5a — Update and rename README.md to remote-log.md
  • 2024-01-30dce61fe — 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

TermDefinition
CASContent-addressed storage. Stores data that can be addressed by its hash.
NSName system. Associates mutable data to a name.
Remote logReplication 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:

  1. Alice
  2. Bob
  3. Name system (NS)
  4. 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

notification

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:

  1. Fully replicated log
  2. Normal sized page with CAS mapping
  3. "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 and related rights waived via CC0.

Footnotes

4/MVDS-META

FieldValue
NameMVDS Metadata Field
Slug4
Statusdraft
EditorSanaz Taheri [email protected]
ContributorsDean Eigenmann [email protected], Andrea Maria Piana [email protected], Oskar Thorén [email protected]

Timeline

  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-02-013a396b5 — Update and rename README.md to mvds-meta.md
  • 2024-01-302e80c3b — 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

NameDescription
parentslist of parent message identifiers for the specific message.
ephemeralindicates 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 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

FieldValue
NameLibp2p Peer Discovery via DNS
Slug25
Statusdeleted
EditorHanno Cornelius [email protected]

Timeline

  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-08a3ad14e — 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

  • matree is the selected multiaddr Merkle 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-root and link-root refer to the root hashes of subtrees containing multiaddrs and links to other subtrees, respectively
  • sequence-number is the tree's update sequence number. This number SHOULD increase with each update to the tree.
  • signature is a 65-byte secp256k1 EC signature over the keccak256 hash of the root record content, excluding the sig= 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:

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:

  1. Resolve the TXT record of the DNS name and check whether it contains a valid matree-root:v1 entry.
  2. 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.
  3. Resolve the TXT record of a hash subdomain indicated in the record and verify that the content matches the hash.
  4. If the resolved entry is of type:
  • matree-branch: parse the list of hashes and continue resolving them (step 3).
  • ma: import the multiaddr and add it to a local list of discovered nodes.

Copyright and related rights waived via CC0.

References

  1. 10/WAKU2
  2. EIP-1459: Client Protocol
  3. EIP-1459: Node Discovery via DNS
  4. libp2p
  5. libp2p peer identity
  6. Merkle trees

32/RLN-V1

FieldValue
NameRate Limit Nullifier
Slug32
Statusdraft
EditorAaryamann Challani [email protected]
ContributorsBarry 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-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-06-06cbefa48 — 32/RLN-V1: Move to Draft (#40)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-0194db406 — Update rln-v1.md
  • 2024-02-01a23299f — Update and rename RLN-V1.md to rln-v1.md
  • 2024-01-27539575b — 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:

  1. Registration
  2. Signaling
  3. 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[][]
}

  1. root - The root of membership group Merkle tree at the time of publishing the message
  2. indices - The index fields of the leafs in the Merkle tree - used by the Merkle tree algorithm for verification
  3. 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_nullifier correctness
  • non-duplicate message check
  • zk_proof zero-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

TermDescription
ZK-SNARKzksnarks
StakeFinancial or social stake required for registering in the RLN applications. Common stake examples are: locking cryptocurrency (financial), linking reputable social identity.
Identity secretAn 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 nullifierRandom 32 byte value used as component for identity secret generation.
Identity trapdoorRandom 32 byte value used as component for identity secret generation.
Identity secret hashThe 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 commitmentHash obtained from the Identity secret hash by using the poseidon hash function. It is used by the users for registering in the protocol.
SignalThe message generated by a user. It is an arbitrary bit string that may represent a chat message, a URL request, protobuf message, etc.
Signal hashKeccak256 hash of the signal modulo circuit's field characteristic, used as an input in the RLN circuit.
RLN IdentifierRandom 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 treeMerkle tree data structure, filled with identity commitments of the users. Serves as a data structure that ensures user registrations.
Merkle proofProof that a user is member of the RLN membership tree.

RLN Zero-Knowledge Circuit specific terms

TermDescription
xKeccak hash of the signal, same as signal hash (Defined above).
A0The identity secret hash.
A1Poseidon hash of [A0, External nullifier] (see about External nullifier below).
yThe result of the polynomial equation (y = a0 + a1*x). The public output of the circuit.
External nullifierPoseidon hash of [Epoch, RLN Identifier]. An identifier that groups signals and can be thought of as a voting booth.
Internal nullifierPoseidon 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
  • x
  • external_nullifier
Private Inputs
  • identity_secret_hash
  • path_elements - rln membership proof component
  • identity_path_index - rln membership proof component
Outputs
  • y
  • root - the rln membership tree root
  • internal_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 inputstRFRP
12856
23857
34856
45860
56860
67863
78864
89863
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_secret
  • identity_secret_hash
  • identity_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_nullifier
  • identity_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 and related rights waived via CC0

References

Informative

ETH-DCGKA

FieldValue
NameDecentralized Key and Session Setup for Secure Messaging over Ethereum
Slug103
Statusraw
Categoryinformational
EditorRamses Fernandez-Valencia [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-04517b639 — Update the RFCs: Vac Raw RFC (#143)
  • 2024-10-03c655980 — Eth secpm splitted (#91)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-05-277e3a625 — 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:

  1. Asynchronous Messaging: Users can send messages even if the recipients are not online at the moment.

  2. 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.

  3. 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:

  1. security,
  2. scalability, and
  3. 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:

  1. m_1 and m_2 were sent by the same group member, and m_1 was sent before m_2.
  2. m_2 was sent by a group member U, and m_1 was received and processed by U before sending m_2.
  3. There exists m_3 such that m_1 < m_3 and m_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:

  1. A user calls the create function and broadcasts a control message of type create.
  2. Each receiver of the message processes the message and broadcasts an ack control message.
  3. 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:

  1. If the control message was dispatched by the local user, it uses the most recent invocation of generate-seed stored the seed secret in gamma.nextSeed.
  2. If the control message was dispatched by another user, and the local user is among its recipients, the function utilizes decrypt-from to decrypt the direct message that includes the seed secret.
  3. Otherwise, it returns an ack message 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:

  1. A server generates a unique Nonce for each user intending to sign in.
  2. A user initiates a request to connect to a website using their wallet.
  3. The user is presented with a distinctive message that includes the Nonce and details about the website.
  4. The user authenticates their identity by signing in with their wallet.
  5. Upon successful authentication, the user's identity is confirmed or approved.
  6. 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:

  1. The server corresponds to user D1,who initiates a request (instead of generating a nonce) to obtain the public key of user D2.
  2. Upon receiving the request, the wallet of D2 send the request to the user,
  3. User D2 receives the request from the wallet, and decides whether accepts or rejects.
  4. 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:

  1. 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.
  2. 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.
  3. To get the ability to sign messages, this new leader should request the keypair associated to the ID to the wallet.
  4. Once the leader has been changed, it revocates access from DCGKA to the former leader using the DCGKA protocol.
  5. 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 and related rights waived via CC0.

References

ETH-MLS-OFFCHAIN

FieldValue
NameSecure channel setup using decentralized MLS and Ethereum accounts
Slug104
Statusraw
CategoryStandards Track
EditorUgur Sen [email protected]
Contributorsseemenkina [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-11-26e39d288 — VAC/RAW/ ETH-MLS-OFFCHAIN RFC multi-steward support (#193)
  • 2025-08-213b968cc — 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 named member 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 node creates and sends their credential includes keyPackage.
  • Each member creates voting proposals sends them to from MLS group during epoch E.
  • Meanwhile, the steward collects finalized voting proposals from MLS group and converts them into MLS proposals then sends them with corresponding commit 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.

  1. Each time a single steward initializes a group with group parameters with parameters as in section 8.1. Group Context in MLS RFC 9420.
  2. steward creates a group anouncement (GA) according to the previous step and broadcast it to the all network periodically. GA message is visible in network to all nodes.
  3. The each node who wants to be a member needs to obtain this anouncement and create credential includes keyPackage that is specified in MLS RFC 9420 section 10.
  4. The node send the KeyPackages in plaintext with its signature with current steward public 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 obtain KeyPackages to commit.
  5. The steward aggregates all KeyPackages utilizes them to provision group additions for new members, based on the outcome of the voting process.
  6. Any member start to create voting proposals for 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:

  1. Commit proposal: It is the proposal instance that is specified in Creating Voting Proposal section with Proposal.payload MUST show the commit request from members. Any member MAY create this proposal in any epoch and epoch steward MUST collect and commit YES voted proposals. This is the only proposal type common to both single steward and multi steward designs.
  2. Steward election proposal: This is the process that finalizes the steward 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 choosen steward list ends when the last steward in the list (the one at the final index) completes its commit. At that point, a new steward election proposal MUST be initiated again by any member during the corresponding epoch. The Proposal.payload field 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.
  3. 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 below sn_min,
    it is required to repeat the steward election proposal. Proposal.payload MUST 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:

  1. The steward initialized the group with the config file. This config file MUST contain (sn_min,sn_max) as the steward list size range.
  2. 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 size sn as a consensus among all members, as mentioned in the consensus section 2, according to the checks: the size of the proposed list sn is in the interval (sn_min,sn_max). Note that if the total number of members is below sn_min, then the steward list size MUST be equal to the total member count.
  3. 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 is epoch steward or backup steward, otherwise anyone can create emergency criteria proposal.
  4. If the epoch steward violates the changing process as mentioned in the section Steward violation list, one of the members MUST initialize the emergency criteria proposal to remove the malicious Steward. Then backup steward fulfills 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:

  1. Broken commit: The steward releases a different commit message from the voted commit proposal. This activity is identified by the members since 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:
    1. The commit belongs to the earlier epoch.
    2. The commit message should equal the latest epoch
    3. The commit needs to be compatible with the previous epoch’s MLS proposal.
  2. Broken MLS proposal: The steward prepares a different MLS proposal for the corresponding voting proposal. This activity is identified by the members since both MLS proposal and voting proposal are visible and can be identified by checking the hash of Proposal.payload and MLSProposal.payload is the same as RFC9240 section 12.1. Proposals.
  3. 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 memberssince voting proposals are visible to every member in the group, therefore each member can verify that there is no MLS proposal corresponding to voting proposal.

Security Considerations

In this section, the security considerations are shown as de-MLS assurance.

  1. 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.
  2. Malicious Member: A member is only marked as malicious when the member acts by releasing a commit message.
  3. 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 and related rights waived via CC0

References

ETH-MLS-ONCHAIN

FieldValue
NameSecure channel setup using decentralized MLS and Ethereum accounts
Slug101
Statusraw
CategoryStandards Track
EditorRamses Fernandez [email protected]
ContributorsAaryamann Challani [email protected], Ekaterina Broslavskaya [email protected], Ugur Sen [email protected], Ksr [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-04517b639 — Update the RFCs: Vac Raw RFC (#143)
  • 2024-10-03c655980 — Eth secpm splitted (#91)
  • 2024-08-2913aaae3 — Update eth-secpm.md (#84)
  • 2024-05-21e234e9d — Update eth-secpm.md (#35)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-28b842725 — Update eth-secpm.md
  • 2024-02-01f2e1b4c — Rename ETH-SECPM.md to eth-secpm.md
  • 2024-02-0122bb331 — Update ETH-SECPM.md
  • 2024-01-275b8ce46 — 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:

  • X488 as Diffie-Hellman function.
  • SHA256 as KDF.
  • AES256-GCM as AEAD algorithm.
  • SHA512 as hash function.
  • XEd448 for 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. figure1

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 a PublicMessage MUST 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 KeyPackage is downloaded by a group member, before it is used to add the client to the group.
  • When a KeyPackage is received by a group member in an Add message.

Verification MUST be done as follows:

  • Verify that the cipher suite and protocol version of the KeyPackage match those in the GroupContext.
  • Verify that the leaf_node of the KeyPackage is valid for a KeyPackage.
  • Verify that the signature on the KeyPackage is valid.
  • Verify that the value of leaf_node.encryption_key is different from the value of the init_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_key for the epoch according to Section 8 of RFC9420.
  • Compute a confirmation_tag over the empty confirmed_transcript_hash using the confirmation_key as described in Section 8.1 of RFC9420.
  • Compute the updated interim_transcript_hash from the confirmed_transcript_hash and the confirmation_tag as 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:

figure2

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

figure3

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:

figure4

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

figure5

The flow diagram below shows an update procedure:

figure6

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

  1. On-chain: Alice creates a Smart Contract with ACL.
  2. Off-chain: Alice sends the contract address and an invitation message to Bob over the secure channel.
  3. Off-chain: Bob sends a signed response confirming his Ethereum address and agreement to join.
  4. Off-chain: Alice verifies the signature using the public key of Bob.
  5. On-chain: Alice adds Bob’s address to the ACL.
  6. Off-chain: Alice sends a welcome message to Bob.
  7. Off-chain: Alice sends a broadcast message to all group members, notifying them the addition of Bob.

figure8

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:

  1. Off-chain: Bob creates a new update request.
  2. Off-chain: Bob sends the update request to Alice.
  3. Off-chain: Alice verifies the request.
  4. On-chain: If the verification is successfull, Alice sends it to the smart contract for registration.
  5. Off-chain: Alice sends a broadcast message communicating the update to all users.

figure9

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.

  • scheme OPTIONAL. The URI scheme of the origin of the request. Its value MUST be a RFC 3986 URI scheme.

  • domain REQUIRED. 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.

  • address REQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address encoding specified in ERC-55 where applicable.

  • statement OPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a).

  • uri REQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing.

  • version REQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification.

  • chain-id REQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved.

  • nonce REQUIRED. A random string (minimum 8 alphanumeric characters) chosen by the relying party and used to prevent replay attacks.

  • issued-at REQUIRED. The time when the message was generated, typically the current time.

Its value MUST be an ISO 8601 datetime string.

  • expiration-time OPTIONAL. The time when the signed authentication message is no longer valid.

Its value MUST be an ISO 8601 datetime string.

  • not-before OPTIONAL. The time when the signed authentication message will become valid.

Its value MUST be an ISO 8601 datetime string.

  • request-id OPTIONAL. An system-specific identifier that MAY be used to uniquely refer to the sign-in request.

  • resources OPTIONAL. 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 address could 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.
  • origin of 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 origin being localhost.

The algorithm is described as follows:

  • If scheme was not provided, then assign defaultScheme as scheme.
  • If scheme is not contained in allowedSchemes, then the scheme is 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 scheme does 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 host part of the domain and origin do 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 domain and origin have 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 port be the port component of domain, and if no port is contained in domain, assign port the default port specified for the scheme.
  • If port is not empty, then the Wallet SHOULD show a warning if the port does not match the port of origin.
  • If port is empty, then the Wallet MAY show a warning if origin contains 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 and related rights waived via CC0.

References

ETH-SECPM

FieldValue
NameSecure channel setup using Ethereum accounts
Slug110
Statusdeleted
CategoryStandards Track
EditorRamses Fernandez [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-06-0536caaa6 — Fix Errors rfc.vac.dev (#165)
  • 2025-06-02db90adc — Fix LaTeX errors (#163)
  • 2025-04-04517b639 — Update the RFCs: Vac Raw RFC (#143)
  • 2024-08-2913aaae3 — Update eth-secpm.md (#84)
  • 2024-05-21e234e9d — Update eth-secpm.md (#35)
  • 2024-03-212eaa794 — Broken Links + Change Editors (#26)
  • 2024-02-28b842725 — Update eth-secpm.md
  • 2024-02-01f2e1b4c — Rename ETH-SECPM.md to eth-secpm.md
  • 2024-02-0122bb331 — Update ETH-SECPM.md
  • 2024-01-275b8ce46 — 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:

  1. 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.
  2. Execution of X3DH: This step will output a common secret key SK together with an additional data vector AD. Both will be used in the double ratchet algorithm initialization.
  3. 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:

  • X488 as Diffie-Hellman function DH.
  • SHA256 as KDF.
  • AES256-GCM as AEAD algorithm.
  • SHA512 as hash function.
  • XEd448 for 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 ik must be kept secret,
  • and the key IK is 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 SK output from X3DH becomes the SK input of the double ratchet. See section 3.3 of Signal Specification for a detailed description.
  • The AD output from X3DH becomes the AD input of the double ratchet. See sections 3.4 and 3.5 of Signal Specification for a detailed description.
  • Bob’s signed prekey SigSPKB from X3DH is used as Bob’s initial ratchet public key of the double ratchet.

X3DH has three phases:

  1. Bob publishes his identity key and prekeys to a server, a network, or dedicated smart contract.
  2. Alice fetches a prekey bundle from the server, and uses it to send an initial message to Bob.
  3. 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 AD and SK.

Upon reception of the initial message, Bob MUST:

  1. Perform the same computations above with the DH() function.
  2. Derive SK and construct AD.
  3. Decrypt the initial message encrypted with AES256-GCM.
  4. 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.

  1. Ethereum stores a reference or a hash that points to the off-chain data.
  2. 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:

  • X488 as Diffie-Hellman function.
  • SHA256 as KDF.
  • AES256-GCM as AEAD algorithm.
  • SHA512 as hash function.
  • XEd448 for 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. figure1

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 a PublicMessage MUST 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 KeyPackage is downloaded by a group member, before it is used to add the client to the group.
  • When a KeyPackage is received by a group member in an Add message.

Verification MUST be done as follows:

  • Verify that the cipher suite and protocol version of the KeyPackage match those in the GroupContext.
  • Verify that the leaf_node of the KeyPackage is valid for a KeyPackage.
  • Verify that the signature on the KeyPackage is valid.
  • Verify that the value of leaf_node.encryption_key is different from the value of the init_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_key for the epoch according to Section 8 of RFC9420.
  • Compute a confirmation_tag over the empty confirmed_transcript_hash using the confirmation_key as described in Section 8.1 of RFC9420.
  • Compute the updated interim_transcript_hash from the confirmed_transcript_hash and the confirmation_tag as 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: figure2

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

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: figure4

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

figure5

The flow diagram below shows an update procedure:

figure6

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:

  • scheme OPTIONAL. The URI scheme of the origin of the request. Its value MUST be a RFC 3986 URI scheme.

  • domain REQUIRED. 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.

  • address REQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address encoding specified in ERC-55 where applicable.

  • statement OPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a).

  • uri REQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing.

  • version REQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification.

  • chain-id REQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved.

  • nonce REQUIRED. A random string (minimum 8 alphanumeric characters) chosen by the relying party and used to prevent replay attacks.

  • issued-at REQUIRED. The time when the message was generated, typically the current time.

Its value MUST be an ISO 8601 datetime string.

  • expiration-time OPTIONAL. The time when the signed authentication message is no longer valid.

Its value MUST be an ISO 8601 datetime string.

  • not-before OPTIONAL. The time when the signed authentication message will become valid.

Its value MUST be an ISO 8601 datetime string.

  • request-id OPTIONAL. An system-specific identifier that MAY be used to uniquely refer to the sign-in request.

  • resources OPTIONAL. 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 address could 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.
  • origin of 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 origin being localhost.

The algorithm is described as follows:

  • If scheme was not provided, then assign defaultScheme as scheme.
  • If scheme is not contained in allowedSchemes, then the scheme is 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 scheme does 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 host part of the domain and origin do 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 domain and origin have 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 port be the port component of domain, and if no port is contained in domain, assign port the default port specified for the scheme.
  • If port is not empty, then the Wallet SHOULD show a warning if the port does not match the port of origin.
  • If port is empty, then the Wallet MAY show a warning if origin contains 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.

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.

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

  1. Off-chain - Alice and Bob set a secure communication channel.
  2. Alice creates the smart contract associated to the group. This smart contract MUST include an ACL.
  3. Alice adds Bob’s Ethereum address to the ACL.
  4. 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"}
  5. 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}
  6. Off-chain - Alice verifies the signature, using Bob’s ethereum_pk and checks that it corresponds to an address contained in the ACL.
  7. Off-chain - Alice sends a welcome message to Bob.
  8. Off-chain - Alice SHOULD broadcasts a message announcing the addition of Bob to other users of the group. figure7

Alice does not know Bob’s Ethereum address

  1. Off-chain - Alice and Bob set a secure communication channel.
  2. Alice creates the smart contract associated to the group. This smart contract MUST include an ACL.
  3. 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"}
  4. 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}
  5. Off-chain - Alice verifies the signature using Bob’s ethereum_pk.
  6. Upon reception of Bob’s data, Alice registers data with the smart contract.
  7. Off-chain - Alice sends a welcome message to Bob.
  8. Off-chain - Alice SHOULD broadcasts a message announcing the addition of Bob to other users of the group.

figure8

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.

figure9

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 and related rights waived via CC0.

References

EXTENDED-KADEMLIA-DISCOVERY

FieldValue
NameExtended Kademlia Discovery with capability filtering
Slug143
Statusraw
CategoryStandards Track
EditorSimon-Pierre Vivier [email protected]
ContributorsHanno Cornelius [email protected]

Timeline

  • 2026-01-268bba444 — chore: fix lint (#275)
  • 2026-01-238164992 — chore: fix lint

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 identify protocol implementations, such as go-libp2p, as at the time of writing (Jan 2026) the signedPeerRecord field 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):

  1. Choose a random value in the Kad-DHT key space. (R_KEY).

  2. Follow the Kad-DHT peer routing algorithm, with R_KEY as the target. This procedure loops the Kad-DHT FIND_NODE procedure 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's closerPeers field, as a randomly discovered node of potential interest. The discoverer MUST keep track of such peers as discoveredPeers.

  3. For each discoveredPeer, attempt to retrieve a corresponding XPR. This can be done in one of two ways:

    3.1 If the discoveredPeer in the response contains at least one multiaddress in the addrs field, attempt a connection to that peer and wait to receive the XPR as part of the identify procedure.

    3.2 If the discoveredPeer does not include addrs information, or the connection attempt to included addrs fails, or more service information is required before a connection can be attempted, MAY perform a value retrieval procedure to the discoveredPeer ID.

  4. 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 the services field of the XPR. The discoverer SHOULD ignore (and disconnect, if already connected) discovered peers with invalid XPRs or that do not advertise the services of 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 and related rights waived via CC0.

References

EXTENSIBLE-PEER-RECORDS

FieldValue
NameExtensible Peer Records
Slug74
Statusraw
CategoryStandards Track
EditorHanno Cornelius [email protected]
ContributorsSimon-Pierre Vivier [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — 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 PeerRecord type, 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:

  1. To augment identify with peer capabilities: The libp2p identify protocol allows peers to exchange critical information, such as supported protocols, on first connection. The peer record (in a signed envelope) can also be exchanged during identify. 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 libp2p mix protocol also needing to exchange the mix key before the service can be used.
  2. 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 and related rights waived via CC0.

References

GOSSIPSUB-TOR-PUSH

FieldValue
NameGossipsub Tor Push
Slug105
Statusraw
CategoryStandards Track
EditorDaniel Kaiser [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-05-2799be3b9 — Move Raw Specs (#37)
  • 2024-02-01cd8c9f4 — Update and rename GOSSIPSUB-TOR-PUSH.md to gossipsub-tor-push.md
  • 2024-01-270db60c1 — 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 and related rights waived via CC0.

References

HASHGRAPHLIKE CONSENSUS

FieldValue
NameHashgraphlike Consensus Protocol
Slug73
Statusraw
CategoryStandards Track
EditorUgur Sen [email protected]
Contributorsseemenkina [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-09-15f051117 — 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/3 honest 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, where n is 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:

  1. Initialization of voting
  2. Exchanging votes across the rounds
  3. 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:

  1. Check the signatures of the each votes in proposal, in particular for proposal P_1, verify the signature of V_1 where V_1 = P_1.votes[0] with V_1.signature and V_1.vote_owner

  2. Do parent_hash check: If there are repeated votes from the same sender, check that the hash of the former vote is equal to the parent_hash of the later vote.

  3. Do received_hash check: If there are multiple votes in a proposal, check that the hash of a vote is equal to the received_hash of the next one.

  4. After successful verification of the signature and hashes, the receiving peer proceeds to generate P_2 containing a new vote V_2 as 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 = 0 if 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_id for the vote.

    4.7. Calculate vote_hash by 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_hash with its private key corresponding the public key as vote_owner component then adds V_2.vote_hash.

  5. Create P_2 with by adding V_2 as follows:

    5.1. Assign P_2.name, P_2.proposal_id, and P_2.proposal_owner to be identical to those in P_1.

    5.2. Add the V_2 to the P_2.Votes list.

    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.

  1. Check each signature in the vote as shown in the Section 2.

  2. Check the parent_hash chain if there are multiple votes from the same owner namely vote_i and vote_i+1 respectively, the parent hash of vote_i+1 should be the hash of vote_i

  3. Check the previous_hash chain, each received hash of vote_i+1 should be equal to the hash of vote_i.

  4. Check the timestamp against the replay attack. In particular, the timestamp cannot be the old in the determined threshold.

  5. 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:

  1. 2n/3 rounds in pure P2P networks
  2. 2 rounds 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.

  1. Silent peers means YES: Silent peers counted as YES vote, if the application prefer the strong rejection for NO votes.
  2. 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.

Copyright and related rights waived via CC0

6. References

LOGOS-CAPABILITY-DISCOVERY

FieldValue
NameLogos Capability Discovery Protocol
Slug107
Statusraw
CategoryStandards Track
EditorArunima Chaudhuri [email protected]
ContributorsUgur Sen [email protected], Hanno Cornelius [email protected]

Timeline

  • 2026-01-27ab9337e — Add recipe for algorithms (#232)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-12-09aaf158a — 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 IdentifierService ID
/waku/store/1.0.0313a14f48b3617b0ac87daabd61c1f1f1bf6a59126da455909b7b11155e0eb8e
/libp2p/mix/1.2.09c55878d86e575916b267195b34125336c83056dffc9a184069bcb126a78115d

An advertisement cache ad_cache is a bounded storage structure maintained by registrars to store accepted advertisements.

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

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.

ParameterDefault ValueDescription
K_register3Max number of active (i.e. unexpired) registrations + ongoing registration attempts per bucket.
K_lookup5For each bucket in the search table, number of random registrar nodes queried by discoverers
F_lookup30number of advertisers to find in the lookup process. We stop lookup process when we have found these many advertisers
F_return10max number of service-specific peers returned from a single registrar
E900 secondsAdvertisement expiry time (15 minutes)
C1,000Advertisement cache capacity
P_occ10Occupancy exponent for waiting time calculation
G10⁻⁷Safety parameter for waiting time calculation
δ1 secondRegistration window time
m16Number 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 table
  • d = 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 of d
  • 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 Record message 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 key field is reused for service_id_hash in Logos operations
  • Nodes without Logos Capability Discovery support will ignore REGISTER and GET_ADS messages

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.data field 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

FieldUsageValue
typeREQUIREDREGISTER (6)
keyREQUIREDservice_id_hash (32 bytes)
register.advertisementREQUIREDEncoded advertisement as bytes (RECOMMENDED: XPR)
register.ticketOPTIONALTicket from previous registration attempt
All other fieldsUNUSEDEmpty/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

FieldUsageValue
typeREQUIREDREGISTER (6)
register.statusREQUIREDCONFIRMED, WAIT, or REJECTED
closerPeersREQUIREDList of peers for advertise table
register.ticketCONDITIONALPresent if status = WAIT
All other fieldsUNUSEDEmpty/not set

Status values:

  • CONFIRMED: Advertisement stored in cache
  • WAIT: Not yet accepted, wait and retry with ticket
  • REJECTED: 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

FieldUsageValue
typeREQUIREDGET_ADS (7)
keyREQUIREDservice_id_hash (32 bytes)
All other fieldsUNUSEDEmpty/not set

Example:

Message {
    type: GET_ADS
    key: <service_id_hash>
}

GET_ADS Response

FieldUsageValue
typeREQUIREDGET_ADS (7)
getAds.advertisementsREQUIREDList of encoded advertisements (up to F_return = 10)
closerPeersREQUIREDList of peers for search table
All other fieldsUNUSEDEmpty/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:

  1. type = REGISTER (6)
  2. key = 32 bytes (valid SHA-256)
  3. register.advertisement is present and non-empty
  4. If register.ticket present:
    • Valid signature issued by this registrar
    • ticket.ad matches register.advertisement
    • Retry within window: ticket.t_mod + ticket.t_wait_for ≤ NOW() ≤ ticket.t_mod + ticket.t_wait_for + δ
  5. Advertisement signature is valid (see Advertisement Signature Verification)
  6. Advertisement not already in ad_cache

Respond with register.status = REJECTED if validation fails.

GET_ADS Request Validation

Registrars MUST validate:

  1. type = GET_ADS (7)
  2. key = 32 bytes (valid SHA-256)

Return empty getAds.advertisements list or close stream if validation fails.

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) in ServiceInfo.id. Logos discovery uses service_id_hash = SHA256(ServiceInfo.id) for routing. When verifying, implementations MUST hash the protocol string and compare with the key field (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

ErrorHandling
Invalid message formatClose stream
Signature verification failureREJECTED for REGISTER; discard invalid ads for GET_ADS
TimeoutClose stream, retry with exponential backoff
Cache full (registrar)Issue ticket with waiting time
Unknown service_id_hashEmpty advertisements list but include closerPeers
Missing required fieldsClose 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

Sequence diagram

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 ticket from a previous registration attempt for the same ad in the same registrar, the ticket MUST 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 status field and schedule actions accordingly:
    • If the status is Confirmed, the registration is maintained in the registrar's ad_cache for E seconds. After E seconds the advertiser MUST remove the registration from the ongoing registrations for that bucket.
    • If the status is Wait, the advertiser MUST schedule a next registration attempt to the same registrar based on the ticket.t_wait_for value included in the response. The Response contains a ticket, which MUST be included in the next registration attempt to this registrar.
    • If the status is Rejected, 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.

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_lookup random registrar nodes from every bucket of DiscT(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_return advertisement 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 ad already exists in the ad_cache.
  • The Registrar MUST calculate waiting time using the formula in Waiting Time Calculation.
  • If no ticket is provided in the REGISTER request then this is the advertiser's first registration attempt for the ad. The registrar MUST create a new ticket and return the signed ticket to the advertiser with status Wait.
  • If a ticket is provided, the registrar MUST verify:
    • valid signature issued by this registrar
    • ticket.ad matches current ad
    • ad is not in the ad_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 the ad to the ad_cache. The registrar MUST track the admission time internally for expiry management. The registrar SHOULD return response with status = Confirmed
  • If t_remaining > 0, the advertiser SHOULD continue waiting. The registrar MUST issue a new ticket with updated ticket.t_mod and ticket.t_wait_for = MIN(E, t_remaining). The registrar MUST sign the new ticket. The registrar SHOULD return response with status Wait and the new signed ticket.
  • 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 E from 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_return advertisements from their ad_cache for the requested service_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.

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 their KadDHT(peerID) using the formula described in the Distance Section.
  • When an advertiser sends a REGISTER request, the registrar SHOULD add the advertiser's peerID to RegT(service_id_hash).
  • When a discoverer sends a GET_ADS request, the registrar SHOULD add the discoverer's peerID to RegT(service_id_hash).
  • When registrars receive responses from other registrars (if acting as advertiser or discoverer themselves), they SHOULD add peers from closerPeers fields to relevant RegT(service_id_hash) tables.

Note: The ad_cache and RegT(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 their DiscT(service_id_hash) tables.
  • SHOULD return peers that are diverse and distributed across different buckets to prevent malicious registrars from polluting routing tables.

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
  1. peers is initialized as an empty set
  2. RegT(service_id_hash) is initialized from the node’s KadDHT(peerID).
  3. Go through all m buckets in the registrar’s table —
    1. Pick one random peer from bucket i. getRandomNode() function remembers already returned nodes and never returns the same one twice.
    2. If peer returned is not null then we move on to next bucket. Else we try to get another peer in the same bucket
  4. Return peers which contains one peer from every bucket of RegT(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 occupancy
  • c(ad.service_id_hash): Number of advertisements for service_id_hash in cache
  • getIP(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_cache fills up, the score will be amplified by the divisor of the equation. The higher the value of P_occ, the faster the increase. Implementations should consider this while setting the value for P_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_hash has few advertisements in cache. Thus lower waiting time.
  • High when service_id_hash dominates 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 ads currently in the ad_cache.
  • MUST update its tracking structure when:
    • A new ad is admitted to the ad_cache: MUST add the IP
    • An ad expires after time E: MUST remove IP if there are no other active ads from the same IP
  • 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 ad is admitted to the ad_cache, its IPv4 address is added to the IP tracking structure using the ADD_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 ad is removed from the ad_cache after E, The registrar also removes the IP from IP tracking structure using the REMOVE_FROM_IP_TREE() algorithm if there are no other active ad in ad_cache from the same IP.
  • All the algorithms work efficiently with O(32) time complexity.
  • The root IP_counter tracks total IPs currently in ad_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) and timestamp(service_id_hash)
  • Each IP prefix in the IP tree: bound(IP) and timestamp(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

[3] Go implementation

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.

  1. Initialize a map ongoing for tracking which registrars are currently being advertised to.
  2. Initialize AdvT(service_id_hash) by bootstrapping peers from the advertiser’s KadDHT(peerID) using the formula described in the Distance section.
  3. Iterate over all buckets (i = 0 through m-1), where m is the number of buckets in AdvT(service_id_hash) and ongoing map. Each bucket corresponds to a particular distance from the service_id_hash.
    1. ongoing[i] contains list of registrars with active (unexpired) registrations or ongoing registration attempts at a distance i from the service_id_hash of the service that the advertiser is advertising for.
    2. Advertisers continuously maintain up to K_register active (unexpired) registrations or ongoing registration attempts in every bucket of the ongoing map for its service. Increasing K_register makes the advertiser easier to find at the cost of increased communication and storage costs.
    3. Pick a random registrar from bucket i of AdvT(service_id_hash) to advertise to.
      • AdvT(service_id_hash).getBucket(i) → returns a list of registrars in bucket i from AdvT(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 same ad placement process. If there are no peers, it returns None.
    4. if we get a peer then we add that to that bucket ongoing[i]
    5. Build the advertisement object ad containing service_id_hash, peerID, and addrs (Refer to the Advertisement section). Then it is signed by the advertiser using the node’s private key (Ed25519 signature)
    6. Then send this ad asynchronously to the selected registrar. The helper ADVERTISE_SINGLE() will handle registration to a single registrar. Asynchronous execution allows multiple ads (to multiple registrars) to proceed in parallel.

ADVERTISE_SINGLE() algorithm handles registration to one registrar at a time

  1. Initialize ticket to None as we have not yet got any ticket from registrar
  2. Keep trying until the registrar confirms or rejects the ad.
    1. Send the ad to the registrar using Register request. Request structure is described in section Register Message Structure. If we already have a ticket, include it in the request.
    2. The registrar replies with a response. Refer to the Register Message Structure section for the response structure
    3. Add the list of peers returned by the registrar response.closerPeers to AdvT(service_id_hash). Refer to the [Distance](#distance section) on how to add.
    4. If the registrar accepted the advertisement successfully, wait for E seconds, then stop retrying because the ad is already registered.
    5. 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 than E). Then update ticket with the new one from the registrar, and try again.
    6. If the registrar rejects the ad, stop trying with this registrar.
  3. Remove this registrar from the ongoing map 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.

  1. DiscT(service_id_hash) is initialized by bootstrapping peers from the discoverer’s KadDHT(peerID) using the formula described in the Distance section.
  2. Create an empty set foundPeers to store unique advertisers peer IDs discovered during the lookup.
  3. Go through each bucket of DiscT(service_id_hash) — from farthest (b₀) to closest (bₘ₋₁) to the service ID service_id_hash. For each bucket, query up to K_lookup random peers.
    1. Pick a random registrar node from bucket i of DiscT(service_id_hash) to query
      1. DiscT(service_id_hash).getBucket(i) → returns a list of registrars in bucket i from DiscT(service_id_hash)
      2. .getRandomNode() → function returns a random registrar node. The discover queries this node to get ads for a particular service ID service_id_hash. The function remembers already returned nodes and never returns the same one twice. If there are no peers, it returns None.
    2. A GET_ADS request is sent to the selected registrar peer. Refer to the GET_ADS Message section to see the request and response structure for GET_ADS. The response returned by the registrar node is stored in response
    3. The response contains a list of advertisements response.ads. A queried registrar returns at most F_return advertisements. If it returns more we can just randomly keep F_return of them. For each advertisement returned:
      1. Verify its digital signature for authenticity.
      2. Add the advertiser’s peer ID ad.peerID to the list foundPeers.
    4. The response also contains a list of peers response.closerPeers that is inserted into DiscT(service_id_hash) using the formula described in the Distance section.
    5. Stop early if enough advertiser peers (F_lookup) have been found — no need to continue searching. For popular services F_lookup advertisers 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 than O(log N) where N is number of nodes participating in the network as registrars.
    6. If early stop doesn’t happen then the search stops when no unqueried registrars remain in any of the buckets.
  4. Return foundPeers which is the final list of discovered advertisers that provide service service_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

  1. Make sure this advertisement ad is not already in the registrar’s advertisement cache ad_cache.
  2. Prepare a response ticket response.ticket linked to this ad.
  3. Then calculate how long the advertiser should wait t_wait before being admitted. Refer to the Waiting Time Calculation section for details.
  4. Check if this is the first registration attempt (no ticket yet):
    1. If yes then it’s the first try. The advertiser must wait for the full waiting time t_wait. The ticket’s creation time t_init and last-modified time t_mod are both set to NOW().
    2. If no, then this is a retry, so a previous ticket exists.
      1. Validate that the ticket is properly signed by the registrar, belongs to this same advertisement and that the ad is still not already in the ad_cache.
      2. 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.
      3. Calculate how much waiting time is left t_remaining by subtracting how long the advertiser has already waited (NOW() - ticket.t_init) from t_wait.
  5. Check if the remaining waiting time t_remaining is less than or equal to 0. This means the waiting time is over. t_remaining can be 0 also when the registrar decides that the advertiser doesn’t have to wait for admission to the ad_cache(waiting time t_wait is 0).
    1. If yes, add the ad to ad_cache and confirm registration. The advertisement is now officially registered.
    2. If no, then there is still time to wait. In this case registrar does not store ad but instead issues a ticket.
      1. set reponse.status to wait
      2. Update the ticket with the new remaining waiting time t_wait_for
      3. Update the ticket last modification time t_mod
      4. Sign the ticket again. The advertiser will retry later using this new ticket.
  6. Add a list of peers closer to the ad.service_id_hash using the GETPEERS() function to the response (the advertiser uses this to update AdvT(service_id_hash)).
  7. 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

FieldValue
NameLIBP2P-MIX
Slug99
Statusraw
CategoryStandards Track
EditorPrem Prathi [email protected]
ContributorsAkshaya Mani [email protected], Daniel Kaiser [email protected], Hanno Cornelius

Timeline

  • 2026-01-29925aeac — chore: changes to how per-hop proof is added to sphinx packet which makes it simpler (#263)
  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-12-15dabc317 — fixing format errors in mix rfc (#229)
  • 2025-12-124f54254 — fix format errors in math sections for mix rfc (#225)
  • 2025-12-117f1df32 — chore: use sembreaks for easy review and edits (#223)
  • 2025-12-10e742cd5 — RFC Addition: Section 9 Security Considerations (#194)
  • 2025-12-109d11a22 — docs: finalize Section 8 Sphinx Packet Construction and Handling (#202)
  • 2025-10-0536be428 — RFC Refactor: Sphinx Packet Format (#173)
  • 2025-06-275e3b478 — RFC Refactor PR: Modular Rewrite of Mix Protocol Specification (#158)
  • 2025-06-02db90adc — Fix LaTeX errors (#163)
  • 2024-11-0838fce27 — typo fix
  • 2024-09-167f5276e — 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 mixify flag 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.

  1. Entry-side Integration (Mix Entry Layer):

    • The origin protocol generates a message and sets the mixify flag.
    • 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.
  2. 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.
  3. 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 mix field 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:

  1. 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$.
  2. 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}$.
  3. 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 bytes
    

    Serialize 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:

  1. Derive Session Key

    Let $x \in \mathbb{Z}_q^*$ denote the node's X25519 private key. Compute the shared secret $s = α^x$.

  2. 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.

  3. 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.

  4. 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.

  5. 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:

  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.

  2. 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.

  3. Update Payload

    Use the decrypted payload $δ'$ computed in Section 8.6.1 step 5. as the payload in the outgoing packet.

  4. Assemble Final Packet The final Sphinx packet is structured as defined in Section 8.3:

    α = α'      // 32 bytes
    β = β'      // 576 bytes
    γ = γ'      // 16 bytes
    δ = δ'      // 3984 bytes
    

    Serialize $α'$ using the same format used in Section 8.5.2. The remaining fields are already fixed-length buffers and do not require further transformation.

  5. 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.

  6. 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:

  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}$

  2. 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}$

  3. 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.

  4. 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:

  1. 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)

  2. 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
  1. 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.
  2. The first hop extracts and verifies $\sigma$ before processing the Sphinx packet.
  3. After successful verification and Sphinx processing, the hop generates a new proof $\sigma'$ for the next hop bound to the transformed packet.
  4. The updated packet is forwarded to the next hop.
  5. 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.

AspectSender-Generated ProofsPer-Hop Generated Proofs
DoS protectionWeaker (verify after Sphinx decryption)Stronger (verify before Sphinx decryption)
Sender burdenHigh (generates $L$ proofs)Low (generates 1 proof)
Per-hop computational overheadLow (verify only)High (verify + generate)
Per-hop latencyMinimal (fast verification)Higher (mitigated with pre-computation)
Total end-to-end latencyLowerHigher (mitigated with pre-computation)
Sybil resistanceRequires separate mechanismCan 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.

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_data produced by GenerateProof. 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_data gracefully and return false.
  • 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:

  1. For each hop $i$ in path (from $i = 0$ to $L-1$):
    • Call GenerateProof(binding_data = δ_{i+1}) to generate encoded_proof_data for hop $i$
    • Embed the encoded_proof_data in hop $i$'s routing block within $\beta_i$ during header construction (Step 3.c)

Intermediate Nodes- During Packet Processing (Section 8.6.1):

After decrypting the routing block $\beta$ and payload $\delta'$ (Steps 4-5), the node MUST:

  1. Extract encoded_proof_data from the routing block $\beta$ at the appropriate offset
  2. Call VerifyProof(encoded_proof_data, binding_data = δ')
  3. If valid = false, discard the packet and terminate processing
  4. 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:

  1. Call GenerateProof(binding_data) where binding_data is the complete Sphinx packet bytes
  2. Append encoded_proof_data after 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:

  1. Extract encoded_proof_data from the last proofSize bytes of the received packet
  2. Call VerifyProof(encoded_proof_data, binding_data) where binding_data is the Sphinx packet bytes
  3. If the proof is not valid, discard the packet and terminate processing immediately
  4. If valid, perform standard Sphinx processing, then call GenerateProof(binding_data) with the transformed packet as binding_data
  5. Append the new encoded_proof_data to 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 D disjoint 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

FieldValue
NameMulti-message_id burn feature RLN
Slug141
Statusraw
CategoryStandards Track
EditorUgur Sen [email protected]

Timeline

  • 2026-01-2170f3cfb — 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_limitmessage_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

  • x
  • external_nullifier
  • selector_used []

Private Inputs

  • identity_secret_hash
  • path_elements
  • identity_path_index
  • message_id []
  • user_message_limit

Outputs

  • y []
  • root
  • internal_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 < imax_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 and related rights waived via CC0

References

NOISE-X3DH-DOUBLE-RATCHET

FieldValue
NameSecure 1-to-1 channel setup using X3DH and the double ratchet
Slug108
Statusraw
CategoryStandards Track
EditorRamses Fernandez [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-04-04517b639 — Update the RFCs: Vac Raw RFC (#143)
  • 2024-10-03c655980 — 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:

  1. 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.
  2. Execution of X3DH: This step will output a common secret key SK together with an additional data vector AD. Both will be used in the double ratchet algorithm initialization.
  3. 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:

  • X488 as Diffie-Hellman function DH.
  • SHA256 as KDF.
  • AES256-GCM as AEAD algorithm.
  • SHA512 as hash function.
  • XEd448 for 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 ik must be kept secret,
  • and the key IK is 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 SK output from X3DH becomes the SK input of the double ratchet. See section 3.3 of Signal Specification for a detailed description.
  • The AD output from X3DH becomes the AD input of the double ratchet. See sections 3.4 and 3.5 of Signal Specification for a detailed description.
  • Bob’s signed prekey SigSPKB from X3DH is used as Bob’s initial ratchet public key of the double ratchet.

X3DH has three phases:

  1. Bob publishes his identity key and prekeys to a server, a network, or dedicated smart contract.
  2. Alice fetches a prekey bundle from the server, and uses it to send an initial message to Bob.
  3. 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 AD and SK.

Upon reception of the initial message, Bob MUST:

  1. Perform the same computations above with the DH() function.
  2. Derive SK and construct AD.
  3. Decrypt the initial message encrypted with AES256-GCM.
  4. 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.

  1. Ethereum stores a reference or a hash that points to the off-chain data.
  2. 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 and related rights waived via CC0.

References

RLN DoS Protection for Mixnet

FieldValue
NameRLN DoS Protection for Mixnet
Slug144
Statusraw
CategoryStandards Track
EditorPrem 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:

  1. 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.
  2. 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 epoch refers 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:

  1. Use its secret key sk and the current epoch
  2. Obtain the current Merkle root and path_elements from the synchronized membership tree
  3. 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.

  1. If the epoch in the received message differs from the mix node's current epoch by more than max_epoch_gap.
  2. If the merkle_root is NOT in the acceptable_root_window_size past roots of the mix node.
  3. If the zero-knowledge proof proof is 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.

  1. If no entry exists for this nullifier, the node stores the messaging metadata in the cache and proceeds to process the message normally.
  2. If an entry exists and its share_x and share_y components are different from the incoming message, then proceed with slashing. The mix node uses the share_x and share_y of the new message and the shares from the local cache to reconstruct the sk of the message owner. The sk then 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.
  3. If the share_x and share_y fields 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.

ParameterTypeDescription
proofarray of 128 bytes compressedthe zkSNARK proof as explained in the Sending process
merkle_rootarray of 32 bytes in little-endian orderthe root of membership group Merkle tree at the time of sending the message
epocharray of 32 bytesthe current epoch at time of sending the message
share_x and share_yarray of 32 bytes eachShamir 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
nullifierarray of 32 bytesinternal 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.

ParameterDescription
periodthe length of epoch in seconds
staked_fundthe amount of funds to be staked by mix nodes at the registration
max_epoch_gapthe maximum allowed gap between the epoch of a mix node and the incoming message
acceptable_root_window_sizethe 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-500ms per hop depending on hardware capabilities
  • End-to-end impact: For a 3-hop path, this adds 300-1500ms to 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 and related rights waived via CC0.

References

RLN-INTEREP-SPEC

FieldValue
NameInterep as group management for RLN
Slug100
Statusraw
EditorAaryamann Challani [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-05-2799be3b9 — Move Raw Specs (#37)
  • 2024-02-01860bae2 — Update rln-interep-spec.md
  • 2024-02-013f722d9 — Update and rename README.md to rln-interep-spec.md
  • 2024-01-30ea62398 — 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 -

  1. Generate Semaphore credentials
  2. Verify reputation and join Interep group
  3. 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

  1. As mentioned in Slashing, the slashing mechanism may not have the intended effect on the user.
  2. This spec inherits the security considerations of the RLN spec.
  3. This spec inherits the security considerations of Interep.
  4. 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

  1. RLN spec
  2. Interep
  3. Semaphore
  4. Decentralized cloudflare using Interep
  5. Interep contracts
  6. RLN contract
  7. RLNP2P

RLN-STEALTH-COMMITMENTS

FieldValue
NameRLN Stealth Commitment Usage
Slug102
Statusraw
CategoryStandards Track
EditorAaryamann Challani [email protected]
ContributorsJimmy Debe [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-08-05eb25cd0 — chore: replace email addresses (#86)
  • 2024-04-150b0e00f — 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:

  1. Applications can add users to the 32/RLN-V1 membership set in a batch.
  2. 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 and related rights waived via CC0.

References

RLN-V2

FieldValue
NameRate Limit Nullifier V2
Slug106
Statusraw
EditorRasul Ibragimov [email protected]
ContributorsLev Soukhanov [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-16f01d5b9 — chore: fix links (#260)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2024-09-133ab314d — Fix Files for Linting (#94)
  • 2024-05-2799be3b9 — Move Raw Specs (#37)
  • 2024-02-018342636 — Update and rename RLN-V2.md to rln-v2.md
  • 2024-01-27d7e84b4 — 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:

  1. Registration
  2. Signaling
  3. 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:

  1. Sending identity_secret_hash = poseidonHash(identity_secret, userMessageLimit) and zk proof that user_message_limit is valid (is in the right range). This approach requires zkSNARK verification, which is an expensive operation on the blockchain.
  2. Sending the same identity_secret_hash as in 32/RLN-V1 (poseidonHash(identity_secret)) and a user_message_limit publicly to a server or smart-contract where rate_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 limit numbers, e.g. for the 16 - maximum limit number 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

  • x
  • external_nullifier
  • message_limit - limit per epoch

Private Inputs

  • identity_secret_hash
  • path_elements
  • identity_path_index
  • message_id

Outputs

  • y
  • root
  • internal_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

  • x
  • external_nullifier

Private Inputs

  • identity_secret_hash
  • path_elements
  • identity_path_index
  • message_id
  • user_message_limit

Outputs

  • y
  • root
  • internal_nullifier

Appendix A: Security considerations

Although there are changes in the circuits, this spec inherits all the security considerations of 32/RLN-V1.

Copyright and related rights waived via CC0.

References

SDS

FieldValue
NameScalable Data Sync protocol for distributed logs
Slug109
Statusraw
CategoryStandards Track
EditorHanno Cornelius [email protected]
ContributorsAkhil Peddireddy [email protected]

Timeline

  • 2026-01-19f24e567 — Chore/updates mdbook (#262)
  • 2026-01-1689f2ea8 — Chore/mdbook updates (#258)
  • 2025-12-220f1855e — Chore/fix headers (#239)
  • 2025-12-22b1a5783 — Chore/mdbook updates (#237)
  • 2025-12-18d03e699 — ci: add mdBook configuration (#233)
  • 2025-10-246980237 — Fix Linting Errors (#204)
  • 2025-10-13171e934 — docs: add SDS-Repair extension (#176)
  • 2025-10-026672c5b — docs: update lamport timestamps to uint64, pegged to current time (#196)
  • 2025-09-15b1da703 — fix: use milliseconds for Lamport timestamp initialization (#179)
  • 2025-08-223505da6 — sds lint fix (#177)
  • 2025-08-19536d31b — docs: re-add sender ID to messages (#170)
  • 2025-03-078ee2a6d — docs: add optional retrieval hint to causal history in sds (#130)
  • 2025-02-20235c1d5 — docs: clarify receiving sync messages (#131)
  • 2025-02-187182459 — docs: update sds sync message requirements (#129)
  • 2025-01-287a01711 — fix(sds): remove optional from causal history field in Message protobuf (#123)
  • 2024-12-1708b363d — Update SDS.md: Remove Errors (#115)
  • 2024-11-28bee78c4 — 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_id field 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:

  1. Unacknowledged - there has been no acknowledgement of message receipt by any participant in the channel
  2. Possibly acknowledged - there has been ambiguous indication that the message has been possibly received by at least one participant in the channel
  3. 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 + 1 and the current epoch time in milliseconds. In other words the local Lamport timestamp is set to max(timeNowInMs, current_lamport_timestamp + 1).
  • the participant MUST include the increased Lamport timestamp in the message's lamport_timestamp field.
  • the participant MUST determine the preceding few message IDs in the local history and include these in an ordered list in the causal_history field. The number of message IDs to include in the causal_history depends on the application. We recommend a causal history of two message IDs.
  • the participant MAY include a retrieval_hint in the HistoryEntry for each message ID in the causal_history field. This is an application-specific field to facilitate retrieval of messages, e.g. from high-availability caches.
  • the participant MUST include the current bloom_filter state 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_id matching its own.
  • the participant MAY deduplicate the message by comparing its message_id to 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 content field, 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_history of 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_history as acknowledged.
  • the participant MUST mark all messages included in the bloom_filter as 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_hint in the HistoryEntry to 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), where timeNowInMs is 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_id in HistoryEntry: the original message sender's participant ID. This is used to determine the group of participants who will respond to a repair request.
  • repair_request in Message: 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_req is computed as a pseudorandom backoff from the timestamp when the dependency was detected missing. Determining T_req is described below. We RECOMMEND that the outgoing repair request buffer be chronologically ordered in ascending order of T_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 requested Message if at that point no other participant had rebroadcast the Message. T_resp is computed as a pseudorandom backoff from the timestamp when the repair was first requested. Determining T_resp is 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 Message must 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_request field in the message with eligible entries from the outgoing repair request buffer. An entry is eligible to be included in a repair_request if 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 lowest T_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_req timestamp for that entry. It MUST compute the T_req for each such HistoryEntry according to the steps outlined in Determine T_req.
  • for each item in the repair_request field:
    • 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 Message in 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 unique T_resp timestamp for that entry. It MUST compute the T_resp for 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_req values 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_resp values 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 and related rights waived via CC0.

Zerokit API

FieldValue
NameZerokit API
Slug142
Statusraw
CategoryStandards Track
EditorVinh Trinh [email protected]

Timeline

  • 2026-01-2170f3cfb — 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.

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_config parameter accepts multiple types via the TreeConfigInput trait: a JSON string, a direct config object (with pmtree feature), 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_config parameter accepts multiple types via the TreeConfigInput trait.

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_index to max(next_index, index + n).
  • If n leaves are passed, they will be set at positions index, 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_index to 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_index to max(next_index, index + n) where n is 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_index value, then next_index is incremented.

delete_leaf(index)

  • Sets the leaf at the specified index to the default zero value.
  • Does not change the internal next_index value.

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_limit and path_elements and identity_path_index have 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:

  1. Create a WasmRLNWitnessInput with the required parameters
  2. Export to JSON format using toBigIntJson() method
  3. Pass the JSON to an external JavaScript witness calculator
  4. 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_witness should be a Vec<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 true if 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 x matches 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 WasmFr with fromBytesLE, fromBytesBE, toBytesLE, toBytesBE methods.
  • Vectors of field elements use VecWasmFr with push, get, length methods.
  • Identity generation uses Identity.generate() and Identity.generateSeeded(seed) static methods.
  • Extended identity uses ExtendedIdentity.generate() and ExtendedIdentity.generateSeeded(seed).
  • Witness input uses WasmRLNWitnessInput constructor and toBigIntJson() for witness calculator integration.
  • Proof generation requires external witness calculation via generateRLNProofWithWitness(calculatedWitness, witness).
  • When parallel feature is enabled, call initThreadPool() 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 CFr with corresponding conversion functions.
  • Results use CResult or CBoolResult structs with ok and err fields.
  • Errors are returned as C-compatible strings in the err field of result structs.
  • Memory must be explicitly freed using ffi_*_free functions.
  • Vectors use repr_c::Vec with ffi_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_id counter 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

Informative

Copyright and related rights waived via CC0.

TEMPLATE

FieldValue
NameRFC Template
Slug72
Statusraw/draft/stable/deprecated
CategoryStandards Track/Informational/Best Current Practice
EditorDaniel 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-protocol for Waku protocol definitions (e.g. store, relay, light push),
  • waku/application for 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 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.