Shielded pools are one way to make a private payment on a public blockchain. Two earlier posts on this blog built one, first for private bonds, then for private payments. The primitive works. What comes next is a run of bigger iterations meant to make it both more resilient and more scalable: hard for any single party to watch, censor, or quietly take control of, and fast enough to carry payments at Visa scale. The trick is getting there without giving up the four things that make shielding worth having in the first place: censorship resistance, openness, privacy, and security.

A shielded pool has a few moving parts, and each one makes that hard in its own way. Through a wallet, it writes and reads the Ethereum contract state. And it generates, on the user’s own device, the zero-knowledge proof that makes each spend valid, which is also where the post-quantum question bites. We will walk the open problems, then go deep on the two our prototype takes on.

The open problems

Shielded pools already settle billions of dollars in transaction volume. The open question is whether they can carry private payments at scale: fast, cheap, and decentralized. Four problems stand in the way.

  1. On-chain state never stops growing. Every spend writes a nullifier to Ethereum, a short value that marks a note as spent so it can never be spent twice, and it has to stay there permanently, because the whole point is to remember it forever. At payment volumes, say 150 million transactions a day, that is around 5 gigabytes a day in nullifiers alone, with no end date. Bowe and Miers work through the arithmetic, and the conclusion is plain: storing that much data on Ethereum forever, and asking everyone who validates a spend to keep consulting it, does not hold up. Epoch-based nullifiers are the most promising way out, though the detailed construction does not exist yet: the research so far points a direction more than it specifies a finished design.

  2. Censorship resistance has two ways to go wrong: paying for a spend, and getting it included. If you pay gas from your own address, you have tied yourself to your private spend, so today a relayer pays and submits for you. That works, but it makes the relayer a chokepoint that can stall, refuse, or be compelled to, and even past the relayer, whoever builds the block can simply leave your transaction out. Two proposed changes to Ethereum address both ends. Frame transactions with keyed two-dimensional nonces let a user pay and submit directly while keeping the gas payer separate from the spender. The FOCIL mechanism forces blocks to include eligible transactions, so a private spend cannot be quietly censored. (EIP-8141, EIP-8250, EIP-7805.)

  3. The wallet carries more than you would think, and users feel it. Open an app you have not touched in a few weeks and it has to pull every recent note and try to decrypt each one before it can show a balance, because nothing on the outside says which notes are yours. On a busy pool that is minutes of waiting. And each time the wallet reads from the chain to build a spend, it does so through some provider that sees your address and your requests.

  4. The proof itself is not post-quantum. Many shielded pools prove spends with Groth16, whose proofs are small and cheap for Ethereum to verify but vulnerable to a large quantum computer, which would compromise the soundness of every spend. The fix is a post-quantum proof system. These tend to be heavier, and that weight lands on the user’s device, the same one already doing all that scanning.

No design solves all of these at once. Our prototype tries to solve two: the on-chain state, and the private read.

What our prototype takes on

We built an extension on top of the working pool from the last post, keeping its note format and flows intact, and pointed it at two of those problems: keeping the on-chain state bounded, and letting a wallet read what it needs without anyone watching. The full specification and implementation are open.

The rest of this post follows a spend through both, starting with the read.

Reading without being watched

To spend a note, the wallet has to prove the note sits in the commitment tree, the running list of every note ever created. It already knows where its note sits; that index never changes. What it lacks is the note’s current authentication path, the sibling hashes from the note up to the root, and that path shifts every time someone else spends and changes the tree. So before each spend the wallet has to fetch the latest path. The proof built from it is airtight; the fetch is not. The wallet asks a provider that runs a node, and that request leaks twice: your IP tells the provider you use the pool at all, and what you ask for narrows down which note you are about to spend. The best on-chain privacy can be undone by how the wallet reads around it.

What we want is a way to read one row of a database without the operator learning which row. That exists.

PIR, in plain terms

It is called Private Information Retrieval, PIR. The database is a table of rows. You encrypt the index you want, the server does math over the entire table with your encrypted index, and you get back an encrypted answer only you can open. The server cannot tell row 5 from row 5,000. The everyday version is asking a librarian for a book without the librarian learning which one you read, except here the privacy is cryptographic.

