A user opens a wallet, deposits a dollar of USDC, and from that moment owns a balance that is invisible to anyone reading the chain. They can pay another user out of that balance and the chain will record "two notes were spent and two new ones were created" — no amount, no sender address, no recipient address. They can split the balance, merge it, top it up, or cash part of it out, and to an outside observer every one of those operations looks identical.
That's Private Pool V2 — a Sapling-style UTXO pool for USDC on Solana, live on devnet today. This post is about what the primitive is, how it works in practice, and why we're excited about it.
The Shape
A user's private balance lives as a collection of notes. Each note is a tuple (value, owner_pk, blinding, memo) whose Poseidon hash lands as a leaf in an on-chain merkle tree. The hash hides everything about the note from outside observers — value is inside the hash, not stored alongside it, so two notes worth different amounts produce indistinguishable commitments.
To pay someone, the wallet generates a Groth16 JoinSplit2x2 proof that consumes two of the user's notes and produces two new ones — one for the recipient, one as change. The proof asserts seven things at once: input notes belong to the merkle tree, input owners match the user's spending key, output commitments are well-formed, ASP screening passed, nullifiers are correctly derived, values fit in 64 bits, and the inputs balance the outputs plus the fee.
The chain learns nothing useful. It sees two nullifiers, two new commitments, the merkle root, the ASP root, the protocol fee, and (optionally) a signed public-value field for boundary operations. None of those reveal an address. None of those reveal the transfer amount. The encrypted output ciphertexts are addressed to the recipient's viewing key — only they can decrypt and see "you got a 0.3 USDC note from someone."
For "Alice pays Bob 0.3 USDC out of a 1 USDC balance," the JoinSplit consumes two of Alice's 0.5 USDC notes and produces:
- A 0.3 USDC note owned by Bob.
- A 0.65 USDC change note owned by Alice (after a 0.05 USDC protocol fee).
The Three Operations
Everything reduces to three on-chain shapes. The wizard at /app/send picks the right one based on what notes the user owns.
Deposit
Public USDC enters the pool, a single new commitment is created (or two, with auto-split — more on that below). Amount is visible at the boundary, by design — that's where the privacy guarantee starts, not where it sits.
JoinSplit2x2
The privacy event. Two notes in, two notes out, plus a third fee-collector output. The chain learns the count of nullifiers and commitments and nothing else.
Withdraw
Pool note → public USDC. Used when the recipient is a regular Solana wallet rather than another private balance. If the user owns a single note exactly matching the requested amount, the wizard skips JoinSplit and runs a much cheaper Withdraw circuit (1-in/0-out). Amount and destination are visible at the boundary.
The Smart Router
A user sending privately doesn't pick a shape. The wizard does it for them, in this priority order:
- Exact-match Withdraw — if the sender owns one note whose value equals the send amount, run Withdraw. Fee = 0.
- JoinSplit2x2 — otherwise, pick two notes whose sum covers
amount + 5% fee, run JoinSplit2x2 with one output to the recipient and one change output back to the sender. - Top up — if the sender has fewer than two notes, or insufficient combined balance, the wizard renders a "Top up" CTA that opens the in-flow Deposit modal. Auto-split deposit creates two notes from one click, so the next send has the inputs JoinSplit2x2 needs.
Auto-Split Deposit
UTXO pools have a subtle ergonomics problem: JoinSplit2x2 needs two inputs, but a freshly-deposited wallet has only one note. The first send from a new wallet would fail with "you don't have two notes."
The fix is to deposit two notes at once. When a user tops up 1 USDC, the SDK splits it into a 0.5 USDC note and a 0.5 USDC note in a single transaction. From the chain's perspective it's one Deposit-shape tx with one SPL transfer in and two output commitments. From the user's perspective they clicked "Deposit 1 USDC" and now their wallet is ready to send.
For users who plan to immediately Withdraw the full amount (e.g. testing, or one-shot agent flows), autoSplit: false produces a single note and skips the split.
This is the kind of UX detail that decides whether a privacy primitive ships or sits in a research repo. The first failed send because of "you need two notes" loses 80% of users; auto-split eliminates the case entirely.
Public-Key Registry
A sender needs the recipient's payment_address (concat of spending_pk + viewing_pk) before they can build a JoinSplit output for them. Sapling's solution is z-addresses on invoices — share-your-string-out-of-band. We wanted something more agent-friendly.
V2 ships a public-key registry: wallet_pubkey → (viewing_pk, spending_pk_field, payment_address). The first time a user opens the private wallet, the frontend derives their keys from a wallet signature and posts them to the registry, signed by the wallet itself (Ed25519 verified server-side). Every subsequent sender resolves the recipient by their regular Solana wallet pubkey — no separate address to share, no UX deviation from "send to wallet."
The registry is a public lookup table. It maps "well-known Solana wallet" → "this is what to encrypt notes to." It does not reveal balances, note ownership, or anything about transactions. The viewing key it publishes is a pure-decryption key (X25519 public point) — knowledge of viewing_pk lets you encrypt notes to the recipient, never lets you spend them.
The trade-off: the registry knows that a given wallet has a V2 identity. That's a small leak. What you get in return: any wallet can pay any wallet privately, with no out-of-band address swap, with the same UX as a regular Solana send. For an agent economy where wallets pay each other dozens of times a day, that's the right trade.
ASP Screening
Compliance lives in the Approved Set Provider layer. The same primitive shielded links and shielded payment requests use, lifted into V2.
Every commitment must be present in the ASP snapshot before its corresponding nullifier is accepted on JoinSplit or Withdraw. The ASP snapshot is a Merkle tree of approved commitments; its root is folded into the public signals of every spending proof. A commitment that fails screening is simply absent from the snapshot — its owner can deposit, but their notes can't move.
Three screening states (per ADR-009):
- Cleared — passes OFAC + internal blocklist + heuristics. Added to the next ASP root within ~60s.
- Deferred — passes basic checks but flagged for manual review (e.g. sanctioned-list hash collision, high-value first-deposit). Held in the deferred queue, reviewed by a human on the admin console, then either cleared or blocked.
- Blocked — fails OFAC or matches the internal blocklist. Funds remain locked in the pool; the depositor can submit a refund request.
This is the property that makes V2 deployable in jurisdictions that require sanctions screening — selective compliance, without panopticon.
Sponsored Fees
A user shouldn't have to hold SOL to send USDC privately. They especially shouldn't have to hold SOL when "holding SOL" is itself a metadata signal — wallets with regular SOL balances are easier to track because gas-paying patterns leak.
V2 uses the same sponsored fee-payer pattern as the rest of RelAI: the backend signs the transaction as payerKey (paying SOL), the user signs only their slot (proving authority over the notes being spent), and the program treats them as separate signers. The user's wallet never touches SOL, never approves a SOL transfer, and never reveals SOL holdings.
For Address Lookup Tables (ALTs) — needed to fit the JoinSplit verifier's account list inside Solana's 1232-byte tx envelope — the backend creates and warms the LUT once per session. Subsequent sends reuse it.
The 5% protocol fee on JoinSplit is denominated in pool USDC and stays inside the pool — the fee output goes to the operator's fee-collector commitment, also amount-hidden, also part of the same anonymity set. Withdraw exact-match is fee-free.
Why The 2-In/2-Out Shape
Every JoinSplit in V2 is exactly two inputs, two outputs, plus a fee output. That's not "what we shipped first" — it's the design. A few reasons:
- Less than 2 inputs loses utility. A 1-in JoinSplit can pay + leave change, but then "merging two of my own notes" becomes impossible — and merging is exactly what UTXO wallets need to avoid dust accumulation.
- More than 2 inputs explodes constraints. Each input adds ~30k constraints (merkle proof + ASP membership + spending check). 2-in is ~80k constraints, ~30s on M1. 4-in would push past 50s of proving and start to feel slow.
- A single shape simplifies the verifier story. One circuit, one zkey, one verifier program. Different shapes per send pattern would mean multiple verifier programs and proof types to maintain.
A Story About Stealth Addresses
The original design included per-payment stealth addresses (ADR-008). The idea: every recipient has an xpub-style master key, and every payment derives a fresh stealth_owner_pk = Poseidon2(master_pk, nonce) so the on-chain address rotates per-transaction. Per-payment unlinkability for free.
The math didn't work.
The circuit's spending check is Poseidon(spending_sk) == owner_pk, and Poseidon isn't homomorphic — there's no stealth_spending_sk you can derive from master_spending_sk + nonce such that Poseidon(stealth_spending_sk) == Poseidon2(master_pk, nonce). Three variants over a week, every one of them broke proof verification at the boundary.
ADR-008 is now marked Withdrawn with a postmortem. The privacy guarantee without per-payment stealth is "all transfers within the pool are unlinkable, and recipients are identified only via their viewing key." Strong, but not "every payment uses a fresh on-chain pubkey."
We may revisit stealth on a future circuit revision that includes a Schnorr-style spending-authority gadget (where signature aggregation is homomorphic). For now, the pool's anonymity set is the privacy guarantee.
The reason this story is worth telling: privacy primitives are full of "obvious" extensions that don't compose. Naming what we tried and why it didn't work is more honest than shipping a half-working version because the marketing wanted the bullet point. ADR-008 is a load-bearing failure — it tells the next person who tries it where the rake is.
Privacy Posture (What Lands On Chain)
| Action | Visible | Hidden |
|---|---|---|
| Deposit | Depositor wallet, public USDC amount, output commitment | Owner of the new note (just a hash) |
| JoinSplit (internal) | 2 nullifiers, 2 commitments, merkle root, ASP root, fee | All values, all addresses, the link between input and output notes |
| Withdraw | Recipient wallet, public USDC amount, nullifier consumed | Which deposit funded it, owner's identity, the path through the pool |
For internal transfers between two registered RelAI wallets, the privacy guarantee is end-to-end: the on-chain footprint reveals nothing about the parties or the amount, and the encrypted ciphertexts are decryptable only by the recipient's viewing key.
What's Live
Today, on Solana devnet:
- Pool program:
8QvQ1czEdPMTozNYh8Hxzp35mpxdA8S85kqYyNoK7twj(Anchor 0.30 workspace). - Verifier programs: three raw Groth16 verifier programs (Deposit, Withdraw, JoinSplit2x2) — non-Anchor, no IDL. Circuits built from Circom 2.1.9, ~80k constraints for JoinSplit2x2, ~5k for Deposit, ~25k for Withdraw.
- Browser prover: snarkjs Groth16 in WebAssembly. M1 Mac proves JoinSplit2x2 in ~30s; midrange laptop in ~60s.
- Frontend:
/app/send(smart router),/app/send/private-instant(in-wizard send),/app/private(private wallet view), sidebarPrivateBalanceWidget, in-flowDepositModalwith auto-split. - Backend: server-side RPC cache (6-8s TTL on
/leaves,/notes,/witness,/nullifier), public-key registry (Ed25519-validated writes), wallet-keyed activity log, ASP screening pipeline (cleared/deferred/blocked + OFAC + internal blocklist). - Fees: 5% protocol fee on JoinSplit, 0% on exact-match Withdraw.
- Mainnet — pending multi-party trusted setup ceremony for all three circuits (devnet zkeys are single-contributor).
- EVM parity — the design is portable but the contract-side rebuild is a separate 3-4 week project. Today V2 is Solana-only.
- Mobile prover — JoinSplit proving is currently desktop-only via WebAssembly. We're investigating native iOS/Android provers via a Rust-side
ark-groth16build.
Use Cases
The pattern is the same in every case: the transactional metadata is what leaks in normal DeFi, not the funds themselves. V2 fixes the metadata leak without sacrificing on-chain settlement.
- Persistent private wallet for an agent. An autonomous agent tops up its private balance once a week. Day-to-day payments to suppliers come out of that balance via JoinSplit. The supplier's wallet doesn't appear on chain, the amount doesn't appear on chain, and the agent's main wallet shows one weekly deposit instead of dozens of daily transfers.
- Treasury operations. Move USDC between operating wallets without publishing the rebalancing schedule on a block explorer.
- Per-customer settlement at scale. Pay 50 contractors privately in 50 JoinSplits — observably indistinguishable from one user splitting their own balance 50 times.
- High-frequency micropayments. Stream-of-payments use cases (per-token API billing, per-action gameplay rewards) accumulate in the pool as small notes, settled in batches via JoinSplit when the recipient is ready to claim.
- Cross-wallet consolidation. A user with multiple wallets they control can move funds privately between them — zero public-graph evidence the wallets are linked.
Getting Started
The fastest path is the wallet-driven flow:
- Open
relai.fi/app/sendand connect your Solana wallet. - Sign once — the frontend derives your
spending_sk,viewing_sk, andnullifier_skfrom the signature, registers yourpayment_addressin the public-key registry, and opens the wizard. - First send → "Top up" CTA → auto-split deposit creates two notes from one click.
- Second send onwards → smart router picks Withdraw (exact-match) or JoinSplit (everything else) automatically.
@relai-fi/private-pool-sdk (reference docs). It exposes free functions — each high-level operation builds a VersionedTransaction you sign with the wallet adapter:
import {
loadConfig,
derivePrivatePoolKeys,
buildKeyChallengeMessage,
prepareDepositTransaction,
prepareJoinSplitTransaction,
lookupInRegistry,
registryEntryToRecipient,
} from "@relai-fi/private-pool-sdk";
// 1. Derive keys from a wallet signature
const sig = await wallet.signMessage(buildKeyChallengeMessage());
const keys = await derivePrivatePoolKeys({ walletSignature: sig });
const cfg = await loadConfig({ baseUrl: "https://api.relai.fi", network: "solana-devnet" });
// 2. Top up — deposit 1 USDC into the pool as your own note
const deposit = await prepareDepositTransaction({
cfg,
connection,
depositorPubkey: wallet.publicKey,
recipientSpendingPkField: keys.spendingPkField,
recipientViewingPk: keys.viewingPk,
amountAtomic: 1_000_000n,
});
await wallet.sendTransaction(deposit.transaction, connection);
// 3. Send 0.3 USDC privately to Bob (after he's registered)
const recipient = registryEntryToRecipient(
await lookupInRegistry({ baseUrl: "https://api.relai.fi", network: "solana-devnet", walletAddress: "BobPubkey..." })
);
const send = await prepareJoinSplitTransaction({
cfg, connection,
senderPubkey: wallet.publicKey, senderKeys: keys,
inputNotes: pickTwoUnspentNotes(myNotes, 315_000n), // amount + 5% fee
recipient,
recipientAmount: 300_000n,
fee: 15_000n,
publicValue: 0n,
});
const sig2 = await wallet.sendTransaction(send.transaction, connection);
console.log(sig2); // visible on Solscan as a JoinSplit2x2; amount + parties hidden
End-to-end latency: ~45s for a JoinSplit on devnet, dominated by the browser prover (~30s) and the ASP indexer tick (~10s). Withdraw exact-match is faster (~15s, no pairing prover).
What's Next
Three things are queued behind V2's launch:
- Multi-party trusted setup — Powers-of-Tau Phase 2 ceremony for all three circuits. Required before mainnet. Targeting June 2026, with public participation.
- EVM parity — port the pool program to Solidity (
PrivacyPoolV2.sol), reuse the same circuits + verifier artifacts. Targeting Q3 2026. - Stealth via Schnorr-spending — revisit per-payment recipient unlinkability with a circuit revision that uses signature aggregation instead of hash-only spending authority. Research, not yet scheduled.
In the meantime: open the wizard, top up a dollar, and try a private send between two of your own wallets. Watch the JoinSplit on Solscan — note that nothing on the page tells you what moved or to whom. That's the point.
The full Private Pool V2 protocol spec covers the circuit definitions, key derivation, encrypted note format, and ASP integration. The SDK reference walks through every callable in
@relai-fi/private-pool-sdk.
Written by the RelAI Team.
