Circuit specification
Source: circuits/ghost.circom · Circom 2.2.2 · Groth16 / BN254 · 6,175 constraints · Merkle depth 20.
Statement
Given public inputs (root, mintHi, mintLo, minBalance, appScope, nullifierMode, challenge, expiresAt), the prover knows private inputs (identitySecret, walletKey, balance, pathElements[20], pathIndices[20]) such that:
idCommit = Poseidon(1, identitySecret)
wkCommit = Poseidon(2, walletKey)
leaf = Poseidon(3, idCommit, wkCommit, mintHi, mintLo, balance)
MerkleRoot(leaf, pathElements, pathIndices) == root // Poseidon(2) per level
balance ∈ [0, 2^64) // Num2Bits(64)
minBalance ∈ [0, 2^64) // Num2Bits(64)
balance >= minBalance // GreaterEqThan(64)
pathIndices[i] ∈ {0,1}, nullifierMode ∈ {0,1}
nullifier = Poseidon(4, nullifierMode ? walletKey : identitySecret, appScope, nullifierMode)
challenge and expiresAt are additionally squared into dummy signals so they are explicitly constrained (Groth16 binds all public inputs regardless; this is defence in depth against tooling that might prune unused inputs).
Public signals (order emitted by snarkjs)
| Index | Name | Notes |
|---|---|---|
| 0 | nullifier | output |
| 1 | root | must equal the attested snapshot root |
| 2 | mintHi | mint bytes 0–15, big-endian |
| 3 | mintLo | mint bytes 16–31 |
| 4 | minBalance | raw u64 base units |
| 5 | appScope | SHA-256("ghost/app-scope/v1:"‖appId) mod p |
| 6 | nullifierMode | 1 = wallet, 0 = identity |
| 7 | challenge | verifier nonce (< 2^248) |
| 8 | expiresAt | unix seconds |
These are defined once in packages/sdk/src/core/constants.ts (PUBLIC_SIGNALS).
Design notes
- Domain separation. Every Poseidon use has a distinct leading tag (1–6). Leaves use Poseidon with 6 inputs and inner nodes Poseidon with 2 inputs; different widths use different round constants, which prevents leaf/node confusion.
- No leaf index in the nullifier. Snapshots are shuffled each time; the nullifier depends only on secrets and
appScope, so it is stable across snapshots and position-independent. - Path index malleability (choosing a different index bit order for the same leaf) does not matter: the nullifier does not depend on position.
- Mint as two limbs. A 32-byte public key exceeds the ~254-bit field; two 128-bit limbs are lossless. The verifier recomputes the limbs from the mint it requested.
- Range checks. Without
Num2Bits(64), a prover could pickbalancesuch that the comparator's internal subtraction wraps modulo p. Both operands are range-checked before comparison.
Trusted setup
| Phase | Source |
|---|---|
| 1 (universal) | PSE Perpetual Powers of Tau, contribution 80, 2^14 — ppot_0080_14.ptau, sha256 3ca1149e9349b22b0ee0649399cfb787677129b7b1189d1899fc0d615d9583db |
| 2 (circuit) | 1 local contribution (in-process CSPRNG entropy, never persisted) + beacon 12c14c22…449a06 (10 iterations) |
snarkjs powersoftau verify on the phase-1 file passes, and snarkjs zkey verify (R1CS + ptau + zkey) passes and is run by the build script. Artifact hashes are recorded in packages/sdk/artifacts/manifest.json:
| File | sha256 |
|---|---|
ghost.wasm | 9521bc5227bb41eb0a87610e555bd99d68221e15ca440eafd502c77b74cb4703 |
ghost_final.zkey | 332b59a858a45cbaf4533a07a7909c56834dce770bed28620b369baa81626708 |
verification_key.json | 8bad46bbb214054c4b5b2ebf83a2089b961207c0863ab72a2f9d537c3c2a4108 |
⚠️ A single-party phase 2 means the person who ran the build could, in principle, have kept the toxic waste and could forge proofs. Before production, run a multi-party phase-2 ceremony (e.g. with
snarkjs zkey contributechained across independent participants, or a hosted ceremony tool) and finish with a public, unpredictable beacon (e.g. a future Solana blockhash). Rebuilding changes the verification key — every verifier must update.
Rebuilding
# Linux/macOS: install circom 2.2.x; Windows: the script uses tools/circom.exe
curl -L -o tools/ppot_0080_14.ptau https://pse-trusted-setup-ppot.s3.eu-central-1.amazonaws.com/pot28_0080/ppot_0080_14.ptau
npm run circuits:build # add -- --skip-ptau-verify to skip the slow phase-1 check
The script compiles, sets up, verifies the zkey and copies ghost.wasm, ghost_final.zkey, verification_key.json and manifest.json into packages/sdk/artifacts/ and app/public/circuits/.
Performance
| Operation | Time |
|---|---|
| Proof generation (Node, snarkjs) | ~0.3 s (measured: 20 proofs in the protocol suite take ~6 s) |
| Proof generation (browser) | 4.5 s measured in desktop Chrome on first run, including the cold 4 MB zkey download (Verify → Device self-test reports it for any device) |
| Verification | ~120 ms in browser (measured) |
| zkey download | 4.2 MB (cacheable) |