Private token transfers
Every Cardano transaction shows who paid, who got paid, how much and when. That’s fine for a lot of things, but not for payroll, supplier payments, donations to sensitive causes or a treasury that doesn’t want to broadcast its strategy. Using fresh addresses doesn’t help much, because change outputs and timing let chain analysis stitch them back together. A shielded pool breaks the link: many people deposit the same amount, and each later withdraws to a new address without revealing which deposit was theirs.
The zero-knowledge idea
Section titled “The zero-knowledge idea”The withdrawer proves “I know the secret behind one of the deposits in this pool, and this is its one-time spend tag”, without revealing which deposit.
- Deposit. Pick two random values,
secretandnullifier. Compute the note commitmentcommitment = Poseidon(secret, nullifier)and deposit a fixed amount (say 100 ADA) along with it. The commitment joins the pool’s Merkle tree. - Withdraw. Later, possibly from a brand-new address, prove the commitment is somewhere in the
tree and reveal
nullifierHash = Poseidon(nullifier, 0). The pool records the hash so the same note can’t be withdrawn twice.
Nobody learns which leaf was spent. Your anonymity set is every deposit of the same denomination.
What stays private, what’s public
Section titled “What stays private, what’s public”| Input | Visibility | Why |
|---|---|---|
secret | Secret | Proves you own the note; only the depositor (or whoever they share it with) knows it |
nullifier | Secret | Only its hash is revealed, so the spend tag can’t be matched to the deposit |
Merkle path (siblings, pathBits) | Secret | The path would reveal which deposit is being spent |
merkleRoot | Public | A recent root of the pool’s commitment tree |
nullifierHash | Public | Recorded on-chain to block a second withdrawal of the same note |
recipient | Public | Where the funds go, bound into the proof so it can’t be redirected |
The design notes also add a public relayerFee, bound the same way as recipient.
How it works
Section titled “How it works”- Deposit. Alice sends 100 ADA and her commitment to the pool. Her address is visible, but her secret and nullifier stay with her.
- Grow the tree. The pool absorbs deposits into its commitment tree. On Cardano a single shared root UTxO would serialize every deposit. The design notes therefore recommend collecting deposits as individual UTxOs and folding them into the tree in periodic batches.
- Wait. Time and more deposits grow the anonymity set. Withdrawing right after depositing gives most of it away.
- Withdraw through a relayer. A fresh address has no ADA to pay fees, so a relayer submits the transaction and takes a fee that the proof also binds.
- The pool validator verifies the proof, checks the root is one it has published, checks that
nullifierHashis new (for example in a sorted linked list, as in the voting demo), records it and pays the recipient.
Alice ──100 ADA + commitment──▶ pool (public: Alice → pool) ... other deposits, time passes ...relayer ──proof + nullifierHash──▶ pool ──100 ADA──▶ fresh address (public: pool → ???)The circuit
Section titled “The circuit”This is a design sketch in the annotation style, adapted from the withdrawal circuit in the design notes with BLS12-381 Poseidon and an explicit recipient binding. It isn’t taken from a runnable demo.
import org.zeroj.circuit.annotation.*;import org.zeroj.circuit.lib.poseidon.PoseidonParams;import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3;import org.zeroj.circuit.lib.zk.ZkMerkle;import org.zeroj.circuit.lib.zk.ZkPoseidon;
@ZKCircuit(name = "pool-withdrawal", nameTemplate = "pool-withdrawal-d{treeDepth}", version = 1)public class PoolWithdrawal { private static final PoseidonParams POSEIDON = PoseidonParamsBLS12_381T3.INSTANCE;
public PoolWithdrawal(@CircuitParam("treeDepth") int treeDepth) {}
@Prove ZkBool prove(ZkContext zk, @Public ZkField merkleRoot, @Public ZkField nullifierHash, @Public ZkField recipient, @Secret ZkField secret, @Secret ZkField nullifier, @Secret @FixedSize(param = "treeDepth") ZkArray<ZkField> siblings, @Secret @FixedSize(param = "treeDepth") ZkArray<ZkBool> pathBits) {
// 1. My note is one of the pool's deposits var commitment = ZkPoseidon.hash(zk, POSEIDON, secret, nullifier); ZkMerkle.verifyProofPoseidon(zk, POSEIDON, commitment, merkleRoot, siblings, pathBits);
// 2. Bind the recipient: a variable x variable product emits a real constraint row recipient.mul(nullifier);
// 3. The public spend tag belongs to this note return ZkPoseidon.hash(zk, POSEIDON, nullifier, zk.constant(0)).isEqual(nullifierHash); }}Step 2 is the part people forget. A public input that appears in no constraint isn’t committed to
by a Groth16 proof, so anyone could swap the recipient. Multiplying it by a secret variable creates
a real constraint. The airdrop demo uses the same
technique, and ZeroJ’s native Groth16 setup refuses a relation that leaves a public input unbound.
Before building on a sketch like this, write invalid-witness tests: a note that isn’t in the tree, a
wrong nullifierHash, and a proof checked against a changed recipient. All of them must fail. See
Testing circuits.
On Cardano
Section titled “On Cardano”Verification would use Groth16BLS12381Lib from zeroj-onchain-julc inside a custom pool
validator, as every demo on this site does. The proof is the easy part. The validator carries most
of the security:
- Known roots only. Accept
merkleRootonly if it’s the current root or a recent one the pool published. Otherwise a withdrawer can prove membership in a tree they made up. - Nullifier registry. Store each
nullifierHashso it can’t be reused. A sorted linked list gives trustless, concurrent inserts. - Pay exactly as bound. Require an output to
recipientfor the denomination minusrelayerFee, and the fee to the relayer, and nothing else leaving the pool. - Value accounting. The pool must never release more than it holds for a denomination.
A valid proof is not authorization. Every one of these checks lives in the validator, not the circuit. See Application security.
Security considerations
Section titled “Security considerations”- Anonymity set size. With 5 deposits you’re 1 in 5. Privacy grows only as the pool is used.
- Timing and amount fingerprinting. Withdrawing soon after depositing, or using unusual amounts, links you. Fixed denominations help; variable amounts need a more complex circuit.
- Relayer trust. A relayer can refuse to submit (censor) but can’t redirect funds if the recipient and fee are bound. Use more than one.
- Note secrets. Whoever holds
secretandnullifiercan withdraw. Losing them loses the funds. - Compliance. “Privacy pools” designs let users prove their deposit belongs to an approved association set. The design notes sketch this. Get legal advice before running anything like it.
- Trusted setup and review. A pool holding value would need a multi-party ceremony (Trusted setup, explained) and an external audit, neither of which exists for this design.
Try it
Section titled “Try it”There’s no demo to run. To experiment with the building blocks:
- Work through Private allowlist with a Merkle tree. It’s the same membership-plus-nullifier core, and it’s runnable.
- Run the voting demo to see a sorted-list nullifier registry on Yaci DevKit.
- Read the design notes: Private token transfer — detailed design (UTxO patterns for commitments, batch deposits, relayers, multi-asset pools). Its code samples are older sketches. Prefer the BLS12-381 Poseidon parameters and explicit recipient binding shown above.
Related
Section titled “Related”- Private voting: nullifiers and a sorted-list registry, running end to end
- Gadgets:
ZkMerkle,ZkPoseidon - On-chain verification
- ZK on Cardano