Skip to main content

How the Privacy Pool Works

The pool is a chain of ordinary transactions. Each one spends the current pool outputs, creates the next ones and carries a proof that the private ledger moved from the old state to the new state by the rules. This page describes the pieces in the order a transaction uses them.

Pool instance​

A pool instance is created once. A UNIQUE asset NAME#POOL is issued first. A birth transaction then consumes it and creates the first state output, which holds the digest of an empty pool.

ValueMeaning
Domain32 bytes that separate this instance from every other one: SHA-256 of the genesis hash, the issuance outpoint of the UNIQUE asset and its name. Every note hash includes it
Asset ID32 bytes for the pooled asset. Native XNA in the current profiles
ContextPoseidon hash of domain, asset ID, unit and registry root. The first public input of every proof
CommitmentRoot of the contract's script tree, with one leaf per operation form
Reserve commitmentHash of the guard script that locks the reserve

The domain comes from the issuance outpoint, which is known before the contract is built. This avoids a circular dependency between the verification keys, the contract commitment and the instance identity. The UNIQUE asset is only the identity of the state chain: it is not the reserve, a password or a key that can bypass the contract.

The manifest is a public JSON file that pins all of this, together with the genesis hash, the birth transaction and height, the leaf scripts, the control blocks and the verification keys. Wallets validate it before reading the chain:

  • every leaf with its control block must hash to the commitment;
  • every verification key must hash to the value embedded in its leaf script;
  • the guard script must hash to the reserve commitment;
  • the domain and the context are recomputed from the genesis and the issuance outpoint;
  • the commitment must equal a value pinned independently of the manifest.

These checks prove that the manifest is consistent. They do not prove that the scripts implement safe custody. That is why the commitment is pinned from a reviewed deployment and never taken from an RPC response.

The state and the reserve​

Every pool transaction creates two pool outputs.

OutputValueScriptPurpose
0 · State0OP_1 <commitment> plus one unit of NAME#POOL with the 32-byte state digest attachedSpendable only through a contract leaf, so every state change needs a valid proof
1 · ReserveAll XNA in the poolOP_1 <reserve commitment>The guard lets it move only together with the state output as input 0. Its value is public

After the last note is withdrawn (W_full) there is no reserve output, and the next deposit (D0) creates it again. The trees are kept, so the same instance can be refilled.

output 0 · UNIQUE NAME#POOLthe asset message carries the 32-byte state digest SS = PoseidonBytes(opening)state opening · 109 bytesnote tree root32 bytesnullifier tree root32 bytesseen tree root32 bytescounts3 × 4 bytesmode1 byteNote treeappend-only, 32 levelsevery note commitment, in orderproves the spent note existsNullifier treeindexed sorted set, 32 levelsevery published nullifierproves a nullifier is newSeen treeindexed sorted set, 32 levelsevery created commitmentproves a new note is uniqueCounts, mode3 × u32, one bytemode 0: pool is emptymode 1: holds notes

The state digest commits to three Merkle trees, each 32 levels deep and hashed with Poseidon over the BN254 scalar field.

TreeContentWhy
Note treeCommitments of all notes ever created, in orderProves that a spent note exists
Nullifier treeIndexed, sorted set of all published nullifiersProves that a nullifier is new, which prevents double spends
Seen treeIndexed, sorted set of all commitmentsProves that a new commitment is unique, which prevents duplicate credit

Each proof takes the old and new digests, S_old and S_new, as public inputs. The chain therefore moves from one state to the next only through proven transitions.

Notes, commitments and nullifiers​

note · 169 bytesversion1 B0x01domain32 Bpool instanceasset ID32 Bpool instanceowner32 Brecipientview_pub32 Brecipientamount8 Bsatoshisrho32 BrandomCommitment · when the note is createdcm = PoseidonBytes(tag ‖ note)appended to the note tree and the seen treepublished with the encrypted note recordNullifier · when the note is spentnf = PoseidonBytes(tag ‖ domain ‖ nk ‖ rho ‖ cm)needs nk, which only the owner can derivepublished once; a repeated nullifier is rejected

A note records the pool domain, the asset ID, an owner, the recipient's viewing public key, an amount in satoshis and a random rho. Two secrets of the owner matter:

  • the spend secret, from which the public owner value and the private nullifier key nk are derived;
  • the view seed, from which an X25519 viewing key pair is derived.
owner = PoseidonBytes("NIP043/owner/CP1" || domain || spendSecret)
nk = PoseidonBytes("NIP043/nk/CP1" || domain || spendSecret)
cm = PoseidonBytes("NIP043/cm/CP1" || note)
nf = PoseidonBytes("NIP043/nf/CP1" || domain || nk || rho || cm)

Creating a note appends cm to the note and seen trees. Spending it publishes nf. Only the owner knows nk, so nobody else can compute the nullifier or link it to its commitment. The proof shows that the spent note is in the note tree, that the spender knows its spend secret and that nf is new.

The sender knows the plaintext of the notes it creates, but not the recipient's spend secret, so it cannot spend them.

Encrypted note records​

The sender encrypts every new note to the recipient's viewing key with HPKE (RFC 9180: X25519, HKDF-SHA256, ChaCha20-Poly1305) and publishes the record next to its commitment. The associated data binds the record to the pool domain and to cm.

The recipient finds its notes by trial decryption. Its wallet tries every published record with its viewing keys and keeps the ones that decrypt to a note whose commitment and owner match. The chain carries everything needed to recover a note.