PIR has a hard limitation, and it shapes the rest of this post: it answers exactly one kind of question, give me row i, where you already know the index i. It fetches; it does not search.

Fetching a note’s path blindly

A known-index fetch is exactly what the wallet needs here. When you received a note, you learned its position in the commitment tree from the payment that created it, so you know the index. To read its authentication path privately, we lay the tree out as a flat array, following tree-pir, and the wallet sends a small batch of PIR queries, one per sibling on the path to the root, about log(N) of them for a tree of N notes. The server returns them and learns nothing about which leaf they belong to.

The logic is roughly:

// You know your note's leaf index; the path (its siblings) is what you fetch.
siblings = []
for i in siblingIndicesOnPath(leafIndex):   // ~log(N) positions in the flat tree array
    siblings.push(pirQuery(server, i))       // each fetch hides which index you asked for
// siblings is now the authentication path, retrieved without revealing leafIndex

What it costs

None of this is free. PIR makes the server work over the whole database on every query, and schemes differ in how much precomputation each side keeps. The specification targets a recent scheme, InsPIRe, whose appeal is that it needs no per-database hint on the client: no chunk of client-side data, precomputed from the public database, that the wallet has to hold before queries can start. Our prototype ships an older, simpler one, SimplePIR, which gives the same privacy but does keep such a hint on the client. That hint is not a secret, so losing your phone loses nothing sensitive: you recompute or re-download it, and it has to be refreshed as the database grows. For real figures, the PSE writeup on scaling Semaphore with PIR and the tree-pir benchmarks are the places to look. PIR is practical enough to build with today, and getting cheaper.

Reducing trust in the server

So how much trust does this server need? Less than it first appears. On-chain, the contract keeps only a Merkle root, a single short fingerprint of every note. The server rebuilds the full tree from Ethereum’s public event log and serves you the path you ask for. It cannot hand you a fake path: you fetch the current root separately, recompute a root from the path the server returned, and check that the two match. A forged path will not hash to the right root. In the target design the wallet gets that trusted root from a consensus light client, which verifies it against Ethereum rather than trusting the server. The prototype implements the storage-proof verifier behind a RootVerifier port and leaves wiring up a full consensus light client as production integration work; in its place, the in-process end-to-end test trusts a local development stateRoot.

// The wallet trusts a root from consensus, not from the server.
root    = lightClient.verifyStorageSlot(pool, ROOT_SLOT)  // eth_getProof, checked against a finalized header
rebuilt = root_from_path(siblings_from_server)             // recompute the root from the returned siblings
assert rebuilt == root                                     // a lying server fails right here

A dishonest server can refuse to answer, but it cannot forge your data, and since the data is public, anyone can run their own. Availability could be solved by decentralizing it, not by trusting any one operator.

The light client closes the correctness gap, not the privacy one. It proves the data is right. It does nothing to hide that you asked this provider for this contract’s state in the first place, which is the same metadata leak we started with. Closing that needs cover at the network layer, a mixnet or Tor between the wallet and the provider. The Kohaku wallet SDK for shielding protocols routes its RPC over Tor for exactly this reason, and the Ethereum Foundation’s privreads effort studies private reads as a general capability.

Keeping the ledger from growing forever

Now the write side, and the second problem we took on: keeping that permanent nullifier record from growing without bound.

Closing the book each epoch

Think of accounting periods. Instead of one ledger that grows forever, you close the books at the end of each epoch, a month, say, and open a fresh one. The sealed book leaves only its Merkle root on-chain. The change is in the nullifier itself, which now folds in the current epoch:

nullifier = hash(commitment, spending_key, epoch)

So the same note produces a different nullifier each epoch, and only the current epoch’s set is live on-chain. The price for the tidiness: “not spent” now has to hold against every book the note has lived through, from the epoch it was created in to the present. Spend a two-year-old note and, done naively, you owe two years of monthly checks.

Proving a clean history in one shot

This is where the construction earns its keep. Instead of redoing all those checks at spend time, the wallet keeps a small running proof for each note and extends it by one step whenever an epoch closes. The technique is incrementally verifiable computation, IVC: each new proof verifies the previous one inside itself, so a single proof attests to a note’s clean history across every epoch so far. When you spend, the spend proof checks that one chain proof once, and the cost does not grow with the age of the note. To stop anyone gaming the edges, the note’s commitment binds the epoch it was created in, so the verifier can insist the chain covers the note’s whole life.

