RLN-API

FieldValue
NameRLN Module API
Slug245
Statusraw
CategoryStandards Track
Tagsrln, membership, spam-protection, api
EditorTanya Stubbs [email protected], Arseniy Klempner [email protected]

Timeline

  • 2026-07-310ba596c — 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

TermMeaning
ModuleThe component implementing this interface. Exposes a membership-management portion and a rate-limiting portion.
ConsumerThe component calling this interface — e.g. a relay node implementing WAKU2-RLN-RELAY, a mix node, or a light client.
ApplicationA network or protocol deployment that verifies RLN proofs against a registry, identified by an rln_identifier.
RegistryA 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_identifierA 32-byte per-application identifier (32/RLN-V1), mixed into the external nullifier so one membership can serve several applications.
ScopeThe context a call operates on — a registry and an application: registry_id + rln_identifier (MembershipScope).
Identity commitmentThe public value derived from an identity credential (RLN); the only credential-derived value that appears in the registry.
Rate commitmentposeidon(identity_commitment, rate_limit) — a registry tree leaf.
Direct registrationThe Module registers a membership itself, from a funded account.
Delegated registrationThe 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.root is within the Module's current valid-root window;
  • proof.epoch is within a configured maximum gap of the Module's current epoch, so a newly registered member cannot publish into past epochs;
  • proof.external_nullifier matches the value recomputed from proof.epoch and the scope's rln_identifier;
  • proof.share_x matches hash_to_field_le(signal), recomputed by the Module from the supplied signal, 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_WITHDRAWAL state.

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 casing logos:Testnet:… and logos:testnet:… would identify distinct registries with distinct membership_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_address form: 64 lowercase hexadecimal characters, without prefix.
  • RegistryOptions: funding_holding_account_id — the token holding account paying rate_limit × price_per_unit at 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 and related rights waived via CC0.

References