voidcashdocs

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:

#NameBuilt by the program from
0rootThe tree account's root history, at the root_index the transaction names
1publicAmountext_amount - relayer_fee, as a field element: x, or r - x when negative
2extDataHashA masked SHA-256 over the accounts and ext data of the instruction (below)
3-4inputNullifier[2]The instruction's two nullifiers
5-6outputCommitment[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

NameSizeMeaning
inAmount2Lamports of each input note
inPrivKey2Spend key of each input note
inBlinding2Blinding of each input note
inIndex2Leaf index of each input note
inPathElements2 × 26Merkle siblings of each input note
outAmount2Lamports of each output note
outPubKey2Owner of each output note
outBlinding2Blinding 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]) == root

For 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 * extDataHash

What 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_amount or a relayer_fee whose absolute value is above MAX_AMOUNT = 2^60 (AmountTooLarge), so publicAmount is small or r minus a small value;
  • groth16-solana's verify() 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 r

The 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.

FieldWhat binding it prevents
Domain and program idReplaying a proof in another program
recipientRedirecting an unshield
ext_amountChanging what enters or leaves (it also sets publicAmount)
relayer, relayer_feePaying a different account, or a higher fee
deadline_slotHolding a transaction and sending it later: the program refuses it after that slot (Expired)
fee_bpsApplying a fee rate other than the one the user was shown (FeeChanged if it is not the pool's current rate)
ciphertext0, ciphertext1Replacing the encrypted notes, which would make them unfindable for their owners

Size

MeasureValue
Constraints15,494 (snarkjs r1cs info)
Wires15,555
Private inputs, public inputs66, 7
Proving key (transaction.zkey)9,789,307 bytes
Witness generator (transaction.wasm)3,133,689 bytes
Proof256 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).

ComponentVersion
circom2.2.3, compiled with --O2
circomlib2.0.5
snarkjs0.7.6
groth16-solana0.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.

On this page