Blog / Jat
JatMay 16, 202611 min read

How Jat Proves Membership Without Revealing It

A walk through the shipped devnet design: deposit real lamports into an on-chain Poseidon pool, mint a value-bound commitment, then verify membership and a threshold with a Groth16 proof inside a Solana program, and withdraw trustlessly.

The problem Jat solves

Suppose you want a private pool you can deposit into, then later prove things about your deposit without leaking them. You want to prove "I hold a deposit that clears a threshold" and have a gate open, or prove ownership of a deposit and withdraw its exact value, all without revealing your exact value above the threshold, who you are, or which deposit out of the set is yours; the denomination tier is public, since deposits are fixed-size. A commitment normally carries everything you would rather not leak. Many private designs work around this by handing a viewing key to an operator or an auditor, which leaves the privacy conditional on someone choosing not to look.

Jat does this with two Groth16 proofs verified on chain, each revealing four field elements and keeping the rest hidden. The program holds the pooled lamports, owns the Merkle tree, and pays out withdrawals itself. The design has no operator viewing key and no auditor key, and no authority that can post a root. This post walks the shipped devnet design from the deposit to on-chain verification to the trustless withdraw.

Jat lifecycle: deposit real value, an on-chain Poseidon Merkle tree, a browser-generated proof, and the gate or withdraw

The whole cycle: a deposit pins a real value into an on-chain Poseidon tree, the holder proves against it in the browser, and the program either opens a gate or pays a withdrawal out of the vault, with no operator or auditor key.

Prior art, stated up front

The cryptography here is not new. The deposit-and-gate side is structurally Tornado plus Semaphore: a fixed-denomination shielded deposit, Merkle-tree membership, an external context-scoped nullifier, and a range check. The withdraw side is the same shielded-pool spend with a global single-use nullifier. If you know Tornado Cash or Semaphore, you already know the core of how Jat works. The part that is specific to this project is the packaging: a Solana-native program with its own on-chain incremental Poseidon-BN254 tree, a root that is a deterministic function of deposits, value pinned to real lamports, and no delegated disclosure key. Everything below is that well-understood primitive made concrete on Solana via groth16-solana and the sol_poseidon syscall.

The deposit and the commitment model

A Jat commitment is minted by the program when you deposit. The leaf is:

precommit = Poseidon(nullifier, secret)
leaf      = Poseidon(value, label, precommit)

secret and nullifier are random field elements the holder keeps. value is the deposited amount and label is the leaf's index in the tree. Poseidon is the hash because it is cheap inside an arithmetic circuit, which matters once we prove statements about this leaf.

The binding that makes v2 different is in the deposit instruction. The program first requires amount to be in a fixed set of allowed denominations, then transfers exactly that many lamports from the depositor into the vault PDA, and only then computes leaf = Poseidon(value, label, precommit) with value equal to the lamports that actually moved. The holder cannot self-assign a value to a leaf. When a later proof asserts a threshold over value, the threshold is over a real deposited amount.

The leaf is computed with the sol_poseidon syscall using the BN254 X5 parameters, big-endian, which is byte-identical to circomlib. That equality was verified against the real devnet syscall, not only on a host, so the leaf the program mints matches the leaf the circuit computes.

The commitment: nullifier and secret hash to a precommit, which combines with the real value and the leaf index into the leaf, which sits in the Merkle tree under the root

Each leaf is Poseidon(value, label, Poseidon(nullifier, secret)). Because the program pins the value at deposit, membership of the leaf is membership of a real deposit.

The on-chain Poseidon tree, with no posted root

Each leaf sits in the program's own incremental Merkle tree at depth 20. init_tree computes the empty-subtree zeros on chain (z[0] = 0, z[i] = Poseidon(z[i-1], z[i-1])) and seeds the empty root. There is no authority field on the tree and no set_root instruction. The earlier version's authority-gated root is gone. The root moves only when deposit inserts a leaf, folding it up the path against filledSubtrees and the zeros in the standard incremental insert, and pushing the new root into a small ring buffer of recent roots. The root is therefore a deterministic function of the deposits that happened, and no key can post one.

Depth 20 means the tree can hold on the order of a million leaves, the ceiling on the anonymity set. The practical set is whatever same-denomination deposits exist in the pool, and it grows with adoption.

What the gate circuit proves

The gate circuit is a Circom/Groth16 circuit, Seal(20), over BN254. It proves three statements at once about one hidden leaf.

The public inputs, in this exact order:

[ merkleRoot, threshold, contextHash, nullifierHash ]

The private inputs:

value
label
secret
nullifier
pathElements[depth]
pathIndices[depth]

Given those, the circuit enforces three things.

Membership: precommit = Poseidon(nullifier, secret), leaf = Poseidon(value, label, precommit), and folding that leaf up the Merkle path defined by pathElements and pathIndices produces a root equal to the public merkleRoot. The holder proves their leaf is in the committed set without revealing which leaf. Because the program pinned value at deposit, a passing membership proof means the value is real.

Scoped nullifier: nullifierHash = Poseidon(nullifier, contextHash). The nullifier is derived from the holder's secret nullifier and bound to a context. Because contextHash is an input, the same commitment yields a different nullifier in a different context and one deterministic nullifier within a single context. This is the Semaphore external-nullifier pattern.

Range gate: value >= threshold, enforced by GreaterEqThan(64), so amounts are treated as 64-bit lamport values and the proof asserts the committed amount clears the bar without disclosing the exact value above it. The denomination tier itself is public, since deposits are fixed-size.

merkleRoot, threshold, contextHash, and nullifierHash are public so the chain can check them. The value, label, secret, nullifier, and path stay private and never appear in the proof. The verifier learns that the amount cleared the threshold and nothing else about the exact value above it; the denomination tier is public, since deposits are fixed-size.

