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):
cd circuits && node scripts/gen-fixture.mjs
Development setup
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:
- 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.
- 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.
- A public beacon, applied last, so no contributor controls the final step. Then transcript verification.
Running it
Coordinator — download phase-1 and initialize:
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:
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:
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
.zkeyhashes and the.vk.binhashes; - the
snarkjs zkey verifyoutput 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
| File | Purpose | Distribution |
|---|---|---|
programs/programs/zksvm-pool/keys/*.vk.bin | verifying key the program embeds | commit — public parameters |
build/*/*_final.zkey | proving key for wallets | CDN, publish the hash |
build/*/*_js/*.wasm | witness generator for wallets | CDN, publish the hash |
build/*/*.r1cs | constraint system | keep, 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.