Architecture
Privé is a stack of layers that ship today. On-chain contracts hold the pool and its state, a Groth16 circuit proves a withdrawal is legitimate without revealing which deposit funded it, a TypeScript SDK does all secret handling in the browser, an optional relayer broadcasts withdrawals for users who have no gas, and an Association Set Provider screens deposits into a recomputable approved-set root. The one piece not yet live is enforcing that set inside the withdraw circuit. This page describes each layer as it exists in the repository.
Layer map
| Layer | What it is | Status |
|---|---|---|
| Contracts | PrivacyPool, WithdrawVerifier, MockUSDG | Live on testnet |
| Circuits | withdraw.circom, Groth16 over BN254 | Live on testnet |
| SDK | @pp/sdk, notes, tree sync, in-browser proving | Live, not on npm |
| Relayer | /api/relay, gasless withdrawals for a fee | Live on testnet |
| ASP / compliance | Deposit screening and a recomputable approved-set root | Live as attestation |
| In-circuit enforcement | Proof of innocence checked inside the withdraw circuit | Planned |
1. Contracts
PrivacyPool is the whole protocol. It extends CommitmentTree, which holds a LeanIMT of note commitments, a circular history of the last 64 roots (ROOT_HISTORY_SIZE), and a spent-nullifier set (isSpent). Three values are immutable and set at construction: VERIFIER, TOKEN (the USDG ERC-20 the pool accepts) and SCOPE, a domain separator derived deterministically as uint256(keccak256(“Prive.PrivacyPool.v0”)) % SNARK_SCALAR_FIELD. The pool has no owner, no pause switch, and no upgrade path.
deposit(value, precommitment) assigns a fresh label from keccak256(SCOPE, ++nonce), computes the leaf as Poseidon(value, label, precommitment), inserts it into the tree, and pulls value USDG from the caller. Deposits are public: the depositor address, the amount and the commitment are all in the Deposited event. Privacy comes from the withdrawal side.
withdraw(w, pA, pB, pC, pub) takes a Withdrawal struct (recipient, feeRecipient, feeAmount) and a Groth16 proof with six public signals:
pub[0] newCommitmentHash // the change note
pub[1] existingNullifierHash // spent, prevents double spend
pub[2] withdrawnValue
pub[3] stateRoot // must be in the 64-root history
pub[4] stateTreeDepth
pub[5] context // keccak256(abi.encode(w, SCOPE)) % fieldThe contract rejects a zero withdrawal, recomputes the expected context itself and reverts with ContextMismatch if it differs, rejects a zero recipient, rejects a fee greater than or equal to the withdrawn value, rejects an unknown state root, and then calls the verifier. Only after all of that does it spend the nullifier, insert the change note, and pay out. Because the recipient and the fee split are hashed into context, nobody, including the relayer, can redirect a payout or inflate a fee after the proof was made.
WithdrawVerifier is the Solidity verifier generated by snarkjs from the circuit. It is machine-generated and not hand-edited. MockUSDG is a 6-decimal ERC-20 with an open mint(), which is what powers the testnet faucet. It exists for testnet only and must never be deployed to mainnet.
Deployed on Robinhood Chain Testnet (chain id 46630), from packages/contracts/deployments/46630.json:
| Contract | Address |
|---|---|
| PrivacyPool | 0xd62C837a70b9552637C8ee1681fb09b459648181 |
| WithdrawVerifier | 0xBAFE6434C40e5582b11bA90cFd6cA1f5ebd904BF |
| MockUSDG | 0xF47593cac046C3a4C15B495eDAd59DE5868B6BbB |
2. Circuits
The single proving circuit is packages/circuits/circuits/withdraw.circom, instantiated as Withdraw(32), so the state tree supports up to 32 levels. It is adapted from the 0xbow privacy-pools-core withdraw circuit, with the ASP membership check removed.
What a valid proof asserts:
- The prover knows a note (
value,label,nullifier,secret) whose commitment is a leaf in the LeanIMT under the publicstateRoot, at the claimed depth. - The revealed
existingNullifierHashisPoseidon(nullifier)of that exact note, which is what makes the double-spend check meaningful. - The accounting is correct:
remainingValue = existingValue - withdrawnValue, with bothwithdrawnValueandremainingValuerange-checked to 128 bits, so you cannot wrap the field and mint value. - The change note commits to that remainder under the same
labeland a nullifier that is provably different from the spent one.
What the proof does not assert:
- No ASP membership. The circuit has exactly one Merkle inclusion check, against the state tree. There is no second inclusion check against an approved-deposit set. The source comment says so in plain words. Any claim that Privé enforces proof of innocence in-protocol today would be false.
- No in-circuit context constraint.
contextis a public signal, so a proof is only valid for the exact context it was made with, but the circuit deliberately adds no arithmetic constraint over it. Binding to the recipient and the fee is enforced on-chain, where the contract recomputes the value from theWithdrawalstruct andSCOPE. - Nothing about who you are. The circuit never sees an address.
Primitives: Poseidon over BN254 at arity 1 (nullifier hash), 2 (precommitment and the LeanIMT node hash) and 3 (commitment), matched exactly by poseidon-lite in the SDK and poseidon-solidity in the contract. The proof system is Groth16, which is why every circuit change requires a new proving key and a new on-chain verifier.
The proving key in the repo comes from a development ceremony, not a production one. The build script generates a Powers of Tau of size 2^16 and a single phase-2 contribution with hard-coded, deterministic entropy. Anyone who reads the script can reproduce the toxic waste, and therefore forge proofs. That is acceptable for testnet and unacceptable for real money. A real multi-party ceremony is a prerequisite for mainnet, alongside the audit.
3. SDK
@pp/sdk is the client library that the web app is built on. It is framework-agnostic, browser-first, and does every secret operation locally. It is not published to npm: the package is marked private and is consumed through the monorepo workspace. Treat the module names below as source paths, not as an install command.
| Module | Responsibility |
|---|---|
poseidon | Poseidon at arity 1, 2, 3 plus the LeanIMT node hash. Must match circuit and contract exactly. |
notes | Create a note from Web Crypto randomness, finalize it with the on-chain label, serialize, and select which single note to spend. The circuit is one note in, one change note out. |
tree | Rebuild the LeanIMT locally by replaying LeafInserted events and produce a 32-sibling inclusion proof. |
proof | Compute the context, assemble the witness, and run snarkjs groth16.fullProve in the browser. |
claimlink | Encode a finalized note into a /claim URL. The secret lives in the URL fragment so it is never sent to a server or written to an access log. |
backup | Passphrase-encrypted note export. PBKDF2-SHA256 at 200,000 iterations into AES-GCM-256. |
pool | A viem client for reads, deposits, withdrawals, and event fetching in RPC-friendly block windows. |
4. Relayer
A withdrawal is a transaction, and a transaction needs gas. If the recipient pays gas from a funded address, that address is a link back to them. The relayer removes the link: the browser hands over a finished proof, the relayer broadcasts it and takes its fee out of the withdrawn USDG. This is the /api/relay route in the web app. GET returns the fee recipient, the minimum fee and the chain id. POST relays.
Before it will broadcast, the route:
- rate-limits the source IP to 10 requests per minute,
- checks that
feeRecipientis its own address, so it cannot be used as a free broadcaster for someone else’s fee, - enforces a minimum fee (0.05 USDG on testnet) and requires the fee to be strictly below the withdrawn value,
- recomputes
contextserver-side from the recipient, the fee recipient, the fee and the poolSCOPE, and rejects the request if it does not equalpub[5], - refuses a nullifier that is already in flight, so a retry cannot burn a note twice,
- simulates the call with
eth_calland only broadcasts if the simulation succeeds, so bad proofs and spent notes fail for free.
Trust model of the relayer. It can refuse you: it can censor a withdrawal, go offline, or run out of gas. It cannot steal from you, because the recipient and the fee are inside the proof context and any tampering reverts on-chain. It cannot deanonymize you, because it never sees your note, your secret, or your deposit. And it is never mandatory: the app falls back to self-submission, where you pay your own gas with feeRecipient = address(0) and feeAmount = 0.
5. ASP and the compliance layer
Live (attestation) The Association Set Provider is the piece that makes private payments defensible rather than merely private. It reads the pool’s public Deposited events, screens each depositor against a public policy, and builds a Merkle tree over the approved labels. The root of that tree is the approved-set root. Because it is derived only from public data with no signing key, anyone can rebuild the identical root and catch a lie. It lives in the SDK (screenDeposits, buildAspAttestation, checkNoteClean, buildComplianceReceipt, verifyReceipt) and is served at GET /api/asp.
Planned What is NOT live is enforcement. The withdraw circuit takes no second membership proof against that root, so a withdrawal on its own does not prove the funds are screened. Making it do so needs a new circuit, a new verifier, a fresh trusted setup and a pool upgrade, and lands with the mainnet audit. See Compliance model for the exact line and its open questions.
Trust model
What you do not have to trust. You do not have to trust Privé with custody: notes are generated in your browser and the pool has no owner and no admin key. You do not have to trust the relayer with your funds or your identity. You do not have to trust the web app to be online: the pool is a public contract and the SDK source is in the repository.
What you do have to trust. The contracts are not audited. The proving key comes from a development ceremony, so on this deployment a person with the setup entropy could forge a withdrawal proof. Your anonymity set is only as large as the number of deposits in the pool, which on a testnet is small. Timing and amount correlation can still link a deposit to a withdrawal if you deposit and withdraw an unusual amount immediately. And you must trust your own backup discipline: lose the note and the funds are unrecoverable by anyone, including us.
Everything above is testnet only, with a mock USDG token that anyone can mint. Do not treat any balance in this system as money. See Security and risk for the full list.