CA
GHOST

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)

IndexNameNotes
0nullifieroutput
1rootmust equal the attested snapshot root
2mintHimint bytes 0–15, big-endian
3mintLomint bytes 16–31
4minBalanceraw u64 base units
5appScopeSHA-256("ghost/app-scope/v1:"‖appId) mod p
6nullifierMode1 = wallet, 0 = identity
7challengeverifier nonce (< 2^248)
8expiresAtunix 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 pick balance such that the comparator's internal subtraction wraps modulo p. Both operands are range-checked before comparison.

Trusted setup

PhaseSource
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:

Filesha256
ghost.wasm9521bc5227bb41eb0a87610e555bd99d68221e15ca440eafd502c77b74cb4703
ghost_final.zkey332b59a858a45cbaf4533a07a7909c56834dce770bed28620b369baa81626708
verification_key.json8bad46bbb214054c4b5b2ebf83a2089b961207c0863ab72a2f9d537c3c2a4108

⚠️ 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 contribute chained 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

OperationTime
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 download4.2 MB (cacheable)