Proving absence, and the catch it creates

A nullifier tree has to answer a harder question than the commitment tree. The commitment tree only asks “is this note present,” and an ordinary append-only tree handles that. A nullifier tree has to prove a value is absent, and absence is the harder direction. The naive fix, a sparse tree with a slot for every possible value, is astronomically deep and forces a huge number of hashes per proof, which hurts inside a zero-knowledge circuit. The cheaper answer, and the one we use, is an indexed Merkle tree: leaves are kept sorted, each pointing to the next-larger one, and to prove a value is absent you point at the single leaf that should sit just below it and show the gap. The tree is only as deep as the number of values actually stored. Aztec’s documentation covers it well.

But sorting breaks the clean PIR story. To prove your nullifier is absent, you first have to find that leaf just below it, its neighbor, and you do not know where it sits. Nobody told you its index, and finding it is not a “give me row i” question. For now our prototype steps around this for sealed epochs, where the values a wallet checks never appeared on-chain, by asking the server for them in the clear and accepting the metadata leak. Closing it properly needs a different primitive, and that is the hinge of the whole thing.

The hinge: finding a leaf, finding a note

Look at the shape of what we just hit. PIR fetches a row when you already know its index. Finding that neighbor leaf is a different kind of request: you are asking the server to find, among everything it holds, the one value sitting just below yours, without telling it your value. That is a search, so here PIR is necessary but not enough.

Now recall the other read-side problem, the cold wallet that takes minutes to find your money. It takes minutes because the wallet pulls every recent note and tries to decrypt each one, since nothing on the outside marks which are yours. You could ask the server to filter, but the thing that picks out your notes, your key, is exactly what you cannot hand over.

Both are the same problem. In each, a server holds a big set, you need the few elements that match a private criterion, and revealing the criterion is the whole risk. That is private selection, and it is strictly harder than PIR’s known-index fetch. The two are not identical: finding a neighbor is an ordering question over public values, finding your notes is an ownership question over ciphertexts. Same family, different test. No single primitive drops cleanly into both, which is why our prototype punts on the neighbor problem, and why every team building private payments at scale ends up reaching for the same short list of primitives.

The primitives, and who’s building with them

PIR reads a known row without revealing which one. That is the part our prototype uses, for the commitment path. Oblivious Message Retrieval (OMR) hands you the messages addressed to you without the server learning which are yours, aimed straight at note discovery. Fuzzy Message Detection (FMD) is the lighter cousin: it flags your notes plus a tunable share of decoys, so you trial-decrypt a smaller, blurred set, and Penumbra runs it in production.

Zcash’s Tachyon takes a different route. Rather than layering PIR or OMR on top of existing keys, it redesigns the key hierarchy entirely: viewing keys are gone, replaced by granular delegation keys. You hand the sync service a bounded, per-note key covering only a specific epoch window, enough for it to derive and scan that note’s nullifiers and prove non-spend, without learning the note’s value, its commitment, or that two delegated notes belong to the same wallet. The evolving-nullifier idea underneath comes from Bowe and Miers, the same paper our epoch nullifiers build on, and by keeping the wallet’s payment protocol off-chain Tachyon picks up post-quantum privacy almost as a side effect. Aztec and Miden start lighter, with note tags that narrow what a wallet has to fetch, and weigh the heavier options from there.

What the PoC settles, and what it doesn’t

Four problems stand between a working shielded pool and private payments at scale. The prototype settles two of them. Epoch nullifiers bound the active nullifier state, so the set every validator has to consult stays a fixed size instead of growing without end. PIR lets a wallet read a known row, its note’s commitment path, without revealing which row to the provider. What it does not settle is the harder problem the work surfaced. Finding the neighbor leaf in a nullifier tree and finding your notes in the crowd are both private selection, and neither reduces to a known-index fetch.

This was a proof-of-concept, built to make that gap concrete. The specification and code are open, the earlier posts build the pool it extends, and the IPTF map carries the use case and approach for context.

The work splits across two fronts. Ethereum’s roadmap is taking on censorship resistance, gas abstraction, and scaling at the protocol level. Cryptographic research is closing the rest, and private selection is the hardest piece left.