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

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:
  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

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.