On-chain verification via seal_verify

The verifier is a Solana program, id seuH78RmBPVzoKToLQVEZrDvuL5jDNBSbptozWK9PEm, that exposes seal_verify. Other programs call it via CPI to gate their own logic behind a Jat proof.

A proof flows like this:

  1. The prover builds a Groth16 proof along with the public inputs [merkleRoot, threshold, contextHash, nullifierHash]. The value, label, secret, nullifier, and Merkle path are used only as the witness.
  2. A gate-opening transaction is submitted, signed by a public payer, and it calls seal_verify with the proof and the public inputs.
  3. The program requires merkleRoot to be one of the recent roots in the tree's ring buffer, so a proof against a stale or foreign root is rejected here.
  4. seal_verify checks the proof using groth16-solana, which runs the BN254 pairing check through the alt_bn128 syscalls. This is a full on-chain pairing verification.
  5. The program consumes the nullifier by initializing a PDA at seeds [b"nf", context_hash, nullifier]. If the PDA already exists, the init fails and the gate stays shut.

On the compute cost: a real seal_verify on devnet runs at roughly 109k compute units, comfortably inside Solana's default per-transaction limit. The proof verification, not the Poseidon bookkeeping, dominates that cost.

Trustless withdraw via the vault PDA

The withdraw circuit, Withdraw(20), proves ownership of a pool leaf and is paired with the withdraw instruction that pays the leaf's exact value out of the vault. Its public inputs, in order:

[ merkleRoot, value, recipientHash, nullifierHash ]

The private inputs are label, secret, nullifier, and the Merkle path. The circuit recomputes the same leaf Poseidon(value, label, Poseidon(nullifier, secret)), proves its inclusion under merkleRoot, and binds a global single-use nullifier nullifierHash = Poseidon(nullifier). Here value is public, because the program pays exactly that out of the vault.

The recipientHash closes a front-running gap. It is public and equals Poseidon(hi16, lo16) of the payout pubkey, where hi16 and lo16 are the two 16-byte halves of the recipient's key. On withdraw, the program recomputes that hash from the real recipient account and requires it to match, so a front-runner who copies a pending proof cannot redirect the payout to themselves without a fresh proof bound to their own key.

The withdraw instruction then: requires merkleRoot to be a real pool root; checks recipientHash against the actual recipient; decodes value to u64 lamports; verifies the Groth16 proof against the withdraw verifying key; consumes the global nullifier by initializing a PDA at [b"wnf", nullifier_hash], which makes a second withdraw of the same leaf fail; and pays value lamports from the vault PDA to the recipient via invoke_signed. No operator, no authority, and no auditor key sign or gate any of this. A real withdraw on devnet runs at roughly 117k compute units.

Replay protection via the nullifier PDAs

Both nullifiers are single-use by construction. The gate nullifier Poseidon(nullifier, contextHash) is deterministic per context, so the same commitment used twice in the same context yields the same nullifier both times; the first verify initializes the nf PDA and a second attempt to initialize the same account fails at the runtime level, before any gate logic runs. One commitment opens one gate once per context, and the same commitment in a different context derives a fresh nullifier and is free to act there.

The withdraw nullifier Poseidon(nullifier) is global, with no context, so a leaf can be withdrawn exactly once. The first withdraw initializes the wnf PDA; any second withdraw of the same leaf fails at PDA initialization. A failed transaction leaves no persisted artifact, so the rejection itself has no separate signature to cite, but the e2e run exercises both replays and confirms both are rejected.

The full cycle, verified on devnet

The whole loop runs in one repeatable client against the live program: init_tree if needed, then deposit of a fixed denomination with real lamports moved in and a leaf minted in the on-chain tree, then the Merkle path rebuilt from on-chain state, then a snarkjs proof of the gate, then seal_verify opening the gate and rejecting the replay, then a snarkjs proof of the withdraw, then withdraw paying the exact denomination out of the vault to a bound ephemeral recipient and rejecting the replay, and finally a non-denomination deposit that the program rejects. The run also checks that the off-chain root rebuilt from the path equals the on-chain root, which is the proof that the program's Poseidon tree and the circuit agree byte for byte.

What this design is, end to end

Putting the pieces together gives a precise claim. Jat is a working devnet system. It pins committed value to real deposited lamports, mints leaves in its own on-chain Poseidon tree, verifies Groth16 proofs of membership plus a threshold and of ownership inside the program on real BN254 pairing math, and pays withdrawals trustlessly from a vault PDA, with single-use nullifiers enforced at account initialization. The root is derived from deposits with no authority. There is no viewing key and no auditor key, so no party can later choose to inspect the secret, the exact value above the threshold, or the Merkle path; the denomination tier is public, since deposits are fixed-size.

Around that core, the flow now reaches both ends. A payer funds a one-time stealth address derived from a recipient's published link, with no on-chain link between the parties, so receiving a payment is part of the design. A fee relayer pays the claim and the withdraw, so a fresh recipient address never originates a fee, which closes the gas-and-timing linkage a public fee-payer would otherwise leave. The recipient routes the funds through the fixed-denomination pool and out to a clean address, which shields the link between the deposit and the withdrawal in one round-trip, all on devnet today. The denomination tier stays public, since deposits are fixed-size.

Next milestones

The full cycle, from a stealth payment through the gate and withdraw, is live on devnet now. The road from here is scale and mainnet: growth of the same-denomination anonymity set as adoption rises, and a mainnet deployment with an operator setup for the relayer and indexer where the relayer is constrained to only pay gas. The builder post covers what you can wire in today; the thesis post places this primitive against the rest of the Solana privacy field.

Back to Jat →