zkSVM

App

Litepaper

Documentation

Links

DocsHow it works

The model

Notes, proofs, address-owned notes, and who you trust.

9 min read

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:

PublicPrivateProof
shieldamount, depositorrecipientnone needed
transferthe fee, and whoever submits iteverything else2-in / 2-out join-split
unshieldamount, recipientsender, source note1-in / 1-out
claimthe owning addresssource note, amount, recipients1-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:

AccountAddressHolds
poolPDA ["pool"]the tree and the root history, zero-copy, fixed size
vaultPDA ["vault"]the shielded lamports; a bare system account
nullifier recordPDA ["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.