Protocol
Proofs and circuits

How Umbra uses proofs

Which facts each zero-knowledge proof establishes, which the shielded pool program checks itself, which the attesters vouch for, and how the program rebuilds every proof's public input from its own state.

A proof in Umbra never stands alone. Each operation is checked in three places: the circuit proves what depends on private data, the shielded pool program checks what it can see on chain, and the attesters vouch for what neither can check. This page shows which fact lives where.

The chart below shows the three things a value transaction carries when it reaches the program. Each comes from a different party, and the program refuses the transaction unless all three verify against the same transaction.

flowchart LR
D["Your device"] -->|transaction proof| T["Value transaction"]
S["Submitter"] -->|freshness proof| T
A["Six attesters"] -->|combined signature| T
T --> P["process_3x3x2x2"]

Your device proves the statement about your notes and keys. The submitter, which is the relayer for a relayed transaction, proves that the nullifiers are not in the nullifier tree. The attesters (three guardian nodes, the screener, the fee enclave and the data endorser) each check what only they can see and sign one digest. The rest of this page explains how the work is divided.

Three places a fact is checked

A zero-knowledge proof can only speak about values it is given. Anything that depends on your keys or your notes must be proven, because nobody else holds them. Anything the chain already knows, such as a tree's recent roots, the clock or a token account's balance, the program checks directly, and it hands those values to the proof rather than trusting the prover's copy. What remains is data that lives off chain and is not private to you, such as an encrypted note or a counterparty's address history. The attesters check those, and their combined signature is what the program verifies.

The sections below take the facts about a value transaction one at a time and say where each is established.

The spender owns the notes it spends

Only the circuit can check this, because ownership is knowledge of private keys. The circuit rebuilds your identity commitment (sometimes called the user commitment) from your keys and their blinders and proves it is a leaf of the identity tree. It then requires each spent note's owner field to be that identity commitment, or, for a note owned by a Solana address, the commitment of an address that signed the transaction. Neither the program nor any attester sees the keys. See the transaction circuit.

The spent notes exist

The circuit proves each spent note's leaf is in the note tree at a given root. The program decides which roots are acceptable: it looks the root up by sequence number in the tree account and refuses one that has aged out of the account's recent-root window. A prover therefore cannot invent a tree that contains a note nobody created, because the root it proves against must be one the program recorded.

The notes have not been spent before

Spending a note publishes its nullifier, a tag derived from the note that comes out the same however many times the note is spent. Two checks keep a nullifier from being accepted twice. The nullifier-freshness proof shows the three nullifiers are absent from the nullifier tree at a recent root. The tree lags behind recent spends, so the program also stages every nullifier in the staging buffer, which the code calls the turnstile, and the staging buffer refuses a nullifier it already holds. The proof covers nullifiers already in the tree, and the staging buffer covers the ones still waiting to be inserted. See preventing double spends.

No token is created or destroyed

This is the circuit's job alone. For every token in the transaction it proves that what comes in (spent notes and deposits) equals what goes out (new notes, protocol fees, withdrawals and the relayer's tip), with every amount range-checked to 64 bits so the sum cannot wrap around the field. The program never sees note amounts, so it could not check this even if it tried. See value conservation.

Deposits and withdrawals are what they say