note

The proof binds the published bytes, but the circuit does not prove that each ciphertext decrypts to the committed note. A sender could publish an unreadable record, and the intended recipient would not find that note. Wallets validate every decrypted note against its commitment before crediting it, and an unreadable record never blocks the recovery of other notes.

Operation forms​

Each pool transaction uses exactly one form. Every form has its own circuit, verification key and contract leaf.

FormOperationNotes inNotes outReserve
D0First deposit into an empty pool010 → amount
D1Deposit into a pool that holds notes01Grows by the amount
T1Assign a whole note to one recipient11Unchanged
T2, T3, T4Assign a note to two, three or four notes, change included12 to 4Unchanged
W_partialWithdraw one whole note while other notes remain10Shrinks by the note amount
W_fullWithdraw the last note and empty the pool10→ 0, no reserve output

Rules that follow from the forms:

  • One note in per transaction. A payment cannot be larger than the largest single note. A balance of 900 XNA spread over several notes is not one 900 XNA note.
  • Assignments conserve value. The new notes add up exactly to the consumed one. The fee never comes out of a note.
  • Withdrawals take a whole note. W_partial means that the pool keeps a reserve, not that part of the note is withdrawn. To withdraw 40 XNA from a 100 XNA note, first assign it to yourself as 40 and 60 XNA notes with T2, then withdraw the 40 XNA note. That takes two transactions and two fees.
  • Deposits need an exact coin. The deposited amount comes from one transparent coin of exactly that value. A wallet that has no such coin creates it first with an ordinary payment to itself.

Example: from one 950 XNA note, paying 400, 300 and 200 XNA to three recipients with 50 XNA change is a T4. Four recipients plus change do not fit in one assignment.

Transaction layout​

inputswitness of input 0outputs0 · stateevery formUNIQUE NAME#POOL carrying S_old1 · reservenot in D0all XNA held by the poolfundingD0, D1a coin of exactly the deposit amountsponsorevery formpays the fee, signed after proving0 · stateevery formUNIQUE NAME#POOL carrying S_new1 · reservenot in W_fullthe new reserve totalpayoutW_partial, W_fullthe withdrawn note, transparentsponsor changeevery formsame script, sponsor value − feeleaf of the formproof, 128 bytesverification key of the formpublication, 4096 bytesor the nullifier (withdrawals)prevouts, leaf, control blockthe leaf checksanchor = Poseidon(TXHASH)OP_ZKVERIFY over public inputs
FormInputs, in orderOutputs, in order
D0state, funding, sponsorstate, reserve, sponsor change
D1state, reserve, funding, sponsorstate, reserve, sponsor change
T1 to T4state, reserve, sponsorstate, reserve (unchanged), sponsor change
W_partialstate, reserve, sponsorstate, reserve, payout, sponsor change
W_fullstate, reserve, sponsorstate, payout, sponsor change

Extra inputs or outputs are not allowed. The sponsor is a separate transparent coin that pays the miner fee. Its change must return to the same script and cannot exceed its value, so the pool outputs cannot hide a fee withdrawal.

The witness of input 0 holds the compressed proof (128 bytes), the verification key, either the 4096-byte publication (deposits and assignments) or the nullifier (withdrawals), the serialized prevouts, the leaf script and its control block. The publication carries the new commitments, their encrypted records and, for assignments, the nullifier. Its Poseidon hash data_hash is a public input, so nobody can swap the records after proving. The reserve input reveals the guard script.

The current wallet caps the fee at 1 XNA. The reserve, the payout and the sponsor change must be above the dust threshold of their script: at the default dust relay fee, 546 satoshis for Legacy P2PKH, 3060 for strict PQ and 336 for strict ECDSA. These are policy choices of the node and the wallet, not properties of the proof system.

Binding the proof to the transaction​

A proof must not be reusable in another transaction. The wallet computes the OP_TXHASH digest of the transaction with mask 0x011f, which covers version, locktime, prevouts, sequences, outputs and the empty reference input list. Its Poseidon hash is the anchor, a public input of every proof. The leaf recomputes the same value on chain.

TXHASH does not cover signatures. The wallet can therefore sign the funding and sponsor inputs after proving without invalidating the proof. Changing any input or output, including the fee, requires a new proof.

FormPublic inputs, in order
D0, D1ctx, S_old, S_new, dep, wdr, req, data_hash, anchor, amount
T1 to T4ctx, S_old, S_new, nf, data_hash, anchor, cm_1 … cm_n
W_partial, W_fullctx, S_old, S_new, nf, anchor, amount, reserve_in, reserve_out

The circuits check note ownership and membership, nullifier derivation and freshness, uniqueness of new commitments, the state evolution, amount ranges, conservation and the instance context. Transaction introspection and the publication hash happen in Script, and their results enter the proof as public inputs.

Contention and finality​

There is one state output and every pool transaction spends it, so only one pool transaction can confirm on a given state.

  • Two wallets that prepare against the same state race. The loser's transaction becomes invalid, and its owner rescans and proves again against the new state. A D0 may have to become a D1.
  • Pool transactions are not chained in the mempool. Wallets follow confirmed state only and wait for a confirmation before preparing the next operation.
  • A reorganization can undo confirmed pool transactions. Wallets detect it and rebuild their view of the pool.
  • No privileged publisher is required, and no fair ordering is guaranteed. Creating up to four notes in one assignment reduces the number of transitions, but spends are never parallel.