← All posts

May 20, 2026 · Permissionless Technologies

What is a privacy pool?

A small import business has a payment problem. The fix is the same one every privacy protocol on Ethereum reuses: four objects that hide payments without hiding the rules. Part 1 of Privacy Pool 101.

privacy-pooleducationprivacy-pool-101UPPcommitmentsnullifiersmerkle-tree
// privacy-pool-101.md// part 01 of 08What is aprivacy pool?// notes.// commitments.// one stamped receipt.permissionless-technologies.comUPP
Prefer to watch? A short walkthrough of the four objects we cover below.

Maria runs a small import business in Hamburg. She sells figs, dates, dried herbs, and smoked spices to restaurants and specialty shops across northern Germany. Most of her stock comes from one supplier in Buenos Aires, a family-run operation she has worked with for six years. They talk every other week on video.

The hardest part of working with him has always been paying him. Argentina's capital controls. Bank wires that take a week or vanish. Compliance officers on both ends with questions about every transfer. After enough of those months, Maria and her supplier decided together to try paying in stablecoins instead.

For the last six months, Maria has been paying her supplier in USDC. It fixed the supplier's cash-flow problem. It also created a new one.

Maria, a small-business owner in her mid-thirties, holds a wooden tray of dried figs, dates, apricots, cinnamon sticks, oregano, and rosemary against warm wooden shelving with burlap sacks labelled SPICES, FIGS, HERBS.

This is how you can imagine Maria with her shipment, in her store.

Three things she did not realize when she started

One. Her competitor sees everything

Her competitor in Hamburg can see every payment she makes. Wallet addresses are public on Ethereum. USDC transfers are public. Amounts and dates are public. Anyone who knows her company wallet can pull up her supplier-payment history in five minutes on a block explorer.

Two. The supplier's bank gets nervous

Her supplier's local bank can see the same flow. Last month the bank had questions about a large incoming on-chain transfer and froze the supplier's off-ramp for two weeks. Maria's invoices sat unpaid while her supplier tried to explain.

Three. The bookkeeper leaked the wallet

Maria's own bookkeeper accidentally shared the company wallet address in an email thread with three external partners. Two of them now know exactly how much Maria spends on inventory each month. They have not said anything about it. Yet.

She has a normal small-business problem. She wants to pay her supplier the same way she does now. She does not want everyone with a block explorer to know how much, how often, or on which day.

She types "private USDC payments" into a search bar. The answers come back. They all use a phrase she has not seen before: privacy pool.

This is what one of those is.

A privacy pool, drawn once

// the pool, the users, the chainthe poolnotecommitmentnullifiermerkle treeMariashieldwithdraw// ethereum sees money in and money out. not what happens between.

A privacy pool in one picture: Maria's money goes in, nobody can see what happens inside, someone withdraws on the other end.

A privacy pool is a smart contract on Ethereum that holds stablecoins. The deposit is public. When Maria sends 5,000 USDC into the pool, that transfer is visible on chain like any other. The pool knows she deposited 5,000 USDC. So does anyone with a block explorer.

What is not public is anything that happens after the deposit. Inside the pool, the money can be split, sent to other users, and recombined, and none of those movements are visible from the outside. When someone eventually withdraws, the withdrawal is public too. But it cannot be linked back to any specific deposit.

Think of a music festival with drink tokens. You buy a stack of tokens at the front gate. The cashier sees you do it. You walk in. Inside the festival you give tokens to your friends, you buy a beer, you hand a token to a stranger who needs one. None of that is tracked. At the end of the night, anyone can cash out their remaining tokens at the gate. The gate sees the cash flowing back out. But it cannot match any cashed-out token to who originally bought it.

A privacy pool is the same shape, with stablecoins instead of drink tokens.

How does a smart contract pull this off?

Four objects make it possible: notes, commitments, nullifiers, and one Merkle tree. The next four sections each cover one of them. None of them is harder than what you already understand about email and receipts.

1. The note: your claim ticket

A note is an off-chain receipt. It says "I own X USDC inside the pool." It lives in Maria's wallet, not on the blockchain. She is the only person who has it.