The amounts and addresses of a deposit or a withdrawal are public values of the proof, so the proof commits to them, but the program decides what they are. For a deposit it moves the tokens into the pool token account (for SOL, the pool's lamport account) first, measures the account's balance before and after, and folds the measured amount, not the amount the payload requested. For a withdrawal it reads the destination token account's owner (for SOL, the destination address itself) and folds that. A proof made for a different amount or a different destination fails.

The clock

Notes can carry a validity window, a slot before which and after which they cannot be spent. The circuit checks each spent note's window against an execution slot the payload names. The program checks that the named slot is not in the future and not older than the process policy's proof-age window, then folds it. The circuit never reads the clock and the program never sees the windows; each checks its half.

Fee rates

The circuit proves each rate it applies is at most the default for that action and matches the token's entry in the fee-rate tree, and that the fee paid is at least what those rates require. The program supplies the defaults and the rate-tree root from the process policy account, so a prover cannot pick its own schedule. The fee enclave receives the openings of the fee commitments, rebuilds the commitments from them, and fills in the fee voucher (the table of per-token rate adjustments) according to its schedule before it signs. See the fee model.

Zone approval

A note in a zone other than zone 0 can move only with its zone authority's approval. The circuit verifies the zone authority's EdDSA signature over the transaction and proves the authority's key is in the zone signer set at a given root. The program takes that root from the zone registry's signer-set account, so only a key the registry recorded can approve. See zones.

The counterparties are acceptable

No proof establishes a screening verdict. The screener checks every transaction's bound submitter, plus the depositor and source-account owner for each deposit and the destination party for each withdrawal, and signs only a Clear verdict. A Private Send still screens its submitter. Its key is one of the six in the combined signature.

The encrypted notes and records match the transaction

The circuit computes the sender record and the per-transaction disclosure data itself and commits to all of it in the privacy artifacts root, a public value. The encrypted notes for recipients are made outside the circuit. The data endorser recomputes the memo digest from the ciphertexts and the artifacts root from its leaves, and signs only if both match the payload. It checks that the ciphertexts fold to the digest, not that they decrypt to the committed notes.

Each guardian's viewing-key share is correct

The circuit splits the per-transaction viewing key into three shares and commits each in a share leaf inside the artifacts root. Each guardian node receives its own share and checks it against its leaf before it signs. No single party checks all three shares.

The transaction is the one everyone signed

The context digest ties all of this together. The program computes it as a Keccak-256 hash over the payload up to the signature and the addresses of all 39 accounts, the submitter's first. The same digest is signed by the six attesters and folded into the transaction proof as two 128-bit halves. A proof made for one transaction therefore cannot be reused with another payload or another set of accounts, and an attester's signature cannot be moved to a different transaction.

One public input, rebuilt by the program

Every circuit the program verifies has one public input: a Poseidon fold of all its public values. The program never accepts that fold from the transaction. It rebuilds it from values it holds or has just checked, and verifies the proof against the result, as the diagram below shows.

Roots
note, identity and zone signer-set trees, by sequence number
Execution slot
checked against the clock
Lanes
measured deposits and destination owners
Fee settings
from the process policy account
Committee size
from the guardian cluster's account
Payload values
signed by the attesters
Poseidon fold, computed by the program
The proof's public input
a proof made over any other values fails
The program folds the public values itself; the transaction never supplies the fold.

For a value transaction each value comes from one of these places:

  • Roots. The note-tree root and the identity-tree root are looked up by sequence number in the tree accounts, and the zone signer-set root in the zone registry's signer-set account. A tree root that has aged out of its window, or a signer-set root that is not the live one or is waiting for a rebuild, is refused before any proof is checked.
  • The slot. The payload names an execution slot. The program checks it against the clock and the proof-age window, then folds it.
  • Deposit lanes. The token, the measured amount and the depositor's address come from the accounts and the balance the program measured. The requested amount in the payload is not folded.
  • Withdrawal lanes. The token comes from the asset table, the amount from the payload, and the destination owner from the destination token account (for SOL, the destination address itself).
  • Fee settings. The rate-tree root, the four default rates and the self-exemption flags come from the process policy account.
  • Committee size. The threshold and node count come from the guardian cluster's account.
  • Program-owned spends. The spender authority's address comes from an account that must sign.
  • Payload values. The nullifiers, the output commitments, the fee commitments, the fee voucher commitment, the privacy artifacts root and the viewing-key randomness come from the payload, which the attesters have signed.

If the prover used any other value, the fold differs and the proof fails.

Two proofs per value transaction

A deposit, a private transfer (Private Send) and a withdrawal all go through one handler, process_3x3x2x2, and each carries two proofs.

  • The transaction proof is made on your device. It covers everything about the notes, the keys, the fees, the zones and the records. See the transaction circuit.
  • The nullifier-freshness proof shows the three nullifiers are absent from the nullifier tree at a recent root. It needs no private data, because nullifiers are public, so whoever submits the transaction makes it: the relayer for a relayed transaction, your device for a deposit it submits itself. It sits outside the part of the payload the attesters sign, so the submitter can make it against the newest root without asking anyone to sign again. See nullifier freshness.

The handler runs its checks in a fixed order, shown below.

flowchart LR
C1["Move deposits in, measure"] --> C2["Build both public inputs"]
C2 --> C3["Verify both proofs and the combined signature in one pairing"]
C3 --> C4["Record the effects"]

The deposits move first, because the transaction proof is checked against the amounts that actually arrived. The handler then computes both proofs' public inputs and checks the two proofs and the combined attester signature together, with one pairing call: it weights the freshness check and the signature check by random scalars drawn from a hash of everything the submitter supplied, so the combined check fails if any one of the three would. Only then does the handler stage the nullifiers, append the new notes, stage the fee commitments for a later insert into the fee tree, pay out withdrawals and credit the relayer's tip to its accrual. Nothing is recorded unless every check passes, because Solana reverts the whole transaction on any failure, including the deposit transfers.

Every circuit

Nine circuits are in the repository. The table compares them on what each proves, who makes and checks the proof, and its size.

CircuitWhat it provesMade byVerified byConstraints
transactionA whole value transaction is validYour deviceprocess_3x3x2x289,801
nullifier_non_membershipThree nullifiers are absent from the nullifier treeThe submitterprocess_3x3x2x222,209
registrationA new identity commitment is well formed, unregistered, and its outgoing viewing key is correctly sharedYour deviceregister11,112
escape_hatchOne unspent note is being taken out, with its fields disclosedYour deviceescape_hatch17,337
indexed_batch_appendTwelve staged values were inserted into an indexed tree correctlyThe foresterforester_commit, registration_forester_commit, fee_forester_commit279,462
fee_claim_20A batch of up to twenty fee notes belongs to the treasury and is unspentThe holder of the treasury identity's keysclaim_fee_20256,144
merge and nullifier_non_membership_8Up to eight notes of one owner combine into oneNot used on SolanaNo Solana handler54,588 and 58,849
token_staking_discountIts prover owns a note of a named token that holds at least a minimum amount, was locked for at least a minimum number of slots, and cannot be spent before a stated slotThe note's ownerNo Solana handler7,506

The constraint counts are circom 2.2.3's non-linear constraint counts at optimisation level 2, compiled from the circuits at the verified commit. The repository pins a budget for each circuit that the build refuses to exceed. The token staking discount circuit is the one circuit with more than one public input: no program verifies it, so it publishes its nine public values directly instead of as one fold.

Every verifying key is compiled into the program, one for each circuit it verifies, and each handler checks its proof under its own circuit's key, so a proof for one circuit can never be checked against another circuit's key. No instruction installs or replaces a key; only a program upgrade changes one. process_3x3x2x2 still names the transaction and freshness keys' addresses in two slots of its signed account list, but it does not read them. The registration key is for a 2-of-3 guardian cluster, and register refuses a cluster of any other shape. The circuits' instantiations are on their own pages, read from the circuit manifest. The handlers that verify a proof are listed in the reference section at the bottom of this page.

What the verifier learns

The program learns that each statement holds for the values it folded, and every one of those values is already public: it is in the transaction or in the program's own accounts. A proof adds nothing about which notes were spent, who owns the new ones, or the amounts and tokens inside the pool. What a transaction does reveal, such as a deposit's depositor and amount, is revealed by the public values themselves; see what is private and what is visible.

Status

Proving happens on the device for the transaction, registration and escape-hatch circuits, with a WebAssembly Groth16 prover in the browser. An opt-in prover enclave for the transaction circuit exists in the repository; it has no deployment and all proving is on the device today.

Reference

Last updated

On this page