zkSVM

App

Litepaper

Documentation

Links

DocsOperate

Circuits and ceremony

The circuits and the trusted setup.

5 min read

Groth16 needs a per-circuit trusted setup. Whoever ends up holding the phase-2 toxic waste can forge proofs — which here means minting value out of the pool that nobody deposited, invisibly, forever. This document is the most important one in the directory.

The circuits

transfer.circom — 2-in / 2-out join-split. Public signals, in this order:

[root, nullifier0, nullifier1, out_commitment0, out_commitment1, fee]

The asset id is a private input; the circuit enforces that all four notes share it and that value is conserved within it. So a transfer cannot turn one asset into another, and an observer cannot tell which asset moved. A non-zero fee is allowed only for the native asset.

Zero-amount inputs are dummies — their Merkle check is switched off via the enabled signal, exactly as in Tornado Nova. That is what lets a 1-in transfer use the 2-in circuit without a second real note.

unshield.circom — spend one note to a public recipient, with a private change note:

[root, nullifier, change_commitment, asset_id, amount, recipient]

recipient is a public input bound into the proof. Without it, an observer could lift the proof out of the mempool and resubmit it with themselves as recipient. It is the anti-front-running binding, not decoration.

claim.circom — spend one address-owned note into two outputs:

[root, nullifier, out_commitment0, out_commitment1, fee, owner_hi, owner_lo]

The input note's pk is Poseidon2(owner_hi, owner_lo) and its nullifier is keyed by the note's blinding; there is no spending key anywhere in this circuit. Consent is the program's job: it fills owner_* from an account that signed. The halves are not range-checked in the circuit because the program derives them from 32 bytes, 16 at a time.

lib/note.circom and lib/merkle.circom mirror programs/programs/zksvm-pool exactly. tests/snarkjs_crosstest.rs proves it, by verifying a real snarkjs proof through the program's verifier — same verifying key, same proof encoding, same public-input order. If you touch the circuits, regenerate that fixture (and the rest — 02-deployment.md, step 2):

Shell
cd circuits && node scripts/gen-fixture.mjs

Development setup

Shell
cd privacy/circuits && npm install && npm run build

Compiles, runs a single-contributor setup with fixed entropy, and exports the verifying keys.

The entropy is published in scripts/setup.mjs. Anyone who reads this repository can reconstruct the toxic waste and forge proofs. That is not a weakness to mitigate — it is what the script is for: a fresh checkout gets working artifacts without a multi-day ceremony.

CEREMONY_ENTROPY overrides the fixed value. It does not make this safe — one contributor is still one point of trust — but it stops two independent chains from shipping identical proving keys.

Use it for local development and throwaway devnets. Nothing else.

The real ceremony

scripts/ceremony.mjs. Three properties the dev setup lacks:

  1. Trusted phase-1. The Hermez perpetual powers of tau, a widely witnessed community ceremony. Self-generating phase-1 would just be another single-party setup wearing a hat.
  2. Many independent phase-2 contributions. Each on a different machine, by a different person, with entropy they never reveal. The assumption is at least one honest contributor — so run it with participants who have no reason to collude, not with three of your own laptops.
  3. A public beacon, applied last, so no contributor controls the final step. Then transcript verification.

Running it

Coordinator — download phase-1 and initialize:

Shell
cd privacy/circuits
npm run compile
node scripts/ceremony.mjs init

Verify the downloaded .ptau against the published Hermez attestation hash, out of band, before anything else. A substituted phase-1 file compromises everything downstream and nothing later in the process would notice.

Each contributor, on their own machine:

Shell
node scripts/ceremony.mjs contribute transfer transfer_0000.zkey transfer_0001.zkey "Alice"
npx snarkjs zkey verify build/transfer/transfer.r1cs ptau/<ptau> transfer_0001.zkey

snarkjs prompts for entropy interactively rather than taking it on the command line — so it never lands in shell history or argv. Type something long and unpredictable. Then delete the input .zkey, publish the output's hash, and say publicly what you contributed and how you generated entropy. Contributions are transferred over any channel; integrity is verified by hash, not by the channel.

Coordinator — finalize with the beacon:

Shell
export L2_BEACON=<hex from a public unpredictable source>
node scripts/ceremony.mjs finalize transfer build/transfer/transfer_00NN.zkey
node scripts/ceremony.mjs finalize unshield build/unshield/unshield_00NN.zkey
node scripts/ceremony.mjs finalize claim build/claim/claim_00NN.zkey
npm run export-keys

The beacon must be unpredictable at the time contributions were made and verifiable afterwards — a future Bitcoin block hash at a pre-announced height is the standard choice. Announce the height before contributions begin.

Repeat the whole process for every circuit. They have separate setups; a compromised ceremony for one does not compromise the others, and a clean one does not vouch for them either.

The transcript

Publish, before anyone deposits:

  • the phase-1 file's URL and hash, and the attestation you checked it against;
  • every contributor, in order, with their contribution hash;
  • how each generated entropy, in their own words;
  • the beacon source, its pre-announced height, and its value;
  • the final .zkey hashes and the .vk.bin hashes;
  • the snarkjs zkey verify output for each circuit.

Then check that the keys/*.vk.bin compiled into the deployed program match the transcript — rebuild from the tagged source and compare against solana program dump, or use a verifiable build. A perfect ceremony followed by shipping the dev keys is the same as no ceremony, and it is a mistake that has happened to other projects.

Artifacts

FilePurposeDistribution
programs/programs/zksvm-pool/keys/*.vk.binverifying key the program embedscommit — public parameters
build/*/​*_final.zkeyproving key for walletsCDN, publish the hash
build/*/​*_js/*.wasmwitness generator for walletsCDN, publish the hash
build/*/​*.r1csconstraint systemkeep, needed to verify the ceremony

Verifying keys are not secret and must be committed: they are what lets anyone check the program verifies against the keys the ceremony produced. Proving keys are large and public too, just not repository-shaped — serve them with published hashes so wallets can check what they downloaded.

Never publish a contribution's intermediate entropy. That is the toxic waste, and one leak is enough.

If the ceremony is compromised

Assume the pool can be minted from. There is no way to tell from on-chain data whether it has been — a forged proof verifies.

Recovery means a new ceremony, new verifying keys, and a new pool: the tree and every note in it are tied to the compromised setup. Existing notes cannot be migrated, because migrating them requires trusting proofs from the compromised system. Users unshield through the old keys, if the pool is still solvent, and shield into the new one.

Which is why this document is long and the checklist is short: get it right the first time.