Blog / Jat
JatApril 18, 202610 min read

Building with Jat: a Private Deposit Pool You Can Call

Jat is a live devnet deposit pool with a CPI-able gate and a trustless withdraw. Here is what one verify checks, how to integrate it, and what you can honestly build on it today.

A gate you can call from your own program

Most access control on Solana leaks. To check that a wallet is on an allowlist, you publish the list or a Merkle root of public keys, and anyone watching can map who claimed what. To gate on a balance or a tier, you read a public account and the value is right there on chain. The check works, but the thing you were checking is no longer private.

Jat gives you a private deposit pool with two callable surfaces. Deposit real lamports against a commitment and the program mints a value-bound leaf in its own on-chain tree. Then seal_verify is a CPI-able gate: your program issues one inner instruction and in return gets a guarantee that the caller holds a deposit whose commitment is in the pool tree, that the deposited value clears a threshold you choose, and that this exact (context, nullifier) pair has never been used before. The caller proves all of this in zero knowledge. Your program learns only that the conditions held, never the secret, the exact value above the threshold, or which leaf was used; the denomination tier is public, since deposits are fixed-size. A separate withdraw lets a holder claim a leaf's exact value trustlessly out of the vault.

The program is live on devnet at program id seuH78RmBPVzoKToLQVEZrDvuL5jDNBSbptozWK9PEm. This post is about building on top of it and what it honestly proves today.

What one gate verify actually checks

Under the hood is a Circom/Groth16 circuit, Seal(20), over BN254. This is the Tornado-plus-Semaphore pattern, shielded membership plus a scoped nullifier plus a range check; the technical post covers the prior art in full. The public inputs are four field elements, in this exact order:

[ merkleRoot, threshold, contextHash, nullifierHash ]

The private inputs, which never leave the prover, are value, label, secret, nullifier, and the Merkle authentication path (pathElements[depth], pathIndices[depth]).

The circuit enforces three statements at once:

  1. Membership. leaf = Poseidon(value, label, Poseidon(nullifier, secret)) is included in a depth-20 Merkle tree whose root equals merkleRoot. Because the program pinned value to real lamports at deposit, membership means the value is real, not self-chosen.
  2. Single use, context scoped. nullifierHash = Poseidon(nullifier, contextHash). The nullifier is derived from the secret nullifier and the context, so the same secret produces a different nullifier in a different context, and the same secret in the same context always produces the same one.
  3. Threshold. value >= threshold, enforced by GreaterEqThan(64) over 64-bit lamport amounts, so the proof shows the deposited value clears the bar without revealing the exact value above it. The denomination tier itself is public, since deposits are fixed-size.

On chain, seal_verify does three corresponding things. It requires the proof's merkleRoot to be one of the pool's recent roots, so a proof against a stale or foreign tree is rejected. It verifies the Groth16 proof using groth16-solana and the BN254 alt_bn128 syscalls. And it consumes the nullifier by initializing a PDA derived from [b"nf", context_hash, nullifier]: the first use succeeds, and any replay fails at PDA initialization because the account already exists.

On compute cost: a real seal_verify on devnet runs at roughly 109k compute units, well inside Solana's default limit. The proof reveals only the four public inputs. There is no operator viewing key and no auditor key anywhere in the design, and no authority that posts the root.

How a deposit gets into the pool

Before anyone can prove anything, a leaf has to exist. A holder picks a random secret and nullifier, computes precommit = Poseidon(nullifier, secret), and calls deposit with a fixed denomination and that precommit. The program moves the lamports into the vault, pins the leaf's value to that exact amount, computes leaf = Poseidon(value, label, precommit) where label is the leaf's index, and inserts it into the on-chain tree. Deposits are accepted only in a fixed set of denominations, so all deposits of the same denomination are indistinguishable, and a later withdraw's revealed value only narrows the crowd to deposits of that denomination.

To prove later, the holder rebuilds the Merkle path from on-chain tree state. The program emits the leaf, its index, and the new root on every deposit, so a client can reconstruct the path deterministically.

The gate integration shape

Calling seal_verify: your program issues one CPI, the gate checks membership, a value threshold, and a fresh nullifier, and on success your privileged logic runs

One CPI, four public inputs, and a pass-or-revert gate.

From a caller's perspective, seal_verify is a precondition. You hand it the proof and the four public inputs, it either succeeds or the whole transaction reverts, and you branch on success. The names below are illustrative; treat them as the integration shape rather than a frozen API.

// inside your program's instruction handler

