manual PT-8086 · rev 4 · live on base and sepolia · arbitrum built, not switched on
How the machine works
This document covers what works and what does not. Anything unmeasured says so.
01The problem
An ordinary ERC-20 transfer writes three things to the chain forever: who sent, who received, how much. For payroll, for paying suppliers, or simply for not publishing your net worth to anyone who asks — that is a real problem.
PrivateToken8086 is a shielded pool: tokens sit together in one contract and ownership is recorded as cryptographic commitments. Moving value inside the pool emits no transfer at all.
02The note model
There is no balance table. Every amount is a note, like a paper bill — spending it consumes the whole thing and the remainder comes back as a new note.
Commitment
commitment = Poseidon(amount, pubkey, blinding, asset) — a hash stored in a 26-level Merkle tree on chain. It reveals neither the amount nor the owner.
Nullifier
nullifier = Poseidon(privkey, leafIndex, commitment) — published when a note is spent. The contract records it to prevent double-spending, but it cannot be traced back to the note it retired.
Encrypted notes
The sender encrypts the note payload to the recipient’s x25519 key (NaCl box with a single-use ephemeral key) and posts it on chain. Only the recipient can open it, and two notes sent to the same person cannot be linked to each other.
Tags
A note a wallet writes to itself — a deposit, the change from a withdrawal — also carries an eight byte tag, SHA256(tagKey, commitment). The tag key is derived from the same private key and never leaves the browser, so to anybody else the tag is eight random bytes, and two notes of one owner carry unrelated tags.
It exists so a wallet can find its own money without opening every note in the pool. See staying usable.
03Five steps
| step | what happens | what the chain shows |
|---|---|---|
| 1 · deposit | your wallet sends tokens to the pool | your wallet → pool, one fixed denomination |
| 2 · prove | the browser generates a Groth16 proof | nothing — it never left your machine |
| 3 · wait | the proof is parked until the note is buried and the step is deep enough | nothing — the queue lives off chain |
| 4 · withdraw | a relayer signs; the pool pays the destination | pool → destination, signed by the relayer |
| 5 · scan | the recipient finds their own note (if staying in the pool) | no transaction at all |
Step 3 is where most of the privacy is actually won, and it is the step every impatient design skips — see burial and delay.
Step 5 is the sharpest difference from an ordinary token: the recipient signs nothing. They read the chain and try to decrypt.
04Architecture
| layer | technology | responsibility |
|---|---|---|
| circuit | Circom 2.2.3 · Groth16 / bn254 | holds the money rules: value conservation, membership, nullifier binding |
| contract | Solidity 0.8.24 · Foundry | verify proof, record nullifiers, insert into tree, move ERC-20 |
| indexer | Node · ethers v6 | rebuild the tree from events, serve Merkle paths the client verifies |
| relayer | Node · ethers v6 | sign withdrawals so the user's wallet never appears |
| sdk | circomlibjs · tweetnacl | keys, note encryption, tree — shared by web and server |
| client | snarkjs (wasm) | generate proofs IN the browser |
Five layers, one root
The Merkle tree is built in the circuit, the contract, the indexer, the test harness and the browser. One bit of divergence and every proof fails, for reasons that are painful to find. All five are byte-exact, checked by automated tests on anvil and on Sepolia.
Why proving happens on your machine
Proving needs your private key and the amount. Moving that to a server means handing the server your secrets — at which point the zero-knowledge part is theatre. The client downloads transfer.wasm and transfer_final.zkey and runs it locally.
05Fixed denominations
Deposit and withdrawal amounts are public — they have to be, the contract calls a real transfer. Allow arbitrary amounts and a pool with a thousand users still falls to one query: whoever deposited 137.42 is whoever withdrew 137.42.
So deposits and withdrawals are snapped to a fixed ladder. Nobody chooses that ladder, not even the deployer: registerToken(token) reads decimals() and derives 1 / 10 / 100 / … / 100,000,000. Registered once, never editable, never removable. Since there is nothing to abuse, the function is permissionless.
Transfers inside the pool take any amount — that number is already hidden.
the trade-off
Each denomination is its own crowd. Someone depositing 100 cannot hide among people depositing 1,000,000. A long ladder exists to make real amounts usable, not to make you more private.
06Burial and delay
Fixed denominations make every deposit look the same. They do nothing about when. Deposit at 10:02 and withdraw at 10:05 and an observer does not need to break anything: yours is the only deposit young enough to be the one that just left.
That is not theory. On a live shielded pool holding close to a million dollars, a deposit and a withdrawal two hours apart still narrowed to a single candidate, because the crowd that mattered was not everyone who ever used the pool — it was everyone who moved in the same window.
Burial, counted in deposits
A note cannot be withdrawn until BURY_UNDER further deposits have landed on top of it. The Base pool is deployed with BURY_UNDER = 3, fixed at deployment and not changeable afterwards. The count is in deposits, not seconds, because seconds are what an observer reads off the chain and deposits are what actually add candidates.
A quiet pool must not turn into a trap, so the rule expires: BURY_TIMEOUT = 24 hours. After a day the note is spendable however few deposits arrived. The pool delays money; it never keeps it.
The proof is built against the oldest Merkle root that contains the note, not the newest. Proving against the current root would publish how recently the note was added, which is the very fact burial exists to hide.
The queue, and why it batches
A finished proof is handed to the relayer and parked. It is released when the step it spends holds SAFE_DEPTH = 10 deposits that belong to other people — the sender’s own deposits are subtracted, because a crowd made of one person is not a crowd. Withdrawals then go out in groups rather than one at a time, so a single transaction timestamp does not identify a single sender.
Once released, a withdrawal waits up to 60 seconds for others to share a transaction with, and goes alone if nobody comes. That clock runs from the moment you press send, not from the moment burial finishes, so the two waits overlap instead of stacking.
How long it actually takes
Measured on Base on 30 July 2026, one 1-USDC send, start to money at the destination: 92 seconds. The breakdown below was measured separately against a local chain with Base’s block time and the same settings, because the total on its own does not say where the time goes.
| what | seconds |
|---|---|
| burial — three deposits supplied and mined | 8 |
| waiting for company to share a transaction | 60 |
| waiting for the next sweep of the queue | 10 |
| mining and confirmation | 2 |
So roughly two thirds of the wait is the deliberate one. Three sends arriving together skip it entirely: on Sepolia the same evening, three withdrawals posted within a minute of each other went out in one transaction after 63, 10 and 6 seconds — the batch filled, so it left immediately.
the honest version
Burial removes the cheapest attack — match the newest deposit to the newest withdrawal — and it does not manufacture people who are not there. On a pool this young the group sharing your transaction is usually small, and some of the deposits burying your note are the project’s own keeper rather than strangers. Those are real deposits at the real step, but they are one entity, so they buy you the clock and not the crowd.
07The relayer
Someone has to pay gas for a withdrawal. If the user signs it, their wallet appears right beside the withdrawal and pairs up with the deposit. A relayer signs instead, and because one relayer serves everyone, the From field says nothing.
The relayer is not trusted with money
The destination address is bound into the proof through extDataHash — change it and the contract reverts. It never sees keys, balances, or anyone’s notes. All it can do is refuse service or delay.
What it does see: your IP, and the (recipient, amount) pair of each withdrawal. A relayer is a central point — several independent ones beat a single one.
If the relayer is down the app falls back to you signing, and says on screen that you are now exposed.
It also holds the queue described in burial and delay: proofs wait there until the step is deep enough, then go out in batches rather than one by one. On Arbitrum this role belongs to Railgun’s public broadcaster network instead, reached over Waku — several independent operators rather than our single one, which is one more thing renting their pool buys.
08Hidden vs visible
Hidden
- Transfer contents inside the pool — no addresses, no amounts, no ERC-20 transfer
- Ownership of each note — only the keyholder can open it
- Balances — there is no balanceOf, the client computes its own
- The link between two notes sent to the same person
- The sender's wallet does not sign the withdrawal (with the relayer on)
Still visible
- That you used this pool — transactions to the pool address are public
- The depositing wallet and the receiving wallet
- Timing — a deposit and a withdrawal minutes apart are linkable
- A thin pool falls to statistics, not to broken cryptography
What real anonymity requires
- A crowd. A pool of one is transparent no matter the maths. This cannot be coded.
- Delay. Withdraw days later, not minutes later.
- Fixed denominations. Done.
- A relayer. Done.
Cryptography is the necessary half. Those four are the sufficient half.
09What an observer actually sees
The most common misunderstanding: people expect the destination address to be hidden. It is not, and it cannot be. To pay a normal wallet the pool has to call a real ERC-20 transfer, and a transfer names the address it pays. Any system that moves money to an ordinary address publishes that address.
Both ends are always public. The link between them is what is hidden.
A real withdrawal, decoded
This is an actual transaction from the Base pool, with real USDC. Anyone can read it: 0x19b8dbcb…6455e55, block 49,266,114.
| field | value |
|---|---|
| signed by | the relayer, not the sender |
| called | the pool contract |
| transfer 1 | pool → 0xb74dc436…4dc9e3 · 0.97 USDC |
| transfer 2 | pool → relayer · 0.01 USDC out of the fee |
| gas used | 1,389,468 |
An observer learns that some address received 0.97 USDC out of the pool. What they cannot learn is which deposit paid for it. The sender’s wallet appears nowhere in this transaction, and nothing in the pool’s events points back at it.
Hiding the recipient too
There is exactly one way: the recipient never withdraws. They hold the money as a note inside the pool, addressed to their pt1… pool address, and spend it to other pool addresses. No transfer is emitted, so no address is published. The moment they cash out to an ordinary wallet, that wallet becomes visible.
true of every shielded pool
Every shielded pool shares the same boundary. Inside the shielded set nothing is visible. Crossing the edge is visible by construction. A tool that claims otherwise is either lying or is not settling on a public chain.
10Where it runs
Both mainnets are usable today, with Base as the default, plus Sepolia for testing. Every value below was read from the chain itself — including the shared pool’s fees, which come from calling shieldFee() and unshieldFee() on its contract rather than from any documentation. Balances were read on 30 July 2026 and move on their own.
| Base | Arbitrum | Sepolia | |
|---|---|---|---|
| chain id | 8453 | 42161 | 11155111 |
| asset | USDC (Circle) | USDC (Circle) | mUSDC (test) |
| pool | ours, v2 · 0x2fcD5326…a1Bd5b ↗ | Railgun’s · 0xFA7093CD…1FA4b9 ↗ | ours, v2 · 0xA14D2e14…816c44 ↗ |
| what you pay | 3% total | 3% total | 3% total |
| of which, to the pool | none — we are the pool | 0.25% in + 0.25% out, taken out of the 3% | none |
| fixed denominations | yes · 1 → 100,000,000 | no — Railgun takes any amount | yes |
| burial before payout | 3 deposits, waived after 24h | none — their design | 3 deposits, waived after 24h |
| undoing your own deposit | free, no wait, batched | not a concept there | free, no wait, batched |
| who signs the withdrawal | our relayer | Railgun's broadcaster network | our relayer |
| held in the pool | 13.46 USDC across 13 notes at the 1 step | 995,264 USDC | test money |
| status | live on v2 since 30 July 2026 | BUILT, NOT SWITCHED ON — off the picker | live, for practice |
The pool on Base was replaced twice on 30 July 2026 — once to fix four defects that had no proxy to fix them through, and once to add refundBatch, so undoing ten unused deposits is one wallet confirmation instead of ten. Both older pools stay reachable. The first of them, 0x386eB13D…ddb00A, is retired and still holds notes that are not ours to move: fee shares there never fell when money left, undoing an unused deposit cost the full rate, and a full tree would have frozen every function including withdrawals. It stays listed in the footer and the app keeps a withdraw-only panel pointed at it, because there is no proxy here and nobody but a note’s owner can ever spend it — taking the address off the screen would be the same as taking the money.
The two designs answer the same problem from opposite ends. Our own pool on Base keeps the fee, sets the rules — the denomination ladder and burial are ours to enforce — and has to grow its crowd one deposit at a time. The shared pool on Arbitrum has the crowd already, charges for it, and gives us no say in how it behaves. ONLY THE FIRST OF THOSE IS SOMETHING YOU CAN USE TODAY: the Arbitrum code is written and works, but the network is filtered off the send page, so everything said about it here describes what is built rather than what is offered.
what each one is actually good for
Base is the only mainnet on offer right now, and it is where the design is fully ours — fixed steps, burial, half the fee paid back to the people providing the depth. The crowd is small: read the pool figure above before you send, and treat anything you would be upset to see traced as not yet suited to it. Arbitrum would be the answer for a large amount — a withdrawal there stands among a million dollars of other people’s money from the first transaction — but it is not switched on, so today that answer is theoretical.
11Staying usable as the pool fills
Every transaction writes two leaves into the tree, and the tree only ever grows: a commitment cannot be removed without invalidating every proof already built against it. So the honest question is not how to shrink it but what a client has to do per note, and the answer used to be: rebuild the whole tree, then try to open every note in it.
| per note, measured | before | now |
|---|---|---|
| insert into the tree | 1.294 ms | 0.051 ms in batches, or none at all |
| find out if it is yours | 0.339 ms | one SHA-256, if you wrote it |
| a pool of a million notes | about 27 minutes | seconds |
The tree, borrowed and checked
The client no longer builds it. It asks an indexer for the Merkle path of one leaf, hashes that path back up to a root, and asks the POOL whether it ever published that root — isKnownRoot. A forged path lands on a root the contract never knew and is thrown away. So the indexer is a convenience that cannot lie, and when it is missing or wrong the client builds the tree itself as before.
It asks for the oldest root containing the note, because the pool reads a root’s age as the note’s age, and roots here never expire.
Your own notes, found by tag
Opening a note costs an x25519 shared secret, and 99.5% of scanning is exactly that. No view tag fixes it, because computing a view tag needs the same shared secret. What does fix it: nearly every note a wallet must find was written by that wallet, so no key exchange is needed at all — the tag in the note model is checked with one hash.
A note somebody else sent you carries no tag you could compute, so those are still opened one by one. That set is small: money leaving this pool arrives in an ordinary wallet, not as a note.
measured in a browser, not in theory
4,001 notes, 41 of them the wallet’s own: the tag pass found 40 in 78 ms and the deep pass found the remaining one in 1 ms. The old full scan found the same 41 in 3,214 ms. Forty times faster, and the same money.
What is left
The tree cap is 226 leaves, 67 million, or 33.5 million transactions. Reaching it does not mean a new pool: Railgun’s contract answers a full tree by opening the next one in the same contract, with notes keyed by tree number, and that is the change to make here before it matters. Nothing about it moves anybody’s money.
12Measurements
Measured on real hardware, on Sepolia and on Base. There are no estimates in this table; the Base figures are receipts read back from chain.
| item | value |
|---|---|
| constraints (total) | 32,301 |
| public inputs | 8 |
| proof generation (Node CLI) | 1.14 s |
| proof generation (browser) | not measured yet |
| gas to verify a proof | 235,455 |
| tree insert, one leaf at a time | 1.294 ms |
| tree insert, in batches | 0.051 ms |
| restore a saved tree | 1.5 s per million leaves |
| scan a note by tag | 0.007 ms |
| scan a note by opening it | 0.360 ms |
| gas per transaction (Sepolia) | 1,340,656 – 1,390,268 |
| gas per withdrawal (Base, real) | 1,389,468 and 1,433,744 |
| cost of that withdrawal (Base) | 0.0000083 ETH · 0.006 gwei L2 + 5.1 gwei of L1 data |
| gas to deploy the pool | 3,656,193 |
Verification is only 235k of that. The bulk is 26 on-chain Poseidon hashes to insert into the tree — which is why a cheap chain matters more than a smaller circuit.
13Known limits
live on Base · unaudited · one-contributor setup
The phase-2 trusted setup currently has a single contributor. Whoever holds that file can forge proofs and drain the pool, and right now that is whoever ran the ceremony. The deploy script prints this warning and then deploys anyway — it does not refuse. Funding a pool you run yourself is one thing; inviting strangers in before the ceremony is redone with independent participants is another.
- The crowd on Base is small. Read on 30 July 2026: the pool held 13.46 USDC across 13 notes at the 1-USDC step, 94 leaves in the tree, and the 10-USDC step open with nobody in it. Every step above that is shut. Burial fixes the timing attack; it does not conjure up depositors, and no shielded pool can hide you in a crowd this size. Treat anything you would be upset to see traced as not yet suited to this pool.
- Amounts still match. A withdrawal pays out 97% of a step, so what a destination receives is 0.97 × what somebody deposited. The app used to pad deposits to a standard count to break that equation and no longer does, because debiting more than the sender asked for reads as theft. Split a send across days, or over more than one destination, if that ratio matters to you.
- Our own keeper supplies some of the deposits that bury a customer's note on a quiet pool, out of the project's own wallets. Those are real deposits at the real step, but they are ours, so they are not the independent crowd the depth figure implies.
- One relayer, run by us. It cannot steal or redirect — the destination is bound into the proof — but it sees every recipient and every IP, and it can refuse or stall. No rate limiting, no randomised delay yet.
- Arbitrum is written but switched off. Everything this manual says about it describes code that runs, not a network the send page will open — it is filtered out of the picker. When it is switched on, our 3% there is a plain ERC-20 transfer from your wallet to our fee wallet before the money is shielded: proportional, so an observer who spots it can work out roughly how much you shielded, though it does not reveal the recipient. Our own pool on Base takes the fee inside the shielded transaction instead, where nothing is visible.
- Arbitrum has no fixed denominations, no burial and no queue, because none of that is ours to add — it is Railgun's pool and Railgun's rules. It buys crowd, not control.
- The Sepolia pool is an older build with no burial in it. Use it to learn the flow, not to judge the privacy.
- Six-decimal stablecoins only in our own pool: USDC on Base. ETH and WETH do not fit, because the circuit caps amounts below 2^64 and with 18 decimals the smallest denomination would be a whole ETH.
- ERC-8086 is still a Draft — the interface can change.
- No viewing keys, so no selective disclosure. That is the standard's choice, and a genuine regulatory obstacle.
- A malformed proof burns a lot of gas: the verifier correctly returns false, but the failing bn254 precompile consumes the forwarded gas. Not fully investigated.
14The contracts
Four contracts on chain, and only the first one holds money. There is no proxy and no admin key that can change any of them: what is deployed is what runs, and replacing a pool means deploying a new one and leaving the old one reachable.
| contract | on Base | what it does |
|---|---|---|
| PrivacyTokenV2 | 0x2fcD5326…a1Bd5b ↗ | the pool. Holds every deposit, verifies proofs, records nullifiers, inserts leaves, enforces the ladder and burial, and pays out. 1,583 lines. |
| Groth16Verifier | 0x1d4C60Fb…091C4E ↗ | generated by snarkJS from the circuit, never hand written. Checks a proof against the verifying key and returns true or false, nothing else. |
| PoseidonT3 | 0x5343a2e9…7f6e72 ↗ | the hash, deployed from circomlibjs bytecode. Used for commitments, nullifiers and every node of the tree, so the contract and the circuit agree bit for bit. |
| IZRC20 | interface only | the ERC-8086 interface the pool implements. Nothing is deployed for it; it exists so other tools can talk to the pool without reading its source. |
Every address above links to it on Basescan. The retired pools are listed in where it runs, and the app keeps a withdraw-only panel pointed at each of them.
What the pool contract actually enforces
- The denomination ladder, derived from decimals() at registerToken and never editable afterwards, by anybody, including the deployer.
- Burial. BURY_UNDER = 3 deposits on top of a note before it may leave, waived after BURY_TIMEOUT = 24 hours.
- The step ladder opening in order. UNLOCK_AFTER = 10 deposits at a step before the one above it opens, and it closes again when depth falls.
- One nullifier, once. A note cannot be spent twice and the contract cannot be talked out of that.
- The fee split. Half of every 3% is credited to depth holders through an accumulator at the moment a withdrawal lands, so depositing afterwards earns nothing from it.
- Refunds. An unused deposit can be taken back for REFUND_WINDOW = 7 days, free, and refundBatch does ten of them in one transaction.
- Fee shares expire after SHARE_WINDOW = 30 days, so an abandoned position stops diluting the people still supplying depth.
no upgrade path, by design
None of the four can be upgraded, paused or drained by us. That is the reason the Base pool has been deployed three times rather than patched: every fix is a new address, the old one keeps working for whoever still has notes in it, and the app keeps a withdraw-only panel pointed at each of them.
15Using it
Two networks, the same contract and the same rules on both. Sepolia first is not a formality: the waits are real there, so it is where to find out what the screen says while a transfer is happening, before any of it costs money.
What you need either way
- A browser wallet. The proof is generated in the page, so a wallet that only signs is enough; nothing is installed.
- A signature to derive your pool key. It is asked for once per session, never leaves the tab, and is what lets the wallet find its own notes.
- Native gas on the network for the deposit only. The withdrawal is signed and paid for by the relayer, so the destination needs nothing at all.
On testnet, Sepolia
| step | what to do | what to expect |
|---|---|---|
| 1 | open the testnet page and connect | the wallet switches to Sepolia, or offers to add it |
| 2 | press the faucet for mUSDC, and get Sepolia ETH from any public faucet | mUSDC is ours and free; the ETH is not ours and is the one thing we cannot hand you |
| 3 | enter a destination and an amount | the step is chosen for you, and the route line says how it splits |
| 4 | approve, then send | one confirmation for the approval, one for the deposit, and the relayer does the rest |
| 5 | leave the page or watch it | the panel names the state, and the transaction link appears when it lands |
The destination is an ordinary wallet address. It receives ordinary mUSDC and needs no key, no signature and no visit to this site.
On mainnet, Base
Identical, with real USDC and real gas. Two things are worth doing differently.
- Send a small amount first. Not because the contract is different from testnet, but because it is your money and the first run of anything deserves to be cheap.
- Read the pool figures on the front page before a transfer you care about. A step with almost nobody in it hides almost nobody, and no part of the design pretends otherwise.
if it looks stuck
A transfer that has not moved is almost always waiting for burial or for company, and the panel says which. Nothing is lost while it waits: the notes are yours, and take it back returns them to your wallet immediately, free, however thin the step is. That button exists precisely so that waiting is never the same as being trapped.
Undoing deposits you never used is free and batched, so ten of them cost one confirmation. Withdrawing money that has already been through a transfer is a normal withdrawal and pays the normal fee.