Think of the coat check at a restaurant. You hand over your coat. The attendant hands you a paper ticket with a number on it. Later you come back, show the ticket, get your coat. The ticket is yours. The restaurant has the coat. The ticket is what makes you the owner of that coat, even though anyone in theory could pick up a coat off the rack.

A note works the same way. Maria deposits 5,000 USDC into the pool. The pool keeps the money. Her wallet keeps the note. The note says "5,000 USDC, mine, in this pool."

// one note, in maria's walletamount5,000ownermaria.ethblinding0x9a3b...tokenUSDC// lives in the wallet. never on chain in this form.

What's inside one note.

But wait, isn't my private key supposed to be enough?

If you have used Ethereum or any other public chain, you have built up a mental model: your private key controls your money. Lose the key, lose the money. Nothing else to back up.

A privacy pool keeps half of that model and breaks the other half. The chain still holds the value. The pool's smart contract has, say, ten million USDC inside it. But the chain does not know which slice belongs to whom. It cannot. That is the privacy. So your wallet needs to know something the chain does not, in plain form: the note. The note tells the wallet which slice of the pool is yours.

A reasonable next question: "So now I have to back up notes on top of my seed phrase?"

In our design, no. When you deposit, the pool writes your note onto the chain in encrypted form alongside the public commitment. The ciphertext sits there for anyone to see; only your key can decrypt it. If your wallet ever loses its local note cache, it can re-scan the chain and rebuild the cache from those encrypted notes. Your private key is still the only thing you actually have to remember.

  • Lose your private key → standard problem. You cannot sign, and you cannot decrypt your notes.
  • Lose your wallet's local note cache → re-sync from chain. Your private key plus the public history is enough.

Worth knowing, this is a design choice, not a universal one. Earlier privacy pools like Tornado Cash did not emit encrypted notes on chain. Users had to back up the secret locally, and many lost access to their funds when those backups went missing. RAILGUN, Aztec, and UPP take a different path: the encrypted note rides along with the deposit event, so any wallet with the right key can rebuild its state from chain history. Same privacy properties, recoverable wallet.

The encryption itself, what stays sealed and what is left in the clear, what an auditor with the right key can see, is post 7 of this series.

// what the wallet has to remembernormal ethereumyour keyon chain (public)balanceOf(0xMARIA)= 5,000 USDC// the chain tracks the value.// the key signs to move it.privacy poolyour key+noteyour noteson chain (public)???// the chain only sees commitments.// only your notes say which are yours.// lose your key → standard problem. you cannot sign or decrypt.// lose your local notes → re-sync from chain. your key has you covered.

Ethereum splits the bookkeeping between the chain and the wallet differently in a privacy pool.

Bitcoin works like the right panel by default. Every Bitcoin UTXO is a note; your wallet keeps it; the chain only knows which UTXOs exist. Ethereum's account model is the left panel. Your balance is a number on a smart contract, visible to anyone. So every privacy protocol on Ethereum smuggles the note model back in, on top of the account model. You cannot have privacy with public balances. That is why privacy pools exist on Ethereum but no one builds them on Bitcoin.

Maria's wallet now has a note that says "5,000 USDC, mine, in this pool." But the pool itself never received that piece of paper. So how does the pool know the note is real?

That is the next object.

2. The commitment: a sealed envelope on chain

First, what is a hash?

Before the commitment makes sense, you need to know what a hash is. If you already do, skip the next two paragraphs.

A hash function is a one-way machine. Think of mixing colored paints. Yellow plus blue gives green. Anyone can mix yellow and blue and get the same green. But if I hand you a tin of green paint, you cannot separate it back into the yellow and blue you started with. The green is one-way. (If a kitchen analogy works better for you, picture meat through a grinder: mince out, no putting it back.)

// mixing paints. one way only.yellow+bluemixgreen// can you split green back into yellow and blue? no.

Yellow plus blue makes green. There is no path back to yellow and blue.

A hash is the same shape, with numbers instead of paint. You put a bunch of numbers in. You get one number out. The output is always the same size, no matter how much you put in. Same inputs always give the same output. Different inputs almost certainly give different outputs. And you cannot work backwards. If I show you the output, you cannot figure out what the inputs were. Not even if you try every guess in the world.

