Case study · Compliant privacy / ZK

ShieldedPay — Privacy Pools, implemented instead of admired

The 2023 Privacy Pools paper (Buterin, Illum, Nadler, Schär) argues that on-chain privacy and regulatory compliance are not opposites. This project is that argument as running code: stealth addresses for private receiving, a shielded pool where a withdrawal proves in zero knowledge that the funds belong to a "clean" association set — and a real Groth16 proof, generated against the live on-chain tree, verified and paid out on Sepolia.

Circom / Groth1621,735 constraintsPoseidon on-chainERC-5564 / 6538Foundry71 testsbrowser proving

The thesis: why association sets, not a mixer

First-generation privacy tools (Tornado Cash being the archetype) offered anonymity as an undivided set: the ZK proof hides your deposit among all deposits — honest user and thief mathematically indistinguishable. That's why the whole set got sanctioned. Privacy Pools breaks the dilemma with a second membership proof: at withdrawal you prove your commitment is in the pool's Merkle tree and in a smaller "association set" that a provider has screened as clean — both in zero knowledge, revealing which member you are in neither.

The elegance is set-theoretic: an honest user can always stand behind a large clean set and stay anonymous inside it; a flagged deposit can't join any clean set, so its withdrawal proof simply cannot be built. The user's choice of anonymity set is itself the compliance signal. This is the direction the ecosystem is actually funding — Privacy Pools live on mainnet via 0xbow (Coinbase Ventures seed), the EF's Kohaku wallet, EIP-8182, the Privacy Stewards of Ethereum. The full argument is in docs/THESIS.md.

What I built

PieceWhat it does
withdraw.circomThe dual-membership circuit: two Poseidon Merkle inclusion proofs over the same commitment (state tree + association tree), nullifier hash against double-spend, and recipient/relayer/fee bound into the proof (Tornado's x·x pattern) so a malicious relayer can't redirect funds. 21,735 constraints; Groth16 over the public Hermez Powers-of-Tau, verified by its published hash — never a home-rolled ceremony.
PrivacyPool.solFixed-denomination pool with an incremental Poseidon Merkle tree on-chain (hasher deployed from circomlibjs bytecode) and a root ring-buffer. Withdrawals verify the Groth16 proof and require the association root to have been published by the ASP.
ERC-5564 / ERC-6538The receiving-privacy layer: stealth meta-address registry (EIP-712, replay-protected) + announcer. ECDH on secp256k1 runs entirely client-side; the dApp's Stealth tab derives addresses and private keys without gas.
Decentralized ASPBeyond the paper's single-operator ASP: ASPRegistry with staked providers, per-provider root history, and three on-chain fraud proofs that make a lying ASP slashable (see below). Withdrawals can verify against any active registered provider.
dAppVite + React with proving in the browser: snarkjs + the 9.6 MB proving key served as static assets. The secret note never leaves the user's machine. Deposit, withdraw and stealth tabs against the live Sepolia contracts.

The live proof — evidence, not prose

After deploying and seeding the pool, I didn't stop at "contracts verified". I reconstructed the Merkle tree locally, generated a real Groth16 proof against the on-chain root, and submitted the withdrawal: proof verified on-chain, nullifier marked spent, recipient paid — tx 0xc4d21e0f…0531f4, 302,995 gas. Two public demo notes remain seeded so anyone can repeat the experiment from the dApp without depositing first.

Why this matters: the hard failure mode of ZK projects is the off-chain/on-chain mismatch — a tree that hashes differently in JS and Solidity, a verifier with reordered G2 coordinates, a digest that doesn't bind what you think it binds. A withdrawal that actually pays out on a public network proves the whole pipeline agrees, bit for bit.

Decentralizing the ASP — fraud proofs

The paper leaves the Association Set Provider as a trusted role. I designed and implemented the next step (docs/DECENTRALIZED-ASP.md): multiple ASPs register with stake, and three challenge functions make dishonesty provable and slashable on-chain (50% of stake to the challenger):

  • Integrity — the published root doesn't match the Merkle root of the revealed set (recomputed on-chain with a Solidity Poseidon lib that replicates the JS tree 1:1).
  • Degenerate set — a set below the minimum size, which would de-anonymize its members.
  • Inclusion of flagged funds — the set contains a commitment on the flagged registry: the "permissive ASP" attack.

The three compose: publishing a dirty root with a clean set-hash falls to the integrity challenge — no escape path. The limits are documented with the same honesty: censorship isn't slashable, data-withholding and taint propagation aren't recomputable on-chain, and those boundaries are stated in the doc rather than hidden.

Verify it yourself

  • 71 Foundry tests + 4 circuit tests run offline — including a test that verifies a real proof fixture on-chain, the association-set exclusion case (the compliance guarantee), double-spend, and the three fraud-proof paths.
  • All six contracts are verified on Sepolia; the README's deployment table links each one.
  • Withdraw a seeded demo note yourself at shieldedpay.vercel.app — your browser generates the zk-SNARK.
  • The ZK pipeline is reproducible: circuits/build.sh validates the constraint count, and the .ptau hash is checked against the published table.