Protocol
Notes and state

Preventing double spends

The two checks that stop a nullifier being accepted twice, a freshness proof against the nullifier tree and a staging buffer for nullifiers not yet in it, and the watermark that keeps the gap between them closed.

A note is spent at most once because its nullifier is accepted at most once. Two checks enforce that together: a freshness proof shows the nullifiers are not in the nullifier tree, and a staging buffer, which the code calls the turnstile, catches nullifiers that have been accepted but not yet inserted into the tree.

The chart below follows one value transaction through both checks, from the moment it reaches the shielded pool to the moment its nullifiers are staged.

sequenceDiagram
participant S as Submitter
participant P as Shielded pool
participant B as Staging buffer
S->>P: nullifiers, freshness proof, root number
P->>B: look up root by number
B-->>P: root, if still active
P->>P: verify freshness proof
P->>B: stage the three nullifiers
B->>B: refuse any already staged
B-->>P: staged
Note over P,B: later, the forester inserts them into the tree

The submitter is the relayer for a relayed transaction, or your own device for a deposit it submits itself. The first three messages are layer one: the pool resolves the root the freshness proof names and verifies the proof against it, which shows the nullifiers are not in the tree. The last three are layer two: the staging buffer refuses any nullifier it already holds, which catches the ones accepted since that root was published. The sections below explain each layer.

Layer one: the freshness proof

The nullifier tree is an indexed Merkle tree of every nullifier inserted so far, kept as a sorted linked list. A value is absent if the tree holds a leaf whose value is below it and whose next value is above it, or which has no next value.

Every value transaction carries a second Groth16 proof, separate from the transaction proof. Its circuit takes the three nullifiers and a nullifier-tree root, and proves for each nullifier that such a leaf exists under that root. Its one public input is H_on(nullifier 1, nullifier 2, nullifier 3, root), which the program recomputes from the payload and the root it looked up. The proof uses only public values, so whoever submits the transaction makes it: the relayer for a relayed transaction, your device for a deposit it submits itself. It sits in the payload after the attesters' combined signature, so it is not covered by the context digest they sign; it binds its own root instead.

The transaction names the root by its sequence number. The program accepts it only if the root is still active: among the 512 most recent roots of the tree and at or above the watermark described below. Otherwise the transaction fails with StaleRoot, and whoever submits it proves again against a current root. A proof that does not verify makes the combined check of both proofs and the attester signature fail with error 75, and a malformed proof fails with error 59; both are listed in the reference section at the bottom of this page.

An escape hatch makes the same non-membership argument inside its own circuit, for its one nullifier, and names the root by its sequence number in the same way, with the same active-root rule. The staging rules below apply to it unchanged.

Layer two: the staging buffer

A freshness proof is made against a root, and the tree only changes when the forester inserts a batch. Every nullifier accepted since that root was published is missing from it, and the proof alone would let such a nullifier through a second time. The staging buffer closes that gap. Every accepted nullifier is staged in it, and staging refuses any nullifier already there.

Each pool index has one nullifier staging buffer: an account of 2,464,893 bytes. It holds two buffers, each able to take 30,000 keys, and each made of two parts:

  • a Bloom filter of 2²¹ bits with 16 hash positions per key (the registration and fee staging buffers use 30), which answers "definitely not present" for almost every new key without a search. The positions come from a fast mix of all 32 bytes of the key rather than a hash the chain charges for, because they decide only where to look: a key already staged always sets and finds the same bits;
  • an exact array of the keys themselves, searched whenever the filter says "maybe". The filter only speeds the check up, and a false positive costs a search, never a wrong refusal.

The array keeps the keys in the order they were staged, and each run of 12 is one batch for the forester. When a batch commits, the program folds its 12 stored keys into a Poseidon hash chain, and the forester's batch proof must reproduce that chain, so the forester can insert exactly the staged keys, in the order they were staged, and nothing else. Staging a key adds nothing to a chain and computes no Poseidon hash: its filter positions come from the mix above, and the chain waits for the commit. The account also carries the tree's current root, its next free index and the ring of the last 512 roots; the tree's leaves are kept off chain.

Staging a key checks, in order, that:

  1. the key is a canonical field element, below the BN254 modulus;
  2. the root the freshness proof used is still active;
  3. the tree has a place for the key: the position it will take, after every key already staged, comes before the end of the last batch of 12 the tree can still fit, or staging fails with TreeFull. A key past that point could never be inserted, and its buffer could never be cleared;
  4. the key is not in either buffer, or staging fails with DuplicateKey;
  5. the filling buffer has room. When it is full, staging moves to the other buffer if that one is empty, or fully inserted and so retired on the spot; otherwise it fails with StagingBlocked.

