The transact circuit
The one zero-knowledge circuit behind every shield, private transfer and unshield - its inputs, constraints, public inputs, the ext-data binding and the proof system.
Every Voidcash transaction is one Groth16 proof of the Transaction(26, 2, 2) circuit: two input notes, two output
notes, a tree of depth 26. It proves that the
inputs are notes the prover owns, that the outputs are well formed, and that value is conserved, without
showing which notes are spent or what they hold.
Public inputs
In this exact order, the order in which the circuit declares them and the program passes them to the verifier:
| # | Name | Built by the program from |
|---|---|---|
| 0 | root | The tree account's root history, at the root_index the transaction names |
| 1 | publicAmount | ext_amount - relayer_fee, as a field element: x, or r - x when negative |
| 2 | extDataHash | A masked SHA-256 over the accounts and ext data of the instruction (below) |
| 3-4 | inputNullifier[2] | The instruction's two nullifiers |
| 5-6 | outputCommitment[2] | The instruction's two commitments |
The prover chooses none of the first three: the program reads the root from its own account and computes
publicAmount and extDataHash itself. After proving, the app checks that snarkjs's public signals are exactly these
seven values.
Private inputs
| Name | Size | Meaning |
|---|---|---|
inAmount | 2 | Lamports of each input note |
inPrivKey | 2 | Spend key of each input note |
inBlinding | 2 | Blinding of each input note |
inIndex | 2 | Leaf index of each input note |
inPathElements | 2 × 26 | Merkle siblings of each input note |
outAmount | 2 | Lamports of each output note |
outPubKey | 2 | Owner of each output note |
outBlinding | 2 | Blinding of each output note |
Constraints
For each input i:
inAmount[i] < 2^64 Num2Bits(64)
pubKey_i = Poseidon(inPrivKey[i])
cm_i = Poseidon(inAmount[i], pubKey_i, inBlinding[i], 0)
inputNullifier[i] == Poseidon(cm_i, inIndex[i], Poseidon(inPrivKey[i], cm_i, inIndex[i]))
inIndex[i] < 2^26 Num2Bits(26) inside MerkleRoot
if inAmount[i] != 0: MerkleRoot(cm_i, inIndex[i], inPathElements[i]) == rootFor each output j:
outAmount[j] < 2^64 Num2Bits(64)
outputCommitment[j] == Poseidon(outAmount[j], outPubKey[j], outBlinding[j], 0)Then:
inputNullifier[0] != inputNullifier[1]
inAmount[0] + inAmount[1] + publicAmount == outAmount[0] + outAmount[1]
extDataSquare = extDataHash * extDataHashWhat this proves:
- Ownership. Each real input is a leaf under a recorded root, and the prover knows the spend key behind its owner.
- Correct nullifiers. Each nullifier is the one its note must publish, so a note can be spent only once.
- Distinct inputs. The same note cannot be counted twice in one proof.
- Conservation. What goes in, plus what enters from outside (or minus what leaves), equals what comes out.
- Binding. The proof is valid only for one
extDataHash, and so for one recipient, relayer, fee, deadline and pair of ciphertexts.
The root check uses circomlib's ForceEqualIfEnabled with the input's amount as the switch: any non-zero amount
forces it. A zero-amount input is a dummy, which needs no real note,
but its nullifier is still constrained.
The squaring line exists because a public input that no constraint uses has a zero term in the verifying key and is not
bound by the proof. extDataHash appears in no other constraint, so it is squared.
Field and range checks
The field is BN254's scalar field, r ≈ 2^254. The sums cannot wrap it:
- every note amount is below 2^64 (the
Num2Bits(64)checks); - the program refuses an
ext_amountor arelayer_feewhose absolute value is aboveMAX_AMOUNT= 2^60 (AmountTooLarge), sopublicAmountis small or r minus a small value; groth16-solana'sverify()rejects any public input of r or more, so every root, nullifier, commitment and hash enters in its one canonical form. The nullifier PDAs are seeded with those same 32 bytes.
extDataHash
Everything the transaction carries that the circuit does not read is hashed into one public input:
extDataHash = mask(SHA-256(
"voidcash/transact/v1" 20 ASCII bytes
program id 32 bytes
recipient 32 bytes, the account passed as `recipient`
ext_amount i64, little-endian
relayer 32 bytes, the account passed as `relayer`
relayer_fee u64, little-endian
deadline_slot u64, little-endian
fee_bps u16, little-endian
ciphertext0 88 bytes
ciphertext1 88 bytes
)) the parts concatenated, in this order
mask(h) = h with the top three bits of its first byte cleared (h[0] &= 0x1f), read as a big-endian integer:
below 2^253, so below rThe program computes it with the sol_sha256 syscall from the accounts and data of the instruction it is executing;
the app computes the same bytes before proving.
| Field | What binding it prevents |
|---|---|
| Domain and program id | Replaying a proof in another program |
recipient | Redirecting an unshield |
ext_amount | Changing what enters or leaves (it also sets publicAmount) |
relayer, relayer_fee | Paying a different account, or a higher fee |
deadline_slot | Holding a transaction and sending it later: the program refuses it after that slot (Expired) |
fee_bps | Applying a fee rate other than the one the user was shown (FeeChanged if it is not the pool's current rate) |
ciphertext0, ciphertext1 | Replacing the encrypted notes, which would make them unfindable for their owners |
Size
| Measure | Value |
|---|---|
| Constraints | 15,494 (snarkjs r1cs info) |
| Wires | 15,555 |
| Private inputs, public inputs | 66, 7 |
Proving key (transaction.zkey) | 9,789,307 bytes |
Witness generator (transaction.wasm) | 3,133,689 bytes |
| Proof | 256 bytes |
Proof system
The proof system is Groth16 on BN254. The program verifies it with groth16-solana 0.2.0, through Solana's
alt_bn128 syscalls. It uses the library's checked verify(), which rejects any public input of r or more, and never
verify_unchecked(). Any failure of the check is BadProof.
The 256-byte proof is A negated (64 bytes), B with each coordinate as (c1, c0) (128 bytes), and C (64 bytes), all big-endian; the app converts snarkjs's output to this layout. The verifying key is compiled into the program (see Trusted setup).
| Component | Version |
|---|---|
| circom | 2.2.3, compiled with --O2 |
| circomlib | 2.0.5 |
| snarkjs | 0.7.6 |
| groth16-solana | 0.2.0 |
Each component is pinned to an exact stable release, checked against its release page, its package registry and its security advisories.
Where proving happens
Proofs are built on the user's device. The app runs snarkjs's groth16.fullProve in a Web Worker, so the page stays
responsive. The worker loads the witness generator and the proving key from the app's own origin, at
/circuit/transaction.fbed2c0ce5d0ea4c.wasm and /circuit/transaction.34b15fb1a334f8a0.zkey (each name carries the
first 16 hex digits of the file's SHA-256), and checks each against the full SHA-256 pinned when the app was built; it
refuses to prove with any other file. The witness, which holds
the spend key and the note secrets, never leaves the browser.
The Merkle tree
The append-only Poseidon tree of note commitments, its zero values, the per-slot root history, and the layout of the tree account.
Trusted setup
Where the Groth16 keys come from - the public phase 1, one contribution closed with a Solana block hash, the published hashes, and how to check them yourself.