Protocol
Cryptography

Zero-knowledge proofs

What a statement, a witness and a public input are, how Groth16 proofs are built in Circom and verified by Solana programs, and what the verifier learns.

A zero-knowledge proof lets your device convince the shielded pool program that a statement about private data is true without showing it the data. Umbra's proofs are Groth16 proofs over BN254, written as Circom circuits, and the program checks each one with Solana's native BN254 syscalls in the same transaction that moves the tokens.

Statement, witness and public input

Every proof has three parts.

  • The statement is what the proof asserts. For a private transfer it is, roughly: these three nullifiers belong to notes in the note tree at this root, owned by an identity in the identity tree; these three new commitments are well-formed notes; and for every token, value in equals value out plus fees.
  • The witness is the private data that makes the statement true: the notes' contents, your keys, the Merkle paths, the blinding factors. It never leaves your device.
  • The public input is the part of the statement the verifier sees: the roots, the nullifiers, the new commitments, the deposit and withdrawal lanes, the fee parameters and so on.

A valid proof convinces the verifier that a witness exists for this public input. It says nothing else about the witness.

The chart below shows where each part lives. The witness and the proving key meet only on your device. The program sees the proof, computes the public input from values it holds itself, and checks the two against the verifying key.

flowchart LR
witness["Witness, private"] --> prover["Prover on your device"]
pk["Proving key"] --> prover
prover --> proof["Proof, 128 bytes"]
proof --> program["Shielded pool program"]
values["Values the chain holds"] --> fold["Public input"]
fold --> program
vk["Verifying key"] --> program

Circuits and constraints

A Circom circuit describes the statement as a system of constraints over the BN254 scalar field, each of the form (a linear combination) × (a linear combination) = (a linear combination). A value lies in a range because the circuit decomposes it into bits and constrains each bit; a hash is correct because every round of Poseidon is written out as constraints; a signature is valid because the verification equation is.

What the constraints do not cover is not proved. The transaction circuit, for example, range-checks every amount to 64 bits and every owner address half to 128 bits, but not the halves of the token addresses in the deposit and withdrawal lanes: the program fills those in from the accounts it reads, so they cannot be chosen by the prover. Knowing which checks live in the circuit and which in the program matters when reading the protocol, and the proofs overview lays them side by side.

Groth16

Groth16 turns a circuit into a proving key and a verifying key, and a witness into a proof.

  • A proof is small and fixed. Three curve points, two in BN254's G1 group and one in G2, 128 bytes compressed, whatever the size of the circuit.
  • Verification is cheap and fixed. One product of four pairings, plus one scalar multiplication and one addition per public input.
  • Zero knowledge holds unconditionally. A Groth16 proof made under correctly generated keys reveals nothing about its witness, whatever computing power an observer has, now or later. Soundness, the guarantee that false statements cannot be proved, rests on the hardness of problems on BN254.

One public input per proof

Every circuit the program verifies has exactly one public input. The circuit folds all of its public values into a single field element with a chained Poseidon hash (see hash functions) and constrains that element to equal the public input. The transaction circuit folds 53 values, the registration circuit 11, the nullifier-freshness circuit its three nullifiers and a root. The token staking discount circuit, which no program verifies, publishes its nine values directly instead.

The program never takes the folded value from the transaction. It computes the fold itself from values it holds or reads: the roots from its tree accounts, the slot it has checked against the clock, the deposit amounts from the tokens it measured arriving, the token and destination addresses from the accounts, the fee settings from the process policy account, and the nullifiers and commitments from the payload. If any of them differs from what the prover used, the fold differs and the proof fails. Folding also keeps verification at one scalar multiplication however many values the statement has.

Verification on Solana

The shielded pool program verifies proofs with the runtime's BN254 syscalls: sol_alt_bn128_compression to decompress the proof's points, sol_alt_bn128_group_op for the public-input multiplication and addition, and the same syscall's pairing operation for the final check. The public input must be below the field modulus, and no point of the proof may be the point at infinity or carry a flag bit the syscall reads.

Every verifying key is compiled into the program, one per circuit it verifies: transaction, nullifier freshness, registration, batch append, fee collection and escape hatch. No instruction reads a key from an account, and only a program upgrade changes one. A verifying key for a one-input circuit is 576 bytes. The program does not compile with a degenerate key, because such a key would accept proofs no witness produced. A key is degenerate when it has a point at infinity or a flag bit set, when its delta element equals gamma, beta or the negation of either, or when its gamma element equals beta or its negation.

