Cloister — Circuit specification
TxCircuit (gnark, Groth16 over BN254). A 2-input / 2-output shielded transaction.
Source of truth: packages/prover-gnark/zk/circuit.go. 50,481 R1CS constraints.
Primitives
- Hash
H: Poseidon2 in Merkle–Damgård mode (gnark-crypto .../poseidon2). The native hash (zk/hash.go) is proven bit-identical to the in-circuit hasher byhash_test.go. - Key:
pubKey = H(privKey)— curve-free. No BabyJubJub, no subgroup-order constraint. - Note commitment:
C = H(amount, pubKey, blinding). - Signature:
sig = H(privKey, C, leafIndex). - Nullifier:
nf = H(C, leafIndex, sig)— deterministic per (note, position), unlinkable toCwithoutprivKey. - Merkle: fixed depth
Levels = 20(2²⁰ ≈ 1.05M notes). Empty leaf =0. Node= H(l, r).
Public signals (this exact order; matches the on-chain verifier + pub[10])
| # | Name | Meaning |
|---|---|---|
| 0 | Root |
pool Merkle root the inputs are proven against |
| 1 | PublicAmount |
extAmount − fee, field-encoded (deposit +, withdraw `p − |
| 2 | ExtDataHash |
keccak(extData) mod p — binds recipient / relayer / fee / encrypted outputs |
| 3 | InputNullifier[0] |
|
| 4 | InputNullifier[1] |
|
| 5 | OutputCommitment[0] |
|
| 6 | OutputCommitment[1] |
|
| 7 | NewRoot |
root after inserting the two outputs as one pair node |
| 8 | PairIndex |
insertion slot (= laneNextIndex / 2) |
| 9 | AssociationRoot |
compliance: inputs proven ∈ the ASP good-set |
Constraints enforced
For each input t ∈ {0,1}:
pub = H(privKey_t),C = H(amount_t, pub, blinding_t),sig = H(privKey_t, C, idx_t),nf = H(C, idx_t, sig); assertnf == InputNullifier[t].- Range:
amount_t ∈ [0, 2²⁴⁸)(viaToBinary, prevents field-wrap value forgery). isReal = 1 − IsZero(amount_t). Dummy (zero-value) inputs skip membership.- Pool membership (real only):
climb(C, idx_t, pathEls_t) == Root, enforced by(root − Root)·isReal == 0. - ASP membership (real only):
climb(C, assocIdx_t, assocEls_t) == AssociationRoot.
For each output t:
6. C = H(amount_t, pubKey_t, blinding_t); assert C == OutputCommitment[t].
7. Range: amount_t ∈ [0, 2²⁴⁸).
Global:
8. AssertIsDifferent(InputNullifier[0], InputNullifier[1]) — no in-tx double-spend.
9. Value conservation: Σ inAmount + PublicAmount == Σ outAmount (in the field).
10. ExtDataHash is a declared public input, so Groth16 binds its value into the proof. Its
binding to the actual extData (recipient/relayer/fee/outputs) is enforced on-chain:
ShieldedPool._transact recomputes keccak256(abi.encode(extData)) % p and passes that as
this public input, so any tampered field yields a mismatching hash and the proof is rejected.
11. Off-chain insertion: with z1 = H(0,0) and pairNode = H(out0, out1),
climb(z1, PairIndex, pairPathEls) == Root (the slot was empty) and
climb(pairNode, PairIndex, pairPathEls) == NewRoot (correct insertion, same siblings).
Soundness notes
- Conservation cannot be forged by field-wrap: every amount is range-checked to 248
bits and there are only four of them, so
Σstays far belowp.PublicAmountis fixed by the contract (not a free witness) and bounded byMAX_EXT_AMOUNT = 2²⁴⁸. For a withdrawalPublicAmount = p − (W+F); the single modular wrap yields the unique integer relationΣin = Σout + W + F. - Empty-slot soundness: faking an occupied slot as empty would require a Poseidon2
second-preimage (
climb(z1, …) == Rootwith fake siblings) — infeasible. - Nullifier binds position: a note at a fixed leaf has exactly one nullifier; combined with the on-chain spent-set this prevents replay/double-spend.
- extData binding: the contract recomputes
keccak256(abi.encode(extData)) % pon-chain and feeds it as theExtDataHashpublic input; Groth16 binds that input into the proof, so altering any extData field (recipient/relayer/fee/outputs) yields a mismatching hash and the proof is rejected — a relayer cannot redirect funds. (See the "tampered extData reverts" e2e test.)
Trusted setup
groth16.Setup (own run) produces pk/vk; the verifier is exported from vk. The same
setup feeds the prover and the on-chain verifier (a mismatch yields ProofInvalid).
Mainnet requires a multi-party Phase-2 ceremony to replace the single-run keys.