Private Pool V2: A Sapling-Style UTXO Pool For Private USDC On Solana

RelAI Team
May 8, 2026 · 12 min read

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).
From outside, that transaction looks identical to Alice merging two of her own notes, or to anyone paying anyone any amount. The graph of who-paid-whom is not recoverable. The anonymity set is the entire pool depth, not "deposits at this denomination at this time."

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:

  1. Exact-match Withdraw — if the sender owns one note whose value equals the send amount, run Withdraw. Fee = 0.
  2. 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.
  3. 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.
The router runs entirely client-side. The backend only serves leaves, witness paths, and nullifier status — it doesn't see the user's note set and doesn't decide which notes to spend. Everything that touches secret material runs in the browser.

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.
The user proves "my commitment is in some ASP snapshot whose root is current" without revealing which commitment. So we can deny non-compliant deposits without exposing which user owns which commitment, and without breaking the pool's privacy guarantee for everyone else.

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.
For users who have many small notes and need to consolidate, V2 supports iterated JoinSplit — chaining 2-in/2-out steps until the note count is manageable. The SDK does this automatically when the smart router can't find a covering pair.

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)

ActionVisibleHidden
DepositDepositor wallet, public USDC amount, output commitmentOwner of the new note (just a hash)
JoinSplit (internal)2 nullifiers, 2 commitments, merkle root, ASP root, feeAll values, all addresses, the link between input and output notes
WithdrawRecipient wallet, public USDC amount, nullifier consumedWhich deposit funded it, owner's identity, the path through the pool
The only metadata an external observer learns from a JoinSplit transaction is "two notes in this pool were spent and two new ones were created." There's no amount. No sender address. No recipient address. No correlation to any prior or future transaction.

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), sidebar PrivateBalanceWidget, in-flow DepositModal with 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.
Not yet live:
  • 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-groth16 build.
Read the protocol spec, the SDK quickstart, and the deployment runbook on the docs site.

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:

  1. Open relai.fi/app/send and connect your Solana wallet.
  2. Sign once — the frontend derives your spending_sk, viewing_sk, and nullifier_sk from the signature, registers your payment_address in the public-key registry, and opens the wizard.
  3. First send → "Top up" CTA → auto-split deposit creates two notes from one click.
  4. Second send onwards → smart router picks Withdraw (exact-match) or JoinSplit (everything else) automatically.
For programmatic access, the SDK is @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:

  1. Multi-party trusted setup — Powers-of-Tau Phase 2 ceremony for all three circuits. Required before mainnet. Targeting June 2026, with public participation.
  2. EVM parity — port the pool program to Solidity (PrivacyPoolV2.sol), reuse the same circuits + verifier artifacts. Targeting Q3 2026.
  3. 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.
We'll publish the trusted setup ceremony details, the EVM rollout plan, and the Schnorr stealth research note as separate posts.

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.

Understand x402 before you implement

This guide uses payment primitives from the x402 standard. Read the protocol overview for a complete flow, terminology, and integration FAQ.

    Private Pool V2: A Sapling-Style UTXO Pool For Private USDC On Solana | RelAI