How it works

Privé is a shielded pool for USDG on Robinhood Chain. You deposit into the pool and keep a secret note in your browser. Later you prove, in zero knowledge, that you own an unspent note inside the pool, and the pool pays out USDG to any address you name. The deposit and the withdrawal are two separate on-chain events with no cryptographic link between them. This page walks the whole flow, grounded in the contracts, the circuit and the SDK that are actually deployed on testnet.

The pieces

ComponentAddress on chain 46630Role
PrivacyPool0xd62C837a70b9552637C8ee1681fb09b459648181Holds USDG, the commitment tree and the spent-nullifier set.
WithdrawVerifier0xBAFE6434C40e5582b11bA90cFd6cA1f5ebd904BFGroth16 verifier for the withdraw circuit.
MockUSDG0xF47593cac046C3a4C15B495eDAd59DE5868B6BbBTest USDG, 6 decimals, open mint. Testnet faucet token only.

The pool is bound to a SCOPE, a constant set at deployment (0x160e01af87652d4bbe04f0c762e1aa4416be83fc6ab15d0c21457c703b6b3474). It is mixed into every deposit label and into every proof context, so a proof made for this pool cannot be replayed against another one.

1. Deposit Live

Before touching the chain, the app generates a note locally: two random field elements, nullifier and secret, plus the value you want to shield. It hashes them into a precommitment, Poseidon(nullifier, secret). That single number is the only part of the note the chain ever sees.

You then approve the pool to move your USDG and call deposit(value, precommitment). Inside the transaction the pool:

  1. assigns a label, derived from keccak256(SCOPE, ++nonce) reduced into the BN254 scalar field, and records who deposited it in depositors[label];
  2. computes the leaf, commitment = Poseidon(value, label, precommitment);
  3. inserts that leaf into a LeanIMT (a lean incremental Merkle tree hashed with Poseidon), emits LeafInserted(leaf, leafIndex, root), and stores the new root in a 64-entry rolling history;
  4. pulls the USDG in with transferFrom.

The label only exists after the transaction lands, so the SDK reads it back from the Deposited event and calls finalizeNote to attach it. Only then is the note complete and spendable.

import { createNote, notePrecommitment, finalizeNote, PoolClient } from "@pp/sdk";

const note = createNote(25_000_000n);            // 25 USDG, 6 decimals
await pool.approve(note.value);
const { label } = await pool.deposit(note.value, notePrecommitment(note));
const funded = finalizeNote(note, label);        // now has label + commitment

2. The note Live

A note is five numbers: nullifier, secret, value, label and the derived commitment. It lives in your browser. It is never sent to a Privé server, because there is no Privé server that holds balances. The pool cannot reconstruct it, and neither can we.

If you lose the note, you lose the funds. There is no recovery path, no support ticket, no admin key. The pool only pays out against a valid proof, and only the note can produce one.

That is why the app ships an encrypted backup. encryptNotes serializes your notes to JSON, derives a key from your passphrase with PBKDF2 (SHA-256, 200,000 iterations) and encrypts it with AES-GCM 256. The output is a versioned envelope holding only salt, iv and ct. Store it anywhere. Without the passphrase it is useless, and a wrong passphrase fails the authentication tag rather than returning garbage.

3. Withdraw Live

To spend, your browser rebuilds the pool’s Merkle tree by replaying every LeafInserted event from the deployment block, then produces an inclusion proof for your commitment: the sibling path (padded to depth 32), the leaf index, the root and the actual depth.

snarkjs then runs the withdraw circuit locally with groth16.fullProve. The circuit recomputes your commitment from the private inputs, proves it sits under the claimed stateRoot, reveals nullifierHash = Poseidon(nullifier), and range-checks the amounts. Proving happens entirely in the browser. The nullifier and the secret never leave the tab.

The proof carries six public signals, in this order:

IndexSignalMeaning
0newCommitmentHashThe change note, inserted as a fresh leaf.
1existingNullifierHashMarked spent. Prevents double spends.
2withdrawnValueAmount leaving the pool.
3stateRootMust be in the 64-root history.
4stateTreeDepthDepth of the tree at proving time.
5contextBinds the payout terms to this proof.