// 1. Decide the context for this gate. contextHash binds the proof
//    to *your* action, so a commitment valid for one gate cannot be
//    replayed against another.
let context_hash = derive_context(b"my-program:claim:season-3");

// 2. CPI into seal_verify with the proof + public inputs.
let cpi_accounts = SealVerify {
    tree_state,          // holds the on-chain pool tree + recent roots
    nullifier_record,    // initialized on success to consume the nullifier
    payer,               // public signer that pays for the nullifier account
    system_program,
};
seal::cpi::seal_verify(
    CpiContext::new(seal_program.clone(), cpi_accounts),
    proof_a, proof_b, proof_c,   // Groth16 proof bytes
    merkle_root,                 // must be a recent pool root
    threshold,                   // your minimum
    context_hash,                // bound to this action
    nullifier_hash,              // consumed here
)?;

// 3. If we reach this line, the gate held. Run your privileged logic.
mint_reward(ctx)?;

If any of the three statements fails, or the nullifier was already spent, the CPI returns an error and your handler never reaches step 3. You did not have to inspect the secret, store the allowlist, or read a balance. You set a threshold and a context_hash, and the gate handled the rest.

The trustless withdraw

When a holder wants their lamports back, withdraw pays the leaf's exact value out of the vault, with no operator and no authority signing for it. The withdraw proof, Withdraw(20), proves inclusion of the same leaf and spends a global single-use nullifier Poseidon(nullifier), with public inputs [merkleRoot, value, recipientHash, nullifierHash]. The recipientHash is Poseidon(hi16, lo16) of the payout pubkey; the program recomputes it from the real recipient account and requires a match, so a front-runner cannot redirect a pending payout without a fresh proof bound to their own key. The program then pays value lamports from the vault PDA via invoke_signed and consumes a [b"wnf", nullifier_hash] PDA so the leaf can be withdrawn exactly once. A real withdraw on devnet runs at roughly 117k compute units.

What you can build today

These use cases work over real deposits in the pool, which is exactly what the shipped gate and withdraw prove. They need nothing beyond what is live.

Use caseHow the primitive maps to it
Claim-once / one-time redemptionThe nullifier PDA makes a second claim with the same commitment fail at init, so a drop or redemption is enforceably single-use per context.
Private allowlist of depositorsMembership proves the caller's deposit is a leaf in the pool tree without revealing which leaf, so you get an allowlist with no public list.
Threshold-gated accessvalue >= threshold gates entry on a real deposited value clearing a bar without that value appearing on chain.
Context-scoped one-use actionscontextHash scopes the nullifier, so one deposit can act once per context: once per season, once per round, once per feature, each independently single-use.
Private deposit and trustless withdrawDeposit a fixed denomination, later withdraw the exact value to a bound recipient, with no operator or authority in the path.

A concrete combination: a private, single-claim airdrop for depositors of at least a chosen threshold. You set the season's context_hash, set threshold, and call seal_verify before minting. Each eligible deposit can claim exactly once, nobody can see who claimed, and nobody can see the exact value above the threshold that cleared the bar; the denomination tier is public, since deposits are fixed-size. The crowd a claimant hides in is the set of same-denomination deposits in the pool.

The full cycle is live

What started as a single gate is now an end-to-end flow on devnet. A payer funds a one-time stealth address derived from a recipient's published link, with no on-chain link between the two parties. The recipient finds it by scanning announcements with a view tag, and claims it either to an address or into the pool. A fee relayer pays the claim and the withdraw, so the recipient never originates a fee from a fresh account, which closes the gas-and-timing linkage the early design left open. And the withdraw proves membership in the browser and pays the exact value out of the vault to any address the recipient chooses, with no operator and no authority in the path. Graph privacy (the deposit-to-withdrawal link is shielded) holds across the flow; the denomination tier stays public, since deposits are fixed-size.

This widens the primitive from "gate your own program" to "receive privately and settle privately." A payer hits a link, the funds land on a one-time address, and the recipient routes them through the fixed-denomination pool and back out to a clean address, with the link between the deposit and the withdrawal shielded and no trusted operator anywhere in the loop.

The next milestones are scale and mainnet: an operator deployment for the relayer and indexer where the relayer is constrained to only pay gas, and growth of the anonymity set as more same-denomination deposits join the pool. For where this primitive sits among the other Solana privacy tools, the thesis post draws the map.

What you can wire into a Solana program right now is the gate itself: one CPI, four public inputs, a private membership-plus-threshold check over real deposits, and a nullifier that guarantees first use, plus a trustless withdraw and a relayer alongside it. Choose your context and threshold, branch on success, and keep the disclosure model in your own hands.

Back to Jat →