A value transaction stages its three nullifiers only after every other check has passed, in the same instruction, so either all three are staged and the transaction succeeds, or nothing is staged. One of those checks keeps a place in the tree for the pool index's notes: a value transaction spending from it is refused with error 88 well before the third check above could refuse it, so that escapes, which that limit never refuses, find room. The note tree explains it.

The buffer lifecycle

Each of the two buffers moves through three states, and back to the first, as the chart below shows. Each transition is labelled with what causes it.

stateDiagram-v2
[*] --> Filling
Filling --> Full: 30,000th key staged
Full --> Inserted: forester commits the 2,500th batch of 12
Inserted --> Filling: retired after 512 more roots, or when staging needs it

A buffer fills until it holds 30,000 keys and then becomes full; staging moves on to the other buffer. The forester can only insert keys from a full buffer, twelve per proof, so a full buffer takes 2,500 batch proofs to insert. When the last one lands the buffer is marked inserted, and its keys are now all in the tree.

An inserted buffer is not cleared at once. It is retired, and its keys and filter wiped, once 512 further roots have been published, or sooner, the moment staging needs the buffer back because the other one has filled.

The watermark

Retiring a buffer raises the watermark to the sequence number of the root published by that buffer's last batch. From then on, only roots at or above the watermark are active. This is the step that keeps the two layers consistent.

While a buffer holds keys, a nullifier in it is caught by staging, whatever root the freshness proof used. Once the buffer is wiped, the only record of those keys is the tree, and a proof against a root from before their insertion would find them absent. The watermark retires exactly those roots: every root still active contains every key of every wiped buffer. The 512-root grace period gives proofs made just before the last batch time to land before their root is retired, and because it is as long as the ring, a retirement that waits it out refuses no root the ring still holds; when staging forces an early retirement, those proofs fail with StaleRoot and are made again.

Beyond the protocol: the screener

The two layers above are the protocol's own defence. The screener adds one outside it. It is one of the six attesters whose combined signature every value transaction needs, and before it signs, it looks up each of the transaction's input nullifiers in the indexer's mirror of the nullifier tree and staging buffers, alongside its screening. If any of them is already there, as a tree leaf or a buffered key, or two input slots carry the same nullifier, it refuses to sign. If it cannot read the mirror, it refuses as well, rather than sign unchecked.

This check does not share code with the two layers. It reads what has landed on chain, not what the program or a circuit concluded, so if a bug in the freshness proof, the staging buffer or the watermark ever let a spent nullifier through, a value transaction reusing it would still go without the screener's signature, and the pool accepts no value transaction without the combined signature. What it cannot see is two transactions spending the same nullifier at the same moment, before either has landed; those are left to the protocol's own checks.

The escape hatch waits 48 hours

The escape hatch is the one way to spend a note out of the pool without the attesters, so the screener never sees it. Its payout is held instead. escape_hatch spends the note at once, but the tokens go into an escrow for that escape, and release_escape_escrow pays them to the recipient only after 864,000 slots: 48 hours at the 200 milliseconds per slot the protocol counts with, and longer in practice, since Solana's slots usually take more than that.

A double spend through the escape hatch would sit in its escrow, public on chain, for that whole window, which gives Umbra's incident response team time to see it and investigate. The program has no switch that stops a held escrow: once the slots have passed, anyone can release it. Acting on what the team finds before then would take a program upgrade; see governance and upgrades.

Registration and fee collection

The same structure guards two other one-time actions, with the same buffer sizes. The fee staging buffer keeps the nullifier buffer's ring of 512 roots and its 512-root grace period; the registration staging buffer keeps a ring of 120 roots and a 64-root grace period:

  • the registration staging buffer stages each newly registered identity commitment, in front of the registration tree, so an identity cannot register twice;
  • the fee staging buffer stages the nullifiers of collected fee notes, in front of the fee-nullifier tree, so a fee note cannot be collected twice.

Registration and fee collection prove non-membership inside their own circuits rather than with a separate freshness proof.

What is verified

The staging buffer and its Bloom-filtered key set are covered by Kani harnesses: among them, that a key cannot be staged twice within a buffer or across the two, that a root below the watermark is never active, that the watermark only rises, that a retired root is refused, that a commit with the wrong root or the wrong hash chain is refused, and that a non-canonical key is refused without writing anything. The harnesses run on a scaled-down instance, with two keys per buffer, a four-root ring and a stand-in hash, so they check the state machine's logic rather than the production sizes. A Lean model of a buffer as its list of stored keys proves that folding a batch's stored keys at commit gives the same chain as folding each key as it is staged. See formal verification.

Reference

Last updated

On this page