You have probably used hashes without realizing it. When you sign up for a website, most sites do not store your password. They store a hash of it. When you log in, the site hashes what you typed and checks the two hashes match. If a hacker steals the database, they get the hashes, not the passwords.

// a hash function: many in, one out5,000maria.eth0x9a3b...USDC...hash9c4f...8a72no way back// same inputs → same output. always.// output → inputs is impossible to reverse.

A hash takes many values in, gives one out, and there is no way back.

Now the commitment

A commitment is a number on chain that "stands for" Maria's note. It is computed by running the note's contents through a hash function. From the commitment alone, you cannot recover what the note says. That is the no-going-back property we just covered. But Maria, who has the note, can always prove that her note matches the commitment, because she can re-compute the hash and show the same number comes out.

Picture a sealed envelope at a notary's office. The notary writes down the envelope's ID number. They never open the envelope. Months later, Maria walks back in with the original letter. The notary checks: sealed the same way, this letter produces the same envelope ID. Yes, this is the letter that matches that ID. They never had to read the letter.

// the commitment: a sealed envelope on chainamount? ? ? ?owner? ? ? ?blinding? ? ? ?origin? ? ? ?token? ? ? ?commitment = Poseidon31(amount, owner, blinding, origin, token)// only the hash output// goes on chain// the values stay in// maria's wallet

A commitment hides five values inside one number.

Why is there a blinding field?

Without the blinding field, anyone could guess the contents of small notes. If notes only had amount and owner, then "Maria pays 1,000 USDC" has a guessable commitment because the amount is small and Maria's owner field is public. An attacker could hash a few thousand guesses and find a match. The blinding is a fresh random number that makes the commitment unguessable. Maria's wallet picks a new blinding every time she creates a note.

Which hash do we use?

The hash function we use here is called Poseidon31. It is one specific hash, not the only one. Bitcoin uses SHA-256. Git uses SHA-1. Ethereum uses Keccak. There are many hash functions because they have different strengths: speed, security, what they cost to compute inside a zero-knowledge proof. Poseidon31 is the one we picked. Post 3 of this series walks through why. For now, just remember: hash means "put values in, get one number out, can't go back."

3. The nullifier: a one-time stamp

When Maria spends her note (transfers it to someone, withdraws it from the pool), she has to prove the note is hers, and she has to mark the note as spent. Otherwise she could spend the same note twice.

The nullifier is the on-chain marker for "this note is spent." It is a number. The pool keeps a public list of all nullifiers it has ever seen. A new transaction submits a nullifier? Check the list. Already there → reject. Not there → accept, add it to the list.

Picture a theatre ticket. You walk in. The usher tears a stub off your ticket and drops the stub into a bin at the door. The bin is public. Anyone can look in. But nobody can look at a stub and tell which seat you came from, which performance you saw, or who you came with.

// the nullifier: one-time stamp, public binnullifierfresh stampdrop in binnullifierUsed (public)// anyone can read.// nobody can match a stamp to a note.nullifier = Poseidon31(nullifierKey, leafIndex, commitment)// only maria can compute this for her note. nullifierKey is secret

One note, one stamp. Drop it once.

Maria's nullifier is a hash of three things:

  1. A secret only she knows. Her wallet calls it nullifierKey. It is derived from her seed phrase.
  2. The position of her note in the pool. Each note that enters the pool gets its own number.
  3. The commitment of the note she is spending. The sealed envelope from section 2.
// three things go into maria's nullifiermaria's secret(her nullifierKey)leafIndex(note position)commitment(sealed envelope)Poseidon31nullifier

Three inputs, one nullifier. Only the first is private to Maria.

The secret is what keeps anyone else from computing the same nullifier. They do not have it.

Wait, why doesn't this leak anything?

Fair question. The nullifier value itself goes on chain. The pool needs it there to enforce the spend-once rule. So everyone can read every nullifier in the public set.

Now picture the obvious alternative. What if the pool just flipped a flag on the commitment itself?

