GhostRedemption Circuit
The GhostRedemption(20) circuit is the core ZK circuit powering Ghost Protocol's token privacy. It encodes the complete set of constraints that a prover must satisfy to redeem (reveal) a committed token value from the CommitRevealVault. The circuit proves six things simultaneously, all in zero knowledge:
- The prover knows the preimage of a commitment in the Merkle tree
- The commitment exists in the tree (Merkle membership)
- The nullifier is correctly derived (prevents double-spending)
- The withdrawal amount does not exceed the committed amount (conservation)
- The change commitment is valid (leftover balance is re-committed)
- The proof is bound to a specific recipient and policy (prevents front-running)
Circuit Parameters
template GhostRedemption(depth) { ... }
// Instantiated with depth = 20
component main {public [
root,
nullifier,
withdrawAmount,
recipient,
changeCommitment,
tokenId,
policyId,
policyParamsHash
]} = GhostRedemption(20);
The depth parameter (20) determines the Merkle tree depth, which sets the tree capacity at 2^20 = 1,048,576 leaves and requires 20 levels of path verification in the Merkle membership proof.
Signal Layout
Public Inputs (8)
These values are visible to the on-chain verifier and are included in the proof's public signals:
| Index | Signal | Type | Description |
|---|---|---|---|
| 0 | root | Field element | Merkle tree root. Must match a root in the CommitmentTree's 100-root ring buffer. |
| 1 | nullifier | Field element | Spend tag: Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex). Registered on-chain to prevent double-spending. |
| 2 | withdrawAmount | Field element | Amount being withdrawn. Must be <= amount (the committed amount). |
| 3 | recipient | Field element | Address of the withdrawal recipient (as a field element). Bound in the proof to prevent front-running. |
| 4 | changeCommitment | Field element | New commitment for the remaining balance (amount - withdrawAmount). Inserted into the tree after reveal. |
| 5 | tokenId | Field element | Poseidon2(tokenAddress, 0). Identifies which token is being redeemed. |
| 6 | policyId | Field element | Policy contract identifier. 0 for no policy. |
| 7 | policyParamsHash | Field element | keccak256(policyParams) % BN254_FIELD. 0 for no policy. |
Private Inputs (7)
These values are known only to the prover and are never revealed:
| Signal | Type | Description |
|---|---|---|
secret | Field element | 31-byte random secret. Proves ownership of the commitment. |
nullifierSecret | Field element | 31-byte random secret used in nullifier derivation. Separate from secret to enable delegation. |
amount | Field element | The full committed token amount (in base units). |
blinding | Field element | Random blinding factor from the original commitment. |
pathElements[20] | Array of 20 field elements | Sibling hashes along the Merkle path from leaf to root. |
pathIndices[20] | Array of 20 bits (0 or 1) | Direction bits: 0 = leaf is left child, 1 = leaf is right child at each level. |
newBlinding | Field element | Fresh random blinding factor for the change commitment. |
Constraint Breakdown
The circuit enforces six constraint groups. Each group is described below with its mathematical formulation and Circom implementation.
Constraint 1: Commitment Preimage (Poseidon7)
The prover must demonstrate knowledge of the 7-input preimage that produces a valid commitment:
commitment = Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash)
This is the fundamental ownership proof. Without knowing all 7 preimage values, the prover cannot construct a valid commitment that matches a leaf in the tree.
// Compute the commitment from private inputs
component commitmentHasher = PoseidonT8(7);
commitmentHasher.inputs[0] <== secret;
commitmentHasher.inputs[1] <== nullifierSecret;
commitmentHasher.inputs[2] <== tokenId;
commitmentHasher.inputs[3] <== amount;
commitmentHasher.inputs[4] <== blinding;
commitmentHasher.inputs[5] <== policyId;
commitmentHasher.inputs[6] <== policyParamsHash;
signal commitment <== commitmentHasher.out;
Constraint count: ~1,750 R1CS constraints (Poseidon7/PoseidonT8).
Constraint 2: Merkle Membership
The prover must show that the computed commitment exists as a leaf in the Merkle tree with the given root. This is a standard 20-level Merkle path verification:
For each level i from 0 to 19:
if pathIndices[i] == 0:
currentHash = Poseidon2(currentHash, pathElements[i])
else:
currentHash = Poseidon2(pathElements[i], currentHash)
assert currentHash == root
The verification starts at the leaf (the commitment) and hashes upward through 20 levels using the sibling hashes provided in pathElements. The pathIndices bits determine whether the current node is the left or right child at each level.
component merkleProof = MerkleTreeChecker(20);
merkleProof.leaf <== commitment;
merkleProof.root <== root;
for (var i = 0; i < 20; i++) {
merkleProof.pathElements[i] <== pathElements[i];
merkleProof.pathIndices[i] <== pathIndices[i];
}
The MerkleTreeChecker template internally uses a selector at each level:
template MerkleTreeChecker(depth) {
signal input leaf;
signal input root;
signal input pathElements[depth];
signal input pathIndices[depth];
signal hashes[depth + 1];
hashes[0] <== leaf;
component hashers[depth];
component selectors[depth];
for (var i = 0; i < depth; i++) {
// Ensure pathIndices[i] is binary
pathIndices[i] * (1 - pathIndices[i]) === 0;
// Select left and right inputs based on pathIndices[i]
selectors[i] = DualMux();
selectors[i].in[0] <== hashes[i];
selectors[i].in[1] <== pathElements[i];
selectors[i].sel <== pathIndices[i];
// Hash the pair
hashers[i] = PoseidonT3(2);
hashers[i].inputs[0] <== selectors[i].out[0];
hashers[i].inputs[1] <== selectors[i].out[1];
hashes[i + 1] <== hashers[i].out;
}
// Final hash must equal the public root
root === hashes[depth];
}
Constraint count: ~5,000 R1CS constraints (20 x Poseidon2 + 20 x binary check + 20 x mux).
Constraint 3: Nullifier Derivation
The nullifier must be correctly derived using the two-level Poseidon2 scheme. This binds the nullifier to both the commitment content and its position in the tree:
Step 1: innerNullifier = Poseidon2(nullifierSecret, commitment)
Step 2: leafIndex = pathIndices[0] + 2*pathIndices[1] + 4*pathIndices[2] + ... + 2^19*pathIndices[19]
Step 3: nullifier = Poseidon2(innerNullifier, leafIndex)
The leafIndex is reconstructed from the pathIndices bits. Since each bit indicates whether the node is a left (0) or right (1) child, the bits form the binary representation of the leaf's position in the tree.
// Step 1: Inner nullifier
component innerNullifierHasher = PoseidonT3(2);
innerNullifierHasher.inputs[0] <== nullifierSecret;
innerNullifierHasher.inputs[1] <== commitment;
signal innerNullifier <== innerNullifierHasher.out;
// Step 2: Reconstruct leaf index from path indices
signal leafIndex;
signal indexBits[20];
var indexSum = 0;
for (var i = 0; i < 20; i++) {
indexBits[i] <== pathIndices[i] * (2 ** i);
indexSum += indexBits[i];
}
leafIndex <== indexSum;
// Step 3: Final nullifier
component nullifierHasher = PoseidonT3(2);
nullifierHasher.inputs[0] <== innerNullifier;
nullifierHasher.inputs[1] <== leafIndex;
// Constrain against public input
nullifier === nullifierHasher.out;
Why two levels?
- Level 1 (
Poseidon2(nullifierSecret, commitment)) ensures the nullifier is bound to the commitment's content. Without knowingnullifierSecret, an observer cannot predict which commitment a nullifier corresponds to. - Level 2 (
Poseidon2(innerNullifier, leafIndex)) binds the nullifier to the leaf position. This ensures that if the same commitment value somehow appears in two different leaves, each has a unique nullifier -- preventing a scenario where spending one leaf inadvertently blocks the other.
Constraint count: ~500 R1CS constraints (2 x Poseidon2 + index reconstruction).
Constraint 4: Amount Conservation
The withdrawal amount must not exceed the committed amount. This is enforced via a 252-bit range check on the difference:
assert withdrawAmount <= amount
equivalently: assert (amount - withdrawAmount) is in range [0, 2^252)
The range check works by decomposing (amount - withdrawAmount) into 252 binary bits and constraining each bit to be 0 or 1. If the difference were negative (i.e., withdrawAmount > amount), the subtraction would underflow in the finite field, producing a value greater than 2^252, and the bit decomposition would fail.
// Compute the difference
signal amountDiff;
amountDiff <== amount - withdrawAmount;
// 252-bit range check: decompose into bits and verify each is binary
component rangeCheck = Num2Bits(252);
rangeCheck.in <== amountDiff;
// Num2Bits internally constrains:
// - each bit is 0 or 1: bit[i] * (1 - bit[i]) === 0
// - reconstruction: sum(bit[i] * 2^i) === amountDiff
Why 252 bits (not 254)?
The BN254 scalar field is ~254 bits, but not all 254-bit values are valid field elements (the field prime p is slightly less than 2^254). Using 252 bits provides a comfortable margin: any 252-bit value is guaranteed to be less than p, so the range check is sound. If we used 254 bits, a carefully chosen value could wrap around the field modulus and pass the range check despite representing a negative difference.
Constraint count: ~252 R1CS constraints (252 binary checks + reconstruction).
Constraint 5: Change Commitment Validity
When the prover withdraws less than the full amount, the leftover balance must be re-committed. The circuit verifies that the change commitment is correctly formed:
changeAmount = amount - withdrawAmount
changeCommitment = Poseidon7(secret, nullifierSecret, tokenId, changeAmount, newBlinding, policyId, policyParamsHash)
The change commitment uses the same secret, nullifierSecret, tokenId, policyId, and policyParamsHash as the original commitment but with:
- The reduced amount (
changeAmount) - A fresh
newBlindingfactor (for commitment uniqueness)
// Compute the expected change commitment
signal changeAmount;
changeAmount <== amount - withdrawAmount;
component changeHasher = PoseidonT8(7);
changeHasher.inputs[0] <== secret;
changeHasher.inputs[1] <== nullifierSecret;
changeHasher.inputs[2] <== tokenId;
changeHasher.inputs[3] <== changeAmount;
changeHasher.inputs[4] <== newBlinding;
changeHasher.inputs[5] <== policyId;
changeHasher.inputs[6] <== policyParamsHash;
// Constrain against public input
changeCommitment === changeHasher.out;
Policy binding in change commitments: The change commitment inherits the same policyId and policyParamsHash as the original commitment. This ensures that policy restrictions cannot be stripped by performing a partial withdrawal -- the change output remains subject to the same policy.
Full withdrawal case: When withdrawAmount == amount, the changeAmount is 0. The circuit still computes and constrains the change commitment. The on-chain contract inserts this zero-value commitment into the tree. If someone later tries to reveal this change commitment, the amount conservation check (Constraint 4) will allow withdrawing 0 or less, rendering it harmless.
Constraint count: ~1,750 R1CS constraints (Poseidon7).
Constraint 6: Recipient and Policy Binding
The proof must be bound to a specific recipient address, token ID, policy ID, and policy parameters hash. This is enforced through quadratic constraints that include these public signals in the circuit's constraint system:
// Bind recipient -- create a quadratic constraint involving recipient
signal recipientSquare;
recipientSquare <== recipient * recipient;
// Bind tokenId
signal tokenIdSquare;
tokenIdSquare <== tokenId * tokenId;
// Bind policyId
signal policyIdSquare;
policyIdSquare <== policyId * policyId;
// Bind policyParamsHash
signal policyParamsHashSquare;
policyParamsHashSquare <== policyParamsHash * policyParamsHash;
Why quadratic constraints?
In Groth16, public inputs that are not referenced by any constraint can be freely modified by an attacker without invalidating the proof. By including each public input in at least one multiplicative constraint (even a trivial x * x), the proof becomes irrevocably bound to those specific values. If an attacker changes the recipient in the transaction calldata, the proof will fail verification because the pairing equation depends on the exact public input values.
This prevents front-running attacks where a miner or MEV bot observes a reveal transaction in the mempool and substitutes their own address as the recipient.
Constraint count: 4 R1CS constraints.
Total Constraint Summary
| Constraint Group | Constraints (approx.) |
|---|---|
| Commitment preimage (Poseidon7) | 1,750 |
| Merkle membership (20 levels) | 5,000 |
| Nullifier derivation (2 x Poseidon2) | 500 |
| Amount conservation (252-bit range check) | 252 |
| Change commitment (Poseidon7) | 1,750 |
| Recipient/policy binding (quadratic) | 4 |
| Total | ~9,256 |
The actual constraint count may vary slightly depending on the Circom compiler version and optimization settings. With overhead from signal routing and auxiliary constraints, the full circuit compiles to approximately 45,000 constraints.
Data Flow Diagram
Proof Generation Example
import * as snarkjs from "snarkjs";
// All inputs for the GhostRedemption circuit
const circuitInputs = {
// Private inputs
secret: "0x1a2b3c...", // 31-byte random secret
nullifierSecret: "0x4d5e6f...", // 31-byte nullifier secret
amount: "1000000000000000000", // 1 GHOST (18 decimals)
blinding: "0x7a8b9c...", // 31-byte blinding factor
pathElements: [ // 20 sibling hashes
"12345...", "67890...", /* ... 18 more */
],
pathIndices: [ // 20 direction bits
0, 1, 0, 0, 1, /* ... 15 more */
],
newBlinding: "0xaabbcc...", // Fresh blinding for change
// Public inputs (also provided to circuit for constraint checking)
withdrawAmount: "500000000000000000", // 0.5 GHOST
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
tokenId: "18394027...", // Poseidon2(GHOST_ADDRESS, 0)
policyId: "0", // No policy
policyParamsHash: "0", // No policy
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
circuitInputs,
"/circuits/GhostRedemption.wasm",
"/circuits/GhostRedemption.zkey"
);
// publicSignals = [root, nullifier, withdrawAmount, recipient,
// changeCommitment, tokenId, policyId, policyParamsHash]
console.log("Root:", publicSignals[0]);
console.log("Nullifier:", publicSignals[1]);
console.log("Withdraw amount:", publicSignals[2]);
console.log("Change commitment:", publicSignals[4]);
Security Properties
Soundness
A malicious prover cannot generate a valid proof if:
- They do not know the commitment preimage (secret, nullifierSecret, amount, blinding)
- The commitment is not in the Merkle tree
- The nullifier does not match the commitment
- The withdrawal exceeds the committed amount
- The change commitment is malformed
The soundness guarantee is computational (based on the hardness of the discrete logarithm problem on BN254) and relies on the trusted setup being honest.
Zero-Knowledge
The proof reveals nothing about:
- Which commitment in the tree is being spent
- The committed amount (only the withdrawal amount is public)
- The secret or nullifier secret
- The blinding factor
- The Merkle path (which leaf position)
An observer sees only the 8 public inputs and the proof bytes. The nullifier is unlinkable to the commitment without knowing the nullifier secret.
Non-Malleability
The recipient and policy binding (Constraint 6) ensures proofs cannot be modified in transit. A MEV bot cannot change the recipient address in a pending transaction -- the modified public inputs would cause the pairing check to fail.