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.
| Value | Meaning |
|---|---|
| Domain | 32 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 ID | 32 bytes for the pooled asset. Native XNA in the current profiles |
| Context | Poseidon hash of domain, asset ID, unit and registry root. The first public input of every proof |
| Commitment | Root of the contract's script tree, with one leaf per operation form |
| Reserve commitment | Hash 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.
| Output | Value | Script | Purpose |
|---|---|---|---|
| 0 · State | 0 | OP_1 <commitment> plus one unit of NAME#POOL with the 32-byte state digest attached | Spendable only through a contract leaf, so every state change needs a valid proof |
| 1 · Reserve | All XNA in the pool | OP_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.
The state digest commits to three Merkle trees, each 32 levels deep and hashed with Poseidon over the BN254 scalar field.
| Tree | Content | Why |
|---|---|---|
| Note tree | Commitments of all notes ever created, in order | Proves that a spent note exists |
| Nullifier tree | Indexed, sorted set of all published nullifiers | Proves that a nullifier is new, which prevents double spends |
| Seen tree | Indexed, sorted set of all commitments | Proves 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
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
ownervalue and the private nullifier keynkare 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.
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.
| Form | Operation | Notes in | Notes out | Reserve |
|---|---|---|---|---|
D0 | First deposit into an empty pool | 0 | 1 | 0 → amount |
D1 | Deposit into a pool that holds notes | 0 | 1 | Grows by the amount |
T1 | Assign a whole note to one recipient | 1 | 1 | Unchanged |
T2, T3, T4 | Assign a note to two, three or four notes, change included | 1 | 2 to 4 | Unchanged |
W_partial | Withdraw one whole note while other notes remain | 1 | 0 | Shrinks by the note amount |
W_full | Withdraw the last note and empty the pool | 1 | 0 | → 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_partialmeans 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 withT2, 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
| Form | Inputs, in order | Outputs, in order |
|---|---|---|
D0 | state, funding, sponsor | state, reserve, sponsor change |
D1 | state, reserve, funding, sponsor | state, reserve, sponsor change |
T1 to T4 | state, reserve, sponsor | state, reserve (unchanged), sponsor change |
W_partial | state, reserve, sponsor | state, reserve, payout, sponsor change |
W_full | state, reserve, sponsor | state, 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.
| Form | Public inputs, in order |
|---|---|
D0, D1 | ctx, S_old, S_new, dep, wdr, req, data_hash, anchor, amount |
T1 to T4 | ctx, S_old, S_new, nf, data_hash, anchor, cm_1 … cm_n |
W_partial, W_full | ctx, 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
D0may have to become aD1. - 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.