spent[commitment[4728]] = true

That works for double-spend. It is also a privacy disaster. Anyone watching the chain saw which wallet deposited commitment[4728]. The moment that flag flips, the same observer knows the same wallet just spent. The deposit and the spend are tied together in public.

The nullifier is the way around this. It is a different number, derived from the commitment plus Maria's secret. Three properties matter:

  • Only Maria can compute it. The recipe uses her nullifierKey, which never leaves her wallet. No one else can take her commitment and produce the same nullifier.
  • It cannot be reversed. Given a nullifier sitting in the public set, no one can walk back through the Poseidon31 hash and recover the commitment that produced it. The hash is one-way.
  • It is deterministic. The same note produces the same nullifier every time. A second spend attempt submits the same value, and the pool rejects the duplicate.

To anyone watching the chain, the nullifier set looks like a stream of random-looking numbers. They cannot pick out "this one belongs to commitment[4728]." They cannot ask the reverse question either: "what nullifier should I expect when commitment[4728] is spent?" The link exists only in the math, and the math needs Maria's secret to evaluate.

How Maria proves she computed the nullifier correctly without revealing her secret is what a zero-knowledge proof does. That is post 2 of this series.

Why this is the trick that makes privacy pools work. Double-spend prevention without revealing identity, in one number. Fresh nullifier? Accept. Same one again? Reject. The commitment is never named in the open.

If you have heard of Tornado Cash, this is the part of Tornado Cash that worked. The compliance problem with Tornado Cash was elsewhere. Post 8 of this series covers that.

4. Proving Maria's commitment is in the pool

Maria now has a note, a commitment in the pool, and a nullifier ready to stamp. The last piece is showing the pool that her commitment really is one of the commitments inside it. That sounds easy. It is not.

Picture the pool as a long list. Every deposit adds a new commitment to the bottom. After a year, the list is tens of thousands of entries long. When Maria comes to spend, the pool needs proof her commitment sits on that list somewhere.

The obvious way. Point at the entry. "It's row 4,728." The pool checks, sees the match, lets her spend. That works. It also destroys the privacy in one step. The whole world saw her deposit go in at row 4,728. Now the whole world sees her spend out of row 4,728. The link is right there in public.

The next-obvious way. Have Maria prove her commitment is somewhere in the list without saying where. That is closer to what we want. But it forces the pool to keep the full list in memory and walk through every entry on every spend. Gas costs grow with the list. After a million deposits, nobody can afford to use the pool.

// two ways that don't workoption a: point at the row// "it's row 4,728"commitment[4728]deposit// row 4,728spend// row 4,728deposit row = spend row.the link is right there in public.option b: pool checks the whole list// "your commitment is somewhere in here, right?"pool contract holds every commitment. . .checkeveryentryN entries → gas grows with Nat one million deposits,nobody can afford to spend.neither works. there is a better way.

Two failure modes. One leaks the link in public. The other prices everyone out.

So we need both at once: prove "my commitment is one of the commitments in the pool" without saying which one, AND without forcing the pool to keep every entry. That is a strange shopping list.

A data structure that does exactly this has existed in computer science since 1979. It is called a Merkle tree.

Picture a library catalog. The catalog has a single short code at the top: a "root." The root summarizes every book in the library. To prove a book is in the library, you walk a short path of codes from the book up to the root. The librarian only needs that path. They never have to know which shelf the book is on.

// the merkle tree: one root for the whole poolstate_rootmaria's commitment// the teal path is what maria submits, log₂(16) = 4 sibling hashes// the verifier checks the path leads to the known root. that's it.

One leaf, one path, one root.

The path is what Maria submits. The pool's contract takes her path, walks it back up, and checks it ends at the known root. If it does, the commitment is in the tree. If it does not, the proof fails. The pool never learns which leaf is hers.

Three things this gets us.

Anonymity set. The bigger the tree, the more commitments Maria's proof could refer to. With 10,000 commitments, the proof points at "one of 10,000." With 100 million, "one of 100 million."

