What the pool does, what it actually hides, and who you are trusting.
The model
A shielded pool holds value as notes rather than accounts. A note is
(asset_id, amount, owner_pk, blinding). Only its commitment ever appears
on-chain, appended to a Merkle tree that never removes anything.
Spending a note reveals a nullifier derived from the note and the spender's key — enough to prove "this note is now spent" without saying which leaf it was. A Groth16 proof establishes the rest: that the spender knows a note in the tree, that the nullifier is the right one for it, and that value is conserved per asset.
Four operations move value:
| Public | Private | Proof | |
|---|---|---|---|
| shield | amount, depositor | recipient | none needed |
| transfer | the fee, and whoever submits it | everything else | 2-in / 2-out join-split |
| unshield | amount, recipient | sender, source note | 1-in / 1-out |
| claim | the owning address | source note, amount, recipients | 1-in / 2-out |
Three more build on them — a payment that returns to its sender if nobody
claims it (redeem, reclaim), and a proof that a note holds at least some
amount (attest). They are described under
Returnable notes and
Saying something without opening anything.
Why shield needs no proof
The commitment is built in two levels:
secret = Poseidon2(owner_pk, blinding)
commitment = Poseidon3(asset_id, amount, secret)
A depositor supplies only secret, which hides both owner and blinding. The
program computes the commitment itself from secret plus the public
amount it is debiting in the same instruction. So the note's value is bound to funds actually
deposited, with nothing to prove.
A flat Poseidon(asset, amount, pk, blinding) could not be recomputed
on-chain — pk and blinding are secret — which would let a deposit of 1
mint a note worth 1000. The two-level structure is what makes proofless shield
sound, and it is why the layout is not negotiable.
What is actually hidden
Transfer hides everything of substance: sender, recipient and amount. The fee is public when non-zero — and so is the account that submitted the transaction, which is why relayers exist (06-wallet.md).
Shield is public on the way in — everyone sees an address deposit 10 SOL. What is hidden is who ends up owning the note.
Unshield is public on the way out — everyone sees 3 SOL arrive at an address. What is hidden is which shield it came from.
Claim shows that a particular address spent a note it owned. Which note, how much, and where the value went stay hidden — see the next section.
So the privacy is in the link, not in the endpoints. What breaks the link is other people using the pool. See 05-limitations.md — that document is not boilerplate, and a small anonymity set is the failure mode users will actually hit.
Notes owned by addresses
owner_pk is ordinarily Poseidon1(s) for a spending key s, and spending
means proving knowledge of s inside the circuit. A note can instead carry
owner_pk = Poseidon2(address_hi, address_lo)
— the two 16-byte halves of a Solana address. To the tree, to shield and to
transfer it is a note like any other; they never look inside owner_pk. It
is spent with claim, whose proof says "this note belongs to address A" and
whose authorization is simply that A signed the transaction. The program
builds the proof's owner input from the key of a signer account, so the
runtime's signature check stands in for an in-circuit one.
On most chains this trade is not available: checking an account's signature inside a circuit costs millions of constraints, so shielded pools mint their own keys and live apart from the account system. Here the signature check is free and already done, and that changes who can take part:
- Any address can be paid privately, with no setup. The sender needs only the address. Sender and amount are hidden, and the value arrives still shielded — unlike an unshield, which puts the amount on the public record.
- Programs can hold shielded value. A PDA signs by
invoke_signed, so a program can own notes and spend them by CPI: a DAO treasury whose outflows are visible as events but not as amounts or payees, an escrow that settles privately, payroll. Multisigs work the same way. - A claim needs no relayer. The owner is public in a claim anyway, so paying the network fee from the same address leaks nothing further.
What it costs: a claim names its owner publicly, and whoever created the note can recognize the claim when it happens (the nullifier is keyed by the note's blinding, which the creator chose). 05-limitations.md.
Returnable notes
Paying an address has one sharp edge. A browser wallet cannot decrypt what is sent to its address, so the sender passes a claim code — and if the code is lost, or the address was mistyped, the value is gone. The pool cannot tell a lost note from an unhurried recipient.
A returnable note is owned by a condition:
pk = Poseidon4(claimant_hi, claimant_lo, refund_pk, not_before)
The claimant spends it with redeem, which is a claim in all but its
circuit: the address signs, and the refund key and return time stay private.
From not_before on, whoever holds the key behind refund_pk may spend it
with reclaim; the program checks the clock, the proof ties the time to the
note. Both derive the nullifier from the note's blinding, so the first to land
is the only one that does.
Nothing about commitments, the tree or transfer changed. A returnable note
is made by an ordinary transfer that does not know it has made one — the same
trick that gave addresses their notes, used once more.
The sender keeps nothing. The output's ciphertext is a memo encrypted to the sender's own key, from which the wallet rebuilds the right to reclaim on any device. Refund keys are derived per note, so claimants cannot compare codes and recognize a common payer.
Saying something without opening anything
attest shows that one note holds at least a threshold. Membership in the
tree is not enough for that — a note that left the pool last year is still a
leaf — and a circuit cannot consult the nullifier records. So the note is
spent and recreated, same owner and amount, fresh blinding: the nullifier
going on record is what makes the claim about the present. The transaction
shows tag = Poseidon2(s, scope), a pseudonym that is stable under one
scope and unlinkable across scopes, where scope is whatever the verifier
asked for — ideally with a nonce in it.
Receipts need no transaction at all. A small circuit shows that a
commitment opens to a note owned by pk holding at least min_amount, with a
purpose bound in. The verifier checks the proof in the browser and looks the
commitment up among the pool's leaves.
Architecture
One Anchor program, programs/programs/zksvm-pool:
lib.rs the operations, their accounts, the NoteAdded and Attested events
tree.rs the pool account: incremental Merkle tree (depth 26) + 64-root
ring buffer; Poseidon through the syscall
verifier.rs Groth16 over BN254 on the alt_bn128 syscalls
field.rs field elements as bytes: canonicity, endianness, reduction
Proof verification and Poseidon hashing are far too heavy to run as ordinary
program code. They are possible here because Solana exposes both as syscalls:
alt_bn128 addition, multiplication and pairing, and sol_poseidon with
circom-compatible parameters. A transfer costs about 172k compute units, a
claim about 167k, an unshield about 150k.
The accounts:
| Account | Address | Holds |
|---|---|---|
| pool | PDA ["pool"] | the tree and the root history, zero-copy, fixed size |
| vault | PDA ["vault"] | the shielded lamports; a bare system account |
| nullifier record | PDA ["nullifier", nullifier] | nothing — its existence is the record |
Double spends
A spent note is a PDA seeded by its nullifier. Every spend creates that account, and the system program refuses to create an account twice. There is no set to search and no flag to forget to check: the second spend fails in account creation, before the program's own logic runs.
That only works because nullifiers are checked for canonical encoding. The
pairing check sees a scalar mod r, so n and n + r satisfy the same
proof — but they are different bytes, hence different PDAs. field.rs rejects
anything at or above the modulus, which closes that door.
Solana executes transactions that write the same account one at a time, and every pool operation writes the pool account. So operations apply in a strict order, each seeing the tree the previous one left.
Roots and history
The tree is incremental (Tornado-style): only filled left siblings are stored, so an insert is O(depth) and the pool account never grows. Depth 26 is ~67M notes.
The last 64 roots stay valid for proofs. Without that window, a wallet that builds a proof and submits it a moment later would fail whenever anyone else touched the pool in between — a race every user would hit under load. Every operation appends at least one leaf, so the window is 64 operations deep, not 64 slots: on a busy pool a proof goes stale quickly, and wallets should sync, prove and send without pausing.
Assets
Every note carries an asset id: 0 for SOL, and for an SPL token its mint, read little-endian and reduced into the field. The circuits conserve value per asset and take the id as a private input, so a transfer never shows which asset it moved — SOL and tokens share one tree and one anonymity set.
Only the edges are asset-specific. shield_token and unshield_token move
tokens through a per-mint vault that is its own authority; the first shield of
a mint creates it. Nobody lists assets and nobody can delist one. They reuse
the unshield circuit unchanged, so none of this needed a new ceremony.
A balance that earns
shield_stake takes SOL, deposits it into an SPL stake pool by CPI — Jito's,
in the reference wallet — and shields the pool tokens that come back, in one
instruction. The note is denominated in jitoSOL, and jitoSOL appreciates
against SOL every epoch: the yield needs no accounting inside the pool at all.
A proofless shield is sound because the program, not the depositor, decides what the note is worth. Here the wallet has to state the token amount — it encrypts the note before sending — so the program measures what the stake pool actually minted into the vault and refuses a note that claims more. A note may claim a base unit less than was minted; never one more.
unshield_stake is the way back. It spends a note of the stake pool's token
— the unshield circuit, unchanged — and CPIs WithdrawSol: the tokens are
burned from the vault, which signs for itself, and the stake pool pays the
recipient in lamports from its reserve. The recipient is bound into the proof,
as in every unshield.
This is also the pool's incentive. Privacy comes from value staying in the pool while other people come and go, and until now staying cost something. A balance that earns is a balance with a reason to wait.
Who you trust
The ceremony. Groth16 needs a trusted setup. Whoever holds the phase-2 toxic waste can forge proofs and mint value out of the pool. The security assumption is "at least one honest contributor", which is why 03-ceremony.md matters more than any other document here.
The upgrade authority. Whoever can upgrade the program can replace the verifying keys or the rules around them, and with that take the vault. A multisig narrows this; burning the authority removes it, along with any chance of fixing a bug. 02-deployment.md.
Solana, for liveness and ordering — the validator set, not an operator of this pool. Nobody running zkSVM can censor or halt it.
The circuits, for soundness. A constraint bug is a mint bug. They are small and readable; read them.
Your own key management. Notes are bearer instruments. Lose the spending key and the funds are gone — no recovery, no support, nobody who can help you.