> ## Documentation Index
> Fetch the complete documentation index at: https://docs.opaque.cash/llms.txt
> Use this file to discover all available pages before exploring further.

# Amount privacy (privacy pool)

> Deposit and withdraw through the Opaque privacy pool with association-set compliance.

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](https://github.com/opaquecash/spec/blob/main/privacy-pool.md) 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.

<Warning>
  **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`).
</Warning>

## 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](https://github.com/opaquecash/spec/blob/main/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

```ts theme={"dark"}
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:

```ts theme={"dark"}
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](/guides/ons-naming)): 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](/sdk/relayer-client) 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

```ts theme={"dark"}
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.

```ts theme={"dark"}
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:

```ts theme={"dark"}
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.

```ts theme={"dark"}
// `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:

```ts theme={"dark"}
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:

```ts theme={"dark"}
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](/sdk/relayer-client).

### 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](/sdk/relayer-client)
submission also hides the gas wallet that lands the withdrawal transaction.
