RLN-API
| Field | Value |
|---|---|
| Name | RLN Module API |
| Slug | 245 |
| Status | raw |
| Category | Standards Track |
| Tags | rln, membership, spam-protection, api |
| Editor | Tanya Stubbs [email protected], Arseniy Klempner [email protected] |
Timeline
- 2026-07-31 —
0ba596c— docs(anoncomms): add spec for rln api (#376)
Abstract
This document specifies the RLN Module:
a registry-agnostic API that manages
RLN memberships —
registration, persistence, and lifecycle state —
and generates and verifies RLN proofs
on behalf of consuming services.
Registries are identified by
CAIP-10 account identifiers
and applications by their rln_identifier;
together they scope every call,
so one Module serves multiple memberships, registries and applications concurrently.
Motivation
RLN requires a user
to register an identity commitment in a membership set
before participating in rate-limited anonymous signalling.
At the time of writing, existing specifications assume that set
is a smart contract on an EVM-compatible blockchain —
the links below are pinned to the versions this refers to:
WAKU2-RLN-CONTRACT
specifies the contract,
MESSAGING-API configures validation
through an EVM-specific RlnConfig,
and RLN-KEYSTORE identifies a registry
by chain id and contract address.
Registries now exist, and will continue to appear,
in other execution environments.
Supporting memberships that live in different registries, acquired in different ways, means generalizing registry identification and concentrating the registry-specific work in a module that consuming services do not themselves implement. This document defines that Module, so that:
- the registry backing a deployment is selected by configuration, not by code;
- a consumer manages memberships and generates and verifies proofs through one stable interface, independent of how the Module packages its internals;
- payment and account handling remain below the Module, invisible to the consumer.
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 RFC 2119.
Terminology
| Term | Meaning |
|---|---|
| Module | The component implementing this interface. Exposes a membership-management portion and a rate-limiting portion. |
| Consumer | The component calling this interface — e.g. a relay node implementing WAKU2-RLN-RELAY, a mix node, or a light client. |
| Application | A network or protocol deployment that verifies RLN proofs against a registry, identified by an rln_identifier. |
| Registry | A membership set — a Merkle tree of rate commitments — identified by a CAIP-10 registry_id; a smart contract, an on-chain program, or any other service maintaining such a tree. Internal registry access is the Module's concern. |
registry_id | <namespace>:<reference>:<account_address>, e.g. eip155:59144:0xb9cd… or logos:testnet:<64 lowercase hex>. MUST be canonicalized before comparison or hashing. |
rln_identifier | A 32-byte per-application identifier (32/RLN-V1), mixed into the external nullifier so one membership can serve several applications. |
| Scope | The context a call operates on — a registry and an application: registry_id + rln_identifier (MembershipScope). |
| Identity commitment | The public value derived from an identity credential (RLN); the only credential-derived value that appears in the registry. |
| Rate commitment | poseidon(identity_commitment, rate_limit) — a registry tree leaf. |
| Direct registration | The Module registers a membership itself, from a funded account. |
| Delegated registration | The Module registers on a client's behalf as a participant in the RLN Membership Allocation Protocol. |
Registry identification
A registry_id is a CAIP-10
account identifier, namespace:reference:account_address,
that MUST uniquely identify a single registry instance.
A registry deployment typically spans several accounts —
the contract or program, its configuration, the tree accounts —
so each namespace binding MUST define which single account anchors the registry
(the account from which its other objects can be resolved)
and one canonical textual form for account_address, including letter case.
The registry_id is compared as an opaque string
and is an input to the membership_hash,
so implementations MUST canonicalize it before comparing or hashing,
and MUST NOT require the ability to parse the account_address
of namespaces they do not support.
The namespace binding for logos registries is given in
Appendix A.
API design
The Module
An instance of the Module serves the registries selected by its configuration;
every call names the registry and application context it operates on
through a MembershipScope.
It exposes two portions:
- Membership management — registration and membership state: the Module generates the identity credential, registers its rate commitment, persists it, and tracks the membership's lifecycle in the registry.
- Rate limiting — proof generation and verification over state the Module maintains itself: the current epoch, message-id allocation, the membership's Merkle proof path, the valid-root window, and the nullifier log for double-signalling detection.
The consumer supplies only scopes, signals, and proofs. Registry access and payment — the latter via an accounts module beneath the Module — are internal to the Module and out of scope here. Identity credentials never leave the Module: they are generated at registration, persisted encrypted, and used only inside proof generation.
Type definitions
Common types
typedef struct { const uint8_t* ptr; size_t len; } Bytes;
// The minimum set of conditions an error result MUST distinguish.
typedef enum {
RLN_ERR_NOT_READY, // Module cannot serve this yet; retry once ready
RLN_ERR_TRANSIENT, // e.g. registry/RPC failure; the caller MAY retry
RLN_ERR_BUDGET_EXHAUSTED, // the epoch's rate limit is spent; retry next epoch
RLN_ERR_PERMANENT // e.g. invalid input; retrying cannot succeed
} RlnErrorKind;
// An error: the kind the caller dispatches on,
// and a human-readable detail for diagnostics.
typedef struct {
RlnErrorKind kind;
const char* message;
} RlnError;
// Result<T> denotes a fallible return carrying either a T or an RlnError,
// never both. The notation is language-neutral: a binding maps it onto its
// native idiom — a Rust Result, a C value-plus-error struct, an exception —
// preserving the RlnErrorKind distinctions.
// Everything a call operates on: the registry and the application context.
// Every call passes its scope explicitly; the Module holds no default.
typedef struct {
const char* registry_id; // CAIP-10 account identifier, canonicalized,
// e.g. "eip155:59144:0xb9cd..."
uint8_t rln_identifier[32]; // per-application identifier, mixed into
// the external nullifier
} MembershipScope;
// Open registration options. The common key "rate_limit" carries the
// requested per-epoch rate limit as a decimal string; omitted, the
// registry applies its default. All other recognized keys are
// registry-specific, e.g. selecting delegated registration through
// an allocation service.
typedef struct { const char* key; const char* value; } RegistryOption;
typedef struct { const RegistryOption* ptr; size_t len; } RegistryOptions;
// A consistent snapshot of one scope's rate-limit budget.
typedef struct {
uint64_t epoch_index; // current epoch
uint64_t rate_limit; // messages the epoch grants the membership;
// zero when the scope has no usable membership
uint64_t remaining; // messages still unspent in this epoch
} EpochQuota;
Membership
typedef enum {
MEMBERSHIP_UNKNOWN, // no membership known for the scope
MEMBERSHIP_PENDING, // submitted, not yet confirmed by the registry
MEMBERSHIP_FAILED, // observed absent after the confirmation window
MEMBERSHIP_ACTIVE, // confirmed and within its validity period
MEMBERSHIP_GRACE_PERIOD, // still usable, but approaching expiry
MEMBERSHIP_EXPIRED, // validity period lapsed
MEMBERSHIP_ERASED_AWAITS_WITHDRAWAL, // removed; deposit still recoverable
MEMBERSHIP_ERASED // removed; nothing left to recover
} MembershipStatus;
// Membership metadata. The identity credential backing it is generated,
// persisted, and used only inside the Module; it never crosses this interface.
typedef struct {
const char* membership_hash; // stable one-way local handle,
// lowercase_hex(SHA256(registry_id || 0x00 || identity_commitment))
uint64_t rate_limit; // messages per epoch; provisional while PENDING
uint64_t leaf_index; // index of the rate commitment in the tree;
// meaningful once ACTIVE
} Membership;
typedef struct {
MembershipStatus status;
Membership membership; // meaningful unless status is UNKNOWN
} MembershipState;
A membership belongs to exactly one registry
and carries no application association:
it MAY back any application whose scope names its registry,
which is why a MembershipScope pairs the registry_id with an rln_identifier.
PENDING and FAILED are Module-local states:
accepting a submission confirms the registry received the registration,
not that it applied it.
A membership MUST NOT remain PENDING indefinitely —
the Module bounds it with a confirmation window (see register),
after which a membership observed absent is reported FAILED.
While PENDING, rate_limit and leaf_index are provisional:
the registry assigns the leaf position when the registration is applied,
so the Module MUST re-read both once the membership is ACTIVE.
A membership MAY leave the set involuntarily.
Where a registry implements slashing —
which removes the leaf and publicly reveals the identity secret
(32/RLN-V1) —
an ACTIVE membership can disappear at any time.
A registry that erases all record of a membership on removal
makes ERASED indistinguishable from never-registered in a raw read,
so the Module infers erasure from its own records.
A membership reported ERASED or UNKNOWN no longer backs proof generation:
generate_proof requires a usable membership.
The membership_hash is derived deterministically from
the canonical registry_id and the credential's identity commitment,
and from nothing else —
in particular not from provisional values such as leaf_index —
so that Modules sharing a storage backend agree on it.
The construction is given in
Appendix B.
Rate-limit proof
typedef struct {
uint8_t proof[128]; // zero-knowledge proof, compressed encoding
uint8_t root[32]; // root the proof was generated against
uint8_t epoch[32]; // epoch the proof was generated for,
// little-endian field element
uint8_t external_nullifier[32]; // poseidon(hash_to_field_le(epoch),
// hash_to_field_le(rln_identifier))
uint8_t share_x[32]; // hash_to_field_le(signal) —
// the share's evaluation point
uint8_t share_y[32]; // Shamir share of the identity secret,
// evaluated at share_x
uint8_t nullifier[32]; // deterministic fingerprint of (identity
// secret, external_nullifier, message_id)
} RateLimitProof;
typedef enum {
PROOF_VALID, // all verification conditions hold
PROOF_INVALID, // a verification condition fails
PROOF_DUPLICATE, // a proof for a signal already verified
PROOF_RATE_LIMIT_VIOLATION // nullifier reuse with a different signal
} ProofVerdict;
typedef struct {
ProofVerdict verdict;
uint8_t recovered_secret[32]; // the double-signaller's identity secret,
// reconstructed from the colliding shares;
// set only on PROOF_RATE_LIMIT_VIOLATION
} VerificationResult;
A RateLimitProof is opaque to the consumer,
which passes it from generate_proof to
verify_proof unchanged;
its fields follow RLN.
Two proofs that share an external nullifier and message id
expose share_x/share_y pairs that reconstruct the identity secret —
the mechanism the rate limit rests on.
verify_proof performs that reconstruction when it detects the collision
and reports the secret in its result.
The recovered_secret is the violator's,
revealed by their own double-signalling —
not a credential of the Module's,
which never crosses this interface.
Required functions
The Module SHALL expose the functions in this section.
Every function returns a Result:
its declared value on success, an RlnError otherwise.
A function called before the Module can serve it
SHALL fail with RLN_ERR_NOT_READY
rather than be served from a cold registry view.
Lifecycle
start()
Start the Module with its configuration,
which selects the registries the instance serves.
Starting establishes the registry connections,
loads persisted memberships (see Persistence),
and starts the tasks that maintain the Module's local registry view:
the valid-root window, each membership's Merkle proof path, and each membership's state.
The Module does not require a membership to start:
a Module started without one serves verify_proof
from its registry view alone.
stop()
Stop the Module and all its maintenance tasks. In-flight requests SHALL be cancelled cleanly.
Registration
Result<MembershipState> register(MembershipScope scope, RegistryOptions options)
Generate a new identity credential inside the Module,
attempt to register a membership for it,
and persist the credential and membership (see Persistence).
The membership's rate limit is requested through the common option key
rate_limit:
absent, the registry, or the allocation service under delegated registration,
applies its default;
present, it is a request, not a guarantee:
a registry MAY grant a different value,
which the membership reports once ACTIVE
(its rate_limit is provisional while PENDING).
A requested rate_limit outside the registry's accepted bounds
SHALL fail as RLN_ERR_PERMANENT.
Only the rate commitment derived from the credential is submitted to the registry;
the credential itself never leaves the Module.
The remaining options keys carry registry-specific registration choices —
for example, selecting delegated registration through the
RLN Membership Allocation Protocol
rather than direct registration from a funded account.
register is idempotent for a scope:
if the scope already has a membership that is PENDING, ACTIVE,
or in its GRACE_PERIOD,
the function SHALL return that membership
rather than generate a second credential or double-register,
and its rate_limit MAY differ from any requested value.
A membership in a terminal state — FAILED, EXPIRED,
ERASED_AWAITS_WITHDRAWAL, or ERASED —
does not block registration:
the function SHALL register a fresh membership for the scope,
and a prior recoverable deposit remains claimable through
withdrawal.
Holding more than one live membership for a scope is an
optional extension.
Registration is not instantaneous — on some registries confirmation takes minutes —
so the function SHALL return once the registration is submitted and durably persisted,
with the membership PENDING
and its rate_limit and leaf_index provisional.
Confirmation is observed through get_membership_state,
which transitions to ACTIVE once the registration is confirmed in the registry.
The transition to FAILED SHALL be based on a successful registry read
observing the membership absent after the confirmation window;
inability to reach the registry is not such an observation,
and the membership SHALL remain PENDING while the registry cannot be read.
A failed submission SHALL be reported as RLN_ERR_TRANSIENT or RLN_ERR_PERMANENT
according to whether retrying can succeed.
Result<MembershipState> get_membership_state(MembershipScope scope)
Return the status and metadata of the scope's membership,
whether registered in this run or loaded from persistence at start().
The reported status is the registry's view overlaid on the Module's local records:
a submission the registry does not yet know about
is reported PENDING or FAILED rather than UNKNOWN,
and — symmetrically — a membership the Module has previously observed in the set
that the registry no longer reports is ERASED rather than UNKNOWN.
UNKNOWN is returned only when no membership exists for the scope,
in this run or in persistence.
Persistence
The Module SHALL persist each membership it registers, so that a membership registered before a restart is available after it without registering again. The store SHALL satisfy:
- identity credentials are encrypted at rest;
- when the encryption key is password-derived, the derivation uses a function suitable for password hashing (e.g. PBKDF2, RFC 2898);
- tampering with a stored credential is detectable before it is used;
- plaintext identity secrets are never exposed outside the Module.
The storage medium and encoding are the Module's concern. A keystore format portable across implementations is an optional extension; identity secrets held in memory are covered by the security considerations.
Rate limiting
The rate-limiting portion is the proof functions and a quota read. All RLN state they need — the current epoch, message-id allocation within the rate limit, the membership's Merkle proof path, the valid-root window, and the nullifier log for double-signalling detection — is maintained inside the Module; the consumer supplies only the scope and the signal. A membership is required only to generate proofs: verification runs against the registry view alone, so a consumer that only validates messages never registers. Detecting double-signalling across messages — two proofs sharing a nullifier within one epoch — is the Module's responsibility: it keeps a log of the nullifiers it has verified, with their shares, recovers the identity secret two colliding proofs reveal, and reports it in the verification result.
Result<EpochQuota> get_epoch_quota(MembershipScope scope)
Return the scope's current epoch index,
the membership's rate_limit for it,
and the budget still unspent in it,
for consumer-side send scheduling:
rolling a metering window on the epoch boundary,
parking traffic when the budget is spent,
and releasing it when the epoch advances.
All fields SHALL derive from one observation of the epoch:
a remaining computed in one epoch MUST NOT be paired
with the index of another,
so a read taken across an epoch rollover reflects
either the old epoch or the new one, never a mixture.
The read is advisory:
allocation happens in generate_proof,
which remains the authority and fails with RLN_ERR_BUDGET_EXHAUSTED
when the budget is spent between a read and a proof.
A scope without a usable membership — ACTIVE or GRACE_PERIOD — has no budget:
the read SHALL return the current epoch_index
with rate_limit and remaining both zero.
A rate_limit of zero therefore indicates the absence of a usable membership,
never an exhausted budget,
and the consumer resolves which through
get_membership_state.
Result<RateLimitProof> generate_proof(MembershipScope scope, Bytes signal)
Generate an RLN proof that signal was produced by the holder of the scope's membership
within its rate limit for the current epoch.
The Module determines the epoch,
allocates the next unused message_id within the membership's rate_limit,
and binds the proof to the external nullifier
poseidon(hash_to_field_le(epoch), hash_to_field_le(rln_identifier)).
The Module SHALL NOT issue two proofs for the same (epoch, message_id) pair:
doing so reveals the identity secret.
When the epoch's budget is exhausted,
the function SHALL fail with RLN_ERR_BUDGET_EXHAUSTED;
allocation resets at the next epoch.
The membership MUST be usable — ACTIVE or GRACE_PERIOD —
for proof generation to succeed.
Result<VerificationResult> verify_proof(MembershipScope scope, Bytes signal, RateLimitProof proof)
Verify an RLN proof for signal.
The following MUST hold for the verdict to be PROOF_VALID:
- the proof is valid;
proof.rootis within the Module's current valid-root window;proof.epochis within a configured maximum gap of the Module's current epoch, so a newly registered member cannot publish into past epochs;proof.external_nullifiermatches the value recomputed fromproof.epochand the scope'srln_identifier;proof.share_xmatcheshash_to_field_le(signal), recomputed by the Module from the suppliedsignal, so the proof is bound to this signal and cannot be replayed onto another message.
A proof that fails any of these is PROOF_INVALID —
a verdict, not an error:
the error channel is reserved for calls the Module cannot judge,
e.g. RLN_ERR_NOT_READY before its registry view is warm.
A proof that passes them
but whose nullifier is already in the epoch's log
is judged by its share_x against the recorded one:
the same value is a retransmission of a message already verified,
reported PROOF_DUPLICATE;
a different value is double-signalling,
and the Module SHALL reconstruct the identity secret from the two shares
and report PROOF_RATE_LIMIT_VIOLATION with recovered_secret set.
The nullifier log is verification state local to the Module,
retained per epoch for at least the maximum epoch gap
within which proofs are accepted.
Verification is on the message hot path —
it runs for every message a validator receives —
so the Module SHALL serve it from its locally maintained registry state
and SHALL NOT perform registry access on the verification path.
The valid-root window is maintained asynchronously as the registry changes,
and SHOULD be maintained timely enough
that a proof generated against a newly published root is not falsely rejected.
The epoch size, the window's length, and the maximum epoch gap
are configuration parameters of the Module —
the registry does not enforce them;
validators of an application MUST use the same values,
or a proof accepted at one node is rejected at another.
Optional extensions
A Module MAY additionally provide any of the following;
the consumer MUST NOT require them,
and a Module SHALL fail a call to an extension it does not provide
with RLN_ERR_PERMANENT.
- Multiple memberships —
holding more than one membership for a scope.
A Module that does so MUST require the consumer to select one explicitly —
for example by
membership_hash— and MUST NOT choose silently among candidates. - Credential export — exporting a persisted membership (see Persistence) per RLN-KEYSTORE, making credential files portable across implementations. Export is the only operation through which one of the Module's own credentials crosses this interface; a consumer that invokes it takes custody of the identity secrets.
- Slot reclamation — returning a message-id allocation to the epoch's budget when a proof was generated but its message was never published.
- Proof staleness check —
a lightweight check that a held proof would still pass
verify_proof— its root still current, its epoch within tolerance — without performing full verification, so a consumer retrying a long-parked message can refresh its proof before resending rather than after a rejection. - Registry parameters read — exposing the registry-declared parameters that bound registration, e.g. the accepted rate-limit range or an assumed epoch length; a Module offering this read SHOULD reject a configured epoch size that contradicts a declared one.
- Membership state subscriptions —
change notifications for the membership lifecycle,
sparing the consumer polling
get_membership_state. - Withdrawal —
erasing a membership and recovering its deposit, where the registry supports it —
the operation that resolves the
ERASED_AWAITS_WITHDRAWALstate.
Security and privacy considerations
Message-id allocation and shared credentials
The rate limit rests on a single secret:
two proofs that reuse an (epoch, message_id) pair under one external nullifier
expose Shamir shares that reconstruct the identity secret,
which on registries implementing slashing burns the credential
(see Rate-limit proof).
The Module owns message_id allocation for its memberships,
so a single Module never reuses a pair.
This safety holds only within one Module:
a credential shared across Module instances —
by exporting it (see Credential export)
and importing it elsewhere —
splits allocation across uncoordinated allocators.
A consumer that shares a credential this way
MUST coordinate message_id allocation across the holders,
the RECOMMENDED mechanism being static partitioning of the
[0, rate_limit) range;
otherwise a credential MUST be used by one Module at a time.
Credential reuse across applications
One membership serving several applications is safe
only because each application's rln_identifier
is mixed into the external nullifier.
An application's rln_identifier MUST be unique to it
and MUST NOT be copied from another application;
a collision lets proofs from one application
consume another's rate budget and correlate the shared membership.
Registry trust
The Module trusts its registry access for roots and proofs; a compromised registry connection can equivocate about membership state. A consumer with stronger trust requirements SHOULD arrange for the Module to read the registry directly, e.g. by running a node for the host network. Reading a registry through a third-party RPC provider MAY link the consumer's network identity to its membership.
Appendix A: The logos namespace binding
The namespace binding required by Registry identification for registries hosted on Logos execution environments:
- Namespace:
logos. - Reference: the network name, pinned to lowercase (e.g.
testnet,local). References are compared opaquely, so without pinned casinglogos:Testnet:…andlogos:testnet:…would identify distinct registries with distinctmembership_hashes, silently fragmenting stored memberships. - Anchor account: the registration program's configuration account — the account from which every other object of the registry (tree account, subtree accounts, membership accounts, treasury) is derived.
- Canonical
account_addressform: 64 lowercase hexadecimal characters, without prefix. RegistryOptions:funding_holding_account_id— the token holding account payingrate_limit × price_per_unitat registration.- Time base: membership lifecycle state is derived from the on-chain clock account, which reports Unix-epoch milliseconds; lifecycle durations (active duration, grace period) use the same unit.
Appendix B: Membership hash construction
membership_hash = lowercase_hex(
SHA256(utf8(registry_id) || 0x00 || identity_commitment))
registry_id is in canonical form,
0x00 is a single separator byte
(CAIP-10 identifiers cannot contain it),
and identity_commitment is its 32-byte little-endian representation.
Copyright
Copyright and related rights waived via CC0.