PrivacyPool.withdraw rejects a zero-value withdrawal, recomputes the expected context and compares it to signal 5, rejects a zero recipient, rejects a fee that is not strictly less than the withdrawn value, checks the root is known, and only then calls the verifier. If the proof passes, the pool marks the nullifier spent, inserts the change commitment, and transfers USDG. The recipient can be a brand-new address that has never touched the pool.

4. Partial withdrawals Live

A note is not all-or-nothing. The circuit computes remainingValue = existingValue - withdrawnValue, range-checks both to 128 bits so nothing can wrap around, and builds a second commitment for the remainder using the same label and a fresh nullifier. The circuit explicitly asserts that the new nullifier differs from the old one. That change commitment is what gets inserted on-chain as signal 0.

So spending 10 USDG out of a 25 USDG note burns the old note and hands you a new 15 USDG note. The circuit is single-note-in and single-change-out, so one payment can spend at most one note. The SDK’s selectSpendNote picks the smallest note that covers the amount, and spendableMax tells you the largest single payment you can make. A full withdrawal is simply the case where the change note is worth zero.

5. Claim links Live

A claim link is a bearer instrument. You finalize a note and encode it into a URL fragment:

https://privepay.app/claim#v=1&c=46630&p=<pool>&n=<nullifier>&s=<secret>&a=<value>&l=<label>

Everything after the # is a fragment. Browsers never put a fragment in the HTTP request, so the secret is not sent to the web server, is not written into access logs, and is not visible to the host. Anyone who has the link can spend the note, so treat it exactly like cash and send it over a channel you trust.

Claiming needs no new contract logic. The recipient opens the link, the app parses it, recomputes the commitment to check the link is coherent, asks the pool whether the nullifier is already spent (so a used link says “already claimed” instead of failing), and then runs an ordinary full withdrawal to whatever address the recipient chooses. The recipient never needed to hold USDG, and never had to be known to the sender.

6. Gasless withdrawals Live

A fresh recipient address usually has no ETH for gas, and paying that gas from a funded address you already own would undo part of the privacy you just bought. The relayer solves this: it submits the withdrawal transaction, pays the gas, and is repaid in USDG out of the withdrawn amount.

The safety comes from context binding. Before proving, the client hashes the whole payout instruction together with the pool scope: context = keccak256(abi.encode({recipient, feeRecipient, feeAmount}, SCOPE)), reduced into the field. That single number is a public input to the proof. On chain, the pool recomputes the same hash from the calldata it was handed and compares it to the proof. If a relayer edits the recipient, redirects the fee, or raises the fee by one unit, the recomputed context no longer matches and the transaction reverts with ContextMismatch. A relayer therefore has exactly two options: broadcast the withdrawal on the terms you signed, or do nothing at all. It can never steal the payout or inflate its own cut, and it never sees your note.

The relayer in this repo (/api/relay) quotes a fee recipient and a minimum fee, checks the fee recipient matches its own address, enforces the minimum fee and the fee-below-value rule, recomputes the context server-side, deduplicates in-flight nullifiers, rate-limits by IP, and simulates the call before broadcasting. If the relayer is down or refuses, the app falls back to self-submitting the same proof with feeRecipient = address(0) and feeAmount = 0, in which case you pay the gas yourself.

What an observer actually sees

Public on chainNever revealed
Who deposited, how much, and when.The nullifier and secret inside your note.
Each commitment and the tree root.Which deposit a given withdrawal came from.
The recipient, payout, fee and nullifier hash of a withdrawal.The value of the change note left behind.

Privacy is a property of the crowd, not of the maths alone. If you are the only depositor and you withdraw the exact deposit amount minutes later, the timing and the amount link you, even though the proof reveals nothing. Withdraw partial amounts, to fresh addresses, and do not rush. See Security and risk for the full list of caveats.

What is not live yet

Planned The compliance layer. The Association Set Provider, deposit screening, the approved-set root and proof-of-innocence membership are designed but not enforced in protocol. The deployed withdraw circuit has no ASP constraint today, and its own comment says so. In-protocol enforcement is planned to land alongside the mainnet audit. See Compliance model.

Planned Mainnet, and an audit. Nothing here has been audited. The pool is deployed on Robinhood Chain testnet only, the USDG on it is a mock token with an open mint, and the TypeScript SDK lives in the repo but is not published to npm. Use testnet funds, and only testnet funds.