Constant proof size. Whether the tree has 100 commitments or 100 million, the path Maria submits is about the same size, log₂(N) sibling hashes. That keeps gas costs bounded.

One root, not every commitment. The pool only has to remember the current root, not every leaf. Old roots stay in a small history buffer for proofs in flight.

// the tree grows. so does maria's anonymity set.4 leaves// one in 416 leaves// one in 161024 leaves// one in 1024// our STARK tree holds up to 4.29 billion leaves (v4 spec: 8.59 billion).// the proof size barely grows.

The tree grows. The proof size barely changes.

A comparison aside. RAILGUN uses a tree that grows in depth as needed (LeanIMT). Aztec uses a different note-tree shape. We chose a fixed depth: 32 in our PLONK pool, and a wider but shallower 8-ary tree at depth 11 in our STARK pool. Why fixed, and why we recently changed the width, is post 6 of this series.

Going deeper: how the four objects fit together in one transaction

The rest of the post is for the reader who wants to spend an extra minute on the actual mechanics. Skip if you have the picture you need.

When Maria deposits 5,000 USDC into the pool, this is what happens:

  1. Her wallet picks a random blinding. The wallet has a derived ownerHash from her spending secret. The amount is 5,000 USDC. The token is the pool-supported USDC contract.
  2. The wallet computes commitment = Poseidon31(amount, ownerHash, blinding, msg.sender, token). The first slot of that output, commitment[0], is what gets written into the Merkle tree as a leaf. The full output stays in the encrypted note (more on encryption in post 7).
  3. The wallet sends a shieldSTARK transaction to the pool contract. The transaction carries the leaf, the encrypted note, and the USDC transfer authorization.
  4. The pool contract pulls 5,000 USDC from Maria's ERC-20 balance. The pool inserts commitment[0] into the Merkle tree. The new tree root replaces the old one in a small history buffer. The pool emits a StarkCommitmentInserted event.
  5. Maria's wallet picks up the event, decrypts the note, and stores it locally. From here on, the only way to spend the note is to prove ownership inside a zero-knowledge proof. That is post 2.

Why the tree leaf is just commitment[0] and not the full digest. A single-limb leaf halves the per-insert hash work on chain. The actual proof system already binds the full commitment in the circuit. Specifics in post 6.

What this means for you

When Maria shields her 5,000 USDC into the pool, three things happen.

Her wallet keeps a note that says "5,000 USDC, mine."

The pool keeps a sealed commitment with no way to recover what is inside.

The pool's Merkle root updates to include the new commitment.

Her competitor in Hamburg refreshes their block explorer. They see: 5,000 USDC moved into a pool contract. That is all. They do not know what Maria does next. She could withdraw to a different address. She could transfer the note privately inside the pool. She could split the note and pay her supplier directly.

One practical note here. In real use, the wallet Maria deposits from is rarely the same one her competitor has been watching for years. It is a fresh address, funded straight from an on-ramp or from a wallet she keeps separate from her business identity. So even the public side of the deposit is one step removed from her name. The pool then hides everything that happens after.

The pool is the layer that separates "money moved" from "this is the payment." Anyone watching the chain sees the first. Only Maria, and the people she chooses to tell, see the second.

What we did not cover.

How the pool checks Maria's claim without seeing it. That is the whole point of a zero-knowledge proof, and that is post 2 of this series.

How the pool decides Maria is allowed to be in the pool in the first place. That is compliance, and that is post 8.

How any of this is actually fast enough to run in a browser. That is most of posts 3 through 6.

For now, you know what a privacy pool is. Four objects: a note, a commitment, a nullifier, a tree. That is the whole shape.


Privacy Pool 101: eight short posts.

  01. What is a privacy pool?                   ← you are here
  02. How a ZK proof hides everything           coming soon
  03. Picking a ZK-friendly hash                coming soon
  04. The audit finding that made us redo it    coming soon
  05. Wrapping a STARK in a SNARK               coming soon
  06. Why our Merkle tree got eight times wider coming soon
  07. What you must encrypt                     coming soon
  08. Compliance without surveillance           coming soon

UPP is in tech-preview on Sepolia. Stay tuned for part 2.