Skip to main content
The privacy pool adds amount privacy on top of stealth addresses: deposit a value-bearing commitment, then withdraw to a fresh stealth address with no on-chain link between deposit and withdrawal. It is the Privacy Pools construction (Buterin, Illum, Nadler, Schär, Soleimani), not a mixer: a withdrawal requires a zero-knowledge proof that the deposit belongs to an Association Set Provider (ASP)-curated “clean” set, so honest users cryptographically dissociate from illicit funds.
Testnet only, unaudited. The circuits, contracts, and trusted setup are not audited and the setup is a single-contributor testnet ceremony. Do not deposit real value. Mainnet is gated on circuit + contract audits, a production trusted-setup ceremony, and a legal opinion (see the ethereum repo’s DISCLAIMER.md and security/privacy-pool-audit-plan.md).

How it works

The commitment scheme, trees, and proof are specified in privacy-pool.md. The SDK’s Poseidon commitments and Merkle trees are byte-identical to the circuit and contract.

Getting the association set

To withdraw you need the ordered approved labels (aspLeaves) and your label’s position in them (aspIndex). Both are self-authenticating: you verify them by recomputing the Merkle root and checking it equals the on-chain aspRoot, so the source is never trusted. @opaquecash/privacy-pool (>= 0.3.0) gives you two decentralized ways to obtain them:
  1. Chain-native, reconstructAspSetFromDeposits. Under the default approve-all policy the set is just the deposit labels ordered by leafIndex, so the client rebuilds it straight from on-chain Deposit events. You already scan those events for the state tree, so this costs nothing extra and depends only on the chain.
  2. Published manifest, resolveAspSetViaEns. For a selective policy (where the approved set is not derivable from chain), fetch the ASP’s published opening through its ENS text record (com.opaque.aspset, an ipfs:// pointer), then verify it with aspSetFromManifest.
The contract accepts a proof only against the single current aspRoot (the state root, by contrast, has a short history window). So resolve the set against the latest aspRoot, build the proof, and submit promptly. If the ASP posts a new root in between, rebuild against the new one.

Deposit

Withdraw

Reconstruct the state tree from the pool’s Deposit commitments, resolve the association set for the current aspRoot, then prove and submit:
The remainder (value - withdrawnValue) is re-inserted as a fresh commitment under the same label, so it stays in the association set. Persist (remainder, label, newNullifier, newSecret) to spend it later.

Compose with recipient privacy

Withdraw to a fresh stealth address resolved from a recipient’s meta-address (CSAP / ONS): amount privacy (pool) and recipient privacy (stealth address) compose, so neither the payer-to-payee link nor the deposit-to-withdrawal link is on-chain.

Policy

There is no hosted one-click pool UI. Pool access is via this SDK and self-hosted integrators only. See the relayer market to also hide the gas wallet that submits the withdrawal.

Full end-to-end walkthrough (Ethereum Sepolia)

A complete, copy-pasteable deposit and withdraw flow against the live Sepolia pool (0x49a5bB6d079a43d50596069b4F2632005CFe729E). The same shape works on Solana devnet via the program at 5NjweHM4z7NrG4NLVUyJ8rtX8jLM3xtBWAR1wSJZ7vjY (build the instructions with @solana/web3.js instead of viem; the crypto is identical). Working references: ethereum/infra/scripts/e2e-privacy-pool.ts and solana/scripts/e2e-privacy-pool.mjs.

0. Setup

1. Deposit

The depositor picks secrets, deposits the value, and persists the note. It is the only way to withdraw and cannot be recovered from the chain.

2. Wait for ASP approval

An Association Set Provider reviews the deposit and, if it is clean, adds your label to the association set and posts the new aspRoot on-chain. You can withdraw only once your label is in the aspRoot the contract currently holds. On testnet a single authority controls the ASP root. Read the current root you must prove against:

3. Reconstruct the trees

Build the state tree from every Deposit commitment (in leafIndex order), and resolve the association set for the current aspRoot. Both are self-authenticating: the proof only verifies if your rebuilt trees hash to the on-chain roots.
For a selective ASP policy, where the approved set is not derivable from chain, fetch the ASP’s published opening through its ENS pointer instead, then verify it against the on-chain root:

4. Build the proof and withdraw

The recipient is a fresh stealth address. The contract binds the payout into the proof via context, so read it from the contract and feed it to the witness:
To submit without exposing your own gas wallet, hand withdrawTx to a relayer instead of sending it yourself (the recipient and fee are bound into the proof, so the relayer cannot redirect funds). See the relayer market.

5. Spend the remainder later

The remainder (record.value - withdrawnValue) was re-inserted as a new commitment under the same label with newNote’s openings. Persist { value: remainder, label: record.label, nullifier: newNote.nullifier, secret: newNote.secret, leafIndex: <new> } and repeat from step 3 to withdraw it. No new ASP approval is needed, since the label is unchanged.

What an observer sees

  • Deposit: an address deposited value and a commitment + label were emitted.
  • Withdrawal: a (different) address submitted a proof; a fresh address received withdrawnValue; a nullifier and a new commitment were recorded.
There is no on-chain link between the deposit and the withdrawal, and the withdrawal amount need not equal the deposit. Pairing this with a relayer-market submission also hides the gas wallet that lands the withdrawal transaction.