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
deposit: pick nullifier, secret -> precommitment = Poseidon(nullifier, secret)
deposit(precommitment){value} -> contract assigns label = Poseidon(scope, index),
inserts commitment = Poseidon(value, label, precommitment) into the state tree
ASP: provider reviews the deposit; if clean, adds `label` to the association set
withdraw: prove (commitment in state tree) and (label in association set) and nullifier and
value-accounting, to a FRESH stealth address -> paid out, remainder re-inserted
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:
- 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.
- 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
import { buildPoolCrypto, generateDepositNote, buildDepositTx } from "@opaquecash/privacy-pool";
import { randomBytes } from "@noble/hashes/utils";
const crypto = await buildPoolCrypto();
const note = generateDepositNote(crypto, randomBytes); // { nullifier, secret, precommitment }
const tx = buildDepositTx(poolAddress, note, parseEther("1"));
await wallet.sendTransaction(tx); // value = 1 ETH
// PERSIST `note` (nullifier + secret) and the assigned `label` (from the Deposit event).
// They are required to withdraw and are never recoverable from the chain alone.
Withdraw
Reconstruct the state tree from the pool’s Deposit commitments, resolve the association set
for the current aspRoot, then prove and submit:
import {
buildWithdrawalWitness,
generateWithdrawalProof,
buildWithdrawTx,
reconstructAspSetFromDeposits,
aspIndexOf,
} from "@opaquecash/privacy-pool";
// `deposits`: { label, leafIndex } for every Deposit event (the same scan builds the state tree).
const set = reconstructAspSetFromDeposits(crypto, deposits, currentAspRoot);
if (!set) throw new Error("no set matches the current aspRoot yet; wait for the next ASP update");
// The contract binds the payout into the proof via `context`; read it first.
const params = { recipient: freshStealthAddress, feeRecipient, fee };
const context = await publicClient.readContract({
address: poolAddress, abi: opaquePrivacyPoolAbi, functionName: "context", args: [params],
});
const witness = buildWithdrawalWitness(crypto, {
note: { value, label, nullifier, secret },
withdrawnValue: parseEther("0.4"),
newNullifier, newSecret, // fresh openings for the remainder
stateLeaves, stateIndex, // from the pool's Deposit events
aspLeaves: set.aspLeaves, // resolved + verified against aspRoot
aspIndex: aspIndexOf(set.aspLeaves, label),
context,
});
const { proof } = await generateWithdrawalProof(witness, {
wasmPath: "withdrawal.wasm",
zkeyPath: "withdrawal_final.zkey",
});
const tx = buildWithdrawTx(poolAddress, proof, witness.publics, params);
await wallet.sendTransaction(tx); // the recipient receives withdrawnValue - fee
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
import { createPublicClient, createWalletClient, http, parseEther, decodeEventLog, parseAbiItem } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
import { requireEvmDeployment } from "@opaquecash/deployments";
import {
buildPoolCrypto, PoolMerkleTree,
generateDepositNote, buildDepositTx,
buildWithdrawalWitness, generateWithdrawalProof, buildWithdrawTx,
reconstructAspSetFromDeposits, aspIndexOf,
opaquePrivacyPoolAbi,
} from "@opaquecash/privacy-pool";
import { randomBytes } from "@noble/hashes/utils";
const pool = requireEvmDeployment(11155111).contracts.opaquePrivacyPool;
const account = privateKeyToAccount(process.env.FUNDER_KEY as `0x${string}`);
const wallet = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const crypto = await buildPoolCrypto();
// The Deposit event carries the contract-assigned label + leaf index.
const depositEvent = parseAbiItem(
"event Deposit(bytes32 indexed commitment, uint256 label, uint256 value, uint32 leafIndex)",
);
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.
const value = parseEther("0.01");
const note = generateDepositNote(crypto, randomBytes); // { nullifier, secret, precommitment }
const depositTx = buildDepositTx(pool, note, value);
const depositHash = await wallet.sendTransaction(depositTx);
const receipt = await publicClient.waitForTransactionReceipt({ hash: depositHash });
// Decode the contract-assigned label + leaf index from the Deposit event.
const depositLog = receipt.logs.find((l) => l.address.toLowerCase() === pool.toLowerCase());
const { args } = decodeEventLog({ abi: [depositEvent], data: depositLog!.data, topics: depositLog!.topics });
// Persist this note record off-chain (encrypted):
const record = {
value,
label: args.label,
leafIndex: Number(args.leafIndex),
nullifier: note.nullifier,
secret: note.secret,
};
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:
const currentAspRoot = BigInt(
await publicClient.readContract({ address: pool, abi: opaquePrivacyPoolAbi, functionName: "aspRoot" }),
);
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.
// `commitments`: all Deposit event commitments, ordered by leafIndex (from logs or an indexer).
const stateTree = new PoolMerkleTree(crypto, commitments);
const stateProof = stateTree.proof(record.leafIndex);
// `deposits`: { label, leafIndex } from the same Deposit events. Under the default approve-all
// policy the association set is these labels ordered by leafIndex, so rebuild it from chain and
// pick the prefix matching the on-chain root. No dependency on the ASP server.
const set = reconstructAspSetFromDeposits(crypto, deposits, currentAspRoot);
if (!set) throw new Error("no association set matches the current aspRoot yet (deposit not yet approved/posted)");
const aspIndex = aspIndexOf(set.aspLeaves, record.label);
if (aspIndex < 0) throw new Error("your label is not in the current ASP set yet; wait for the next ASP update");
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:
import { resolveAspSetViaEns, aspSetFromManifest } from "@opaquecash/privacy-pool";
const manifest = await resolveAspSetViaEns("evm-asp.opqtest.eth", {
ensGetText: (name, key) => publicClient.getEnsText({ name, key }),
});
const set = aspSetFromManifest(crypto, manifest, currentAspRoot); // throws if it does not match the root
const aspIndex = aspIndexOf(set.aspLeaves, record.label);
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:
const params = { recipient: freshStealthAddress, feeRecipient, fee: 0n };
const context = await publicClient.readContract({
address: pool, abi: opaquePrivacyPoolAbi, functionName: "context", args: [params],
});
const withdrawnValue = parseEther("0.004");
const newNote = generateDepositNote(crypto, randomBytes); // remainder openings
const witness = buildWithdrawalWitness(crypto, {
note: { value: record.value, label: record.label, nullifier: record.nullifier, secret: record.secret },
withdrawnValue,
newNullifier: newNote.nullifier,
newSecret: newNote.secret,
stateLeaves: commitments, stateIndex: record.leafIndex,
aspLeaves: set.aspLeaves, aspIndex,
context: BigInt(context),
});
const { proof } = await generateWithdrawalProof(witness, {
wasmPath: "withdrawal.wasm", // from circuits/v2/build (served statically in a browser)
zkeyPath: "withdrawal_final.zkey",
});
const withdrawTx = buildWithdrawTx(pool, proof, witness.publics, params);
const wHash = await wallet.sendTransaction(withdrawTx);
await publicClient.waitForTransactionReceipt({ hash: wHash });
// freshStealthAddress now holds withdrawnValue - fee, unlinked from the deposit.
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.