process_3x3x2x2 checks its two proofs and the combined attester signature with one pairing call instead of three. It weights the nullifier-freshness check and the signature check by two random 253-bit scalars, drawn from a keccak hash of every proof point, public input and signature value the submitter supplied, and merges the three checks into seven pairs. If any of the three is false, the merged check passes with probability at most 2⁻²⁵³, a bound proved in the verifier's Lean library. The merge needs both keys to come from one trusted setup, sharing their alpha, beta and gamma elements, with gamma the G2 generator; the program refuses to build with keys that do not.

The protocol has six kinds of proof. The next section takes them one at a time.

The six proofs

Transaction proof

The transaction proof is the main proof of every deposit, private transfer and withdrawal. It shows that the whole value transaction is valid: the spent notes exist and belong to the sender, the new notes are well formed, value balances for every token, fees are correct, zone rules are met, and the encrypted sender record and viewing-key shares are consistent with everything else. Your device makes it. process_3x3x2x2 checks it under the compiled-in transaction key, in the same pairing as the nullifier-freshness proof and the combined attester signature. The transaction circuit lists everything it covers.

Nullifier-freshness proof

The nullifier-freshness proof shows that the transaction's three nullifiers are absent from the nullifier tree at a recent root. It needs nothing private, because nullifiers are public, so whoever submits the transaction makes it: the relayer for a relayed transaction, or your device for a deposit it submits itself. process_3x3x2x2 checks it alongside the transaction proof. It sits in the payload after the combined attester signature, which the attesters do not sign over, so the submitter can make it at submission time against the newest root without asking anyone to sign again. See nullifier freshness.

Registration proof

The registration proof shows that your identity commitment is well formed, that it has not been registered before, and that your outgoing viewing key has been correctly split into the three shares the guardian nodes receive. Your device makes it, and register checks it.

Escape-hatch proof

The escape-hatch proof shows that one unspent note you own is being taken out to a public address, with its fields disclosed. Your device makes it, and escape_hatch checks it, with no attester signature involved.

Batch-append proof

The batch-append proof shows that twelve values from the staging buffer were inserted into an indexed tree correctly: each one placed in value order, with the leaf below it updated to point at it. The forester makes it, and the forester commit instructions check it.

Fee-collection proof

A fee-collection proof shows that a batch of up to twenty fee notes belongs to the treasury and has not been collected before. The proof always covers twenty positions: the real notes come first, and the positions after them are padding, which the proof also binds. Whoever holds the treasury identity's keys makes it, and claim_fee_20 checks it.

What the verifier learns

The program learns that the statement holds for the values it folded. Every one of those values is already public: it is in the transaction or in the program's own accounts. The proof adds no information about which notes were spent, who owns the new ones, or the amounts and tokens inside the pool.

Where proofs are made

Your device makes the transaction, registration and escape-hatch proofs in the browser, with a WebAssembly Groth16 prover, so the witness never leaves it. The proofs that need nothing private are made by whoever runs the matching service: the nullifier-freshness proof by the submitter, the batch-append proof by the forester.

Post-quantum proofs

Groth16 rests on pairings over BN254, an elliptic curve that a large enough quantum computer would break. Umbra is built so that its proofs can be swapped for a post-quantum proof system without changing anything users hold.

Why the swap leaves notes alone

What a Groth16 proof proves is fixed by the circuit, not by the proof system. Every Umbra circuit is a Circom constraint system over the BN254 scalar field, and every hash inside it is Poseidon over that same field. Proof systems built from sumcheck and hash-based commitments, such as Spartan with WHIR, prove exactly that kind of constraint system, and work natively over the same field, so Poseidon stays as cheap as it is today.

Because the circuits stay the same, so does everything built on them: note commitments, nullifiers, the Merkle trees and every public input the program rebuilds. A swap replaces the prover on your device and the verifier and keys compiled into the program. It does not ask anyone to move their notes, re-register or withdraw first.

A proof of concept

Umbra has proved an unmodified Circom constraint system with a Spartan and zero-knowledge WHIR prover, outside this repository. The circuit was a chain of Poseidon hashes of about 90,000 constraints, about the size of the transaction circuit, read straight from the files Circom produces. That prover rests only on SHA-256 and the field, with nothing a quantum computer is known to break. On one 16-core laptop it proved the circuit in 0.2 seconds and verified it in 28 milliseconds, where the Groth16 prover took 3.5 seconds. Its proof was about 323 KiB, against Groth16's 128 bytes.

No need to move early

A Groth16 proof made under correctly generated keys reveals nothing about its witness, whatever computing power an observer has, so proofs recorded today give a future quantum computer nothing to read. Notes themselves are encrypted with ML-KEM-768, which is post-quantum. What a quantum computer would add is the ability to forge new proofs, from the day it exists, and the swap answers that. There is nothing to protect by switching before then; see security levels.

Last updated

On this page