SP1ProofVerifier
The SP1ProofVerifier contract performs on-chain verification of Groth16 zero-knowledge proofs over the BN254 (alt_bn128) elliptic curve. It is the cryptographic gatekeeper of Ghost Protocol: every reveal operation must pass through this contract, which validates that the prover possesses knowledge of a valid commitment in the Merkle tree without learning anything about which commitment was used.
Overview
| Property | Value |
|---|---|
| Proof system | Groth16 |
| Curve | BN254 (alt_bn128) |
| Verification gas cost | ~220,000 gas |
| Proof size | 256 bytes (constant) |
| Public inputs | 8 field elements |
| EVM precompile | EIP-197 BN254 pairing check (0x08) |
Groth16 was selected for Specter because of its constant-size proofs, fast verification, and native EVM support via the BN254 pairing precompile. Every proof is exactly 256 bytes regardless of the circuit complexity, and verification cost is fixed at approximately 220k gas regardless of the number of constraints in the circuit.
Contract Interface
interface ISP1ProofVerifier {
/// @notice Verifies a Groth16 proof against the verification key
/// @param proof The serialized Groth16 proof (256 bytes: 3 G1/G2 points)
/// @param publicInputs Array of 8 public input field elements
/// @return bool True if the proof is valid
function verifyProof(
bytes calldata proof,
uint256[8] calldata publicInputs
) external view returns (bool);
}
The contract is stateless and view -- it performs pure computation with no state writes. This means verification can be called by any contract or off-chain process without gas cost when used in a static call context.
Public Inputs Layout
The 8 public inputs encode everything the verifier needs to check without revealing the private commitment data. Each input is a BN254 scalar field element (254 bits, range [0, p) where p = 21888242871839275222246405745257275088548364400416034343698204186575808495617).
| Index | Name | Description |
|---|---|---|
| 0 | root | Merkle tree root at the time of proof generation. Must exist in the CommitmentTree's 100-root ring buffer. |
| 1 | nullifier | Unique spend tag derived from Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex). Registered in NullifierRegistry to prevent double-spending. |
| 2 | withdrawAmount | The amount being withdrawn from the commitment. May be less than or equal to the committed amount (partial withdrawals produce change). |
| 3 | recipient | The address receiving the withdrawn funds, reduced to a field element. Binding the recipient inside the proof prevents front-running attacks. |
| 4 | changeCommitment | A new commitment for the remaining balance (amount - withdrawAmount). If the full amount is withdrawn, this is a commitment to zero. |
| 5 | tokenId | Token identifier derived as Poseidon2(tokenAddress, 0). Ensures the proof is bound to a specific asset type. |
| 6 | policyId | Identifier of the policy contract governing this commitment. 0 indicates no policy. |
| 7 | policyParamsHash | keccak256(policyParams) % BN254_FIELD. Binds the proof to specific policy parameters. 0 when no policy is set. |
Input Encoding
Public inputs are passed as a fixed-size array of uint256 values. Each value must be a valid BN254 scalar field element. The circuit enforces field membership, and any input outside the range [0, p) will cause verification to fail.
uint256[8] memory publicInputs = [
merkleRoot, // from CommitmentTree.roots[]
nullifier, // derived in-circuit
withdrawAmount, // user-specified
uint256(uint160(recipient)), // address cast to uint256
changeCommitment, // computed by client
tokenId, // Poseidon2(tokenAddress, 0)
policyId, // from commitment metadata
policyParamsHash // keccak256(params) % BN254_FIELD
];
Groth16 Proof Structure
A Groth16 proof consists of three elliptic curve points:
| Point | Curve Group | Size | Description |
|---|---|---|---|
| A | G1 | 64 bytes | First proof element (2 x 32-byte coordinates) |
| B | G2 | 128 bytes | Second proof element (2 x 2 x 32-byte coordinates, on the twist curve) |
| C | G1 | 64 bytes | Third proof element (2 x 32-byte coordinates) |
Total: 256 bytes (constant regardless of circuit size).
The proof bytes are serialized as:
proof = A.x || A.y || B.x[1] || B.x[0] || B.y[1] || B.y[0] || C.x || C.y
Note the reversed coordinate ordering for the G2 point B -- this matches the EVM precompile's expected format for the twisted curve representation.
Verification Algorithm
The verifier checks the Groth16 pairing equation:
e(A, B) = e(alpha, beta) * e(sum_of_public_inputs, gamma) * e(C, delta)
Where e is the BN254 optimal Ate pairing, and alpha, beta, gamma, delta are elements of the verification key generated during the trusted setup.
Step-by-Step Verification
-
Deserialize the proof into curve points
A(G1),B(G2),C(G1). -
Compute the public input accumulator by performing a multi-scalar multiplication over the verification key's IC (input commitment) points:
vk_x = IC[0] + publicInputs[0] * IC[1] + publicInputs[1] * IC[2] + ... + publicInputs[7] * IC[8]
This is computed using the ecMul (0x07) and ecAdd (0x06) precompiles.
- Execute the pairing check using the
ecPairingprecompile at address0x08:
ecPairing(
negate(A), B,
alpha1, beta2,
vk_x, gamma2,
C, delta2
) == 1
The pairing precompile returns 1 if the pairing equation holds, 0 otherwise.
EIP-197 Precompile Usage
The contract relies on three EVM precompiles defined in EIP-196 and EIP-197:
| Address | Precompile | Operation | Gas Cost |
|---|---|---|---|
0x06 | ecAdd | BN254 G1 point addition | 150 gas |
0x07 | ecMul | BN254 G1 scalar multiplication | 6,000 gas |
0x08 | ecPairing | BN254 pairing check | 45,000 + 34,000 per pair |
The total verification cost breaks down approximately as:
| Operation | Count | Gas |
|---|---|---|
ecMul (public input accumulation) | 8 | 48,000 |
ecAdd (public input accumulation) | 8 | 1,200 |
ecPairing (4 pairs) | 1 | 181,000 |
| Calldata + overhead | -- | ~10,000 |
| Total | ~220,000 |
Solidity Implementation Pattern
function verifyProof(
bytes calldata proof,
uint256[8] calldata publicInputs
) external view returns (bool) {
// 1. Compute the linear combination of public inputs with IC points
// vk_x = IC[0] + sum(publicInputs[i] * IC[i+1])
uint256[2] memory vk_x = [IC_0_X, IC_0_Y];
for (uint256 i = 0; i < 8; i++) {
// ecMul: publicInputs[i] * IC[i+1]
(uint256 mx, uint256 my) = ecMul(IC[i+1], publicInputs[i]);
// ecAdd: accumulate into vk_x
(vk_x[0], vk_x[1]) = ecAdd(vk_x[0], vk_x[1], mx, my);
}
// 2. Decode proof points
(uint256 ax, uint256 ay) = decodeG1(proof, 0);
// B is G2 - decoded as 4 uint256 values
(uint256 bx1, uint256 bx0, uint256 by1, uint256 by0) = decodeG2(proof, 64);
(uint256 cx, uint256 cy) = decodeG1(proof, 192);
// 3. Negate A (flip y-coordinate: y -> p - y)
uint256 nay = (BN254_P - ay) % BN254_P;
// 4. Pairing check: e(-A,B) * e(alpha,beta) * e(vk_x,gamma) * e(C,delta) == 1
return ecPairing([
ax, nay, bx1, bx0, by1, by0, // -A, B
ALPHA_X, ALPHA_Y, BETA_X1, BETA_X0, BETA_Y1, BETA_Y0, // alpha, beta
vk_x[0], vk_x[1], GAMMA_X1, GAMMA_X0, GAMMA_Y1, GAMMA_Y0, // vk_x, gamma
cx, cy, DELTA_X1, DELTA_X0, DELTA_Y1, DELTA_Y0 // C, delta
]);
}
Verification Key
The verification key is embedded as immutable constants in the contract. It is generated during the trusted setup ceremony and is specific to the GhostRedemption circuit. The key consists of:
| Component | Type | Description |
|---|---|---|
alpha1 | G1 point | Trusted setup element |
beta2 | G2 point | Trusted setup element |
gamma2 | G2 point | Trusted setup element |
delta2 | G2 point | Trusted setup element |
IC[0..8] | G1 points (9 total) | Input commitment points: one base point plus one per public input |
The verification key is hardcoded rather than stored in storage to minimize gas costs. Since these values never change after deployment, there is no reason to pay SLOAD costs on every verification.
Trusted Setup
The Groth16 proving system requires a circuit-specific trusted setup to generate the proving key and verification key. Specter's trusted setup has been completed for the GhostRedemption circuit.
Setup Properties
- Ceremony type: Powers-of-tau (Phase 1) + circuit-specific contribution (Phase 2)
- Circuit: GhostRedemption with tree depth 20
- Toxic waste: Destroyed after ceremony completion -- if any single participant in the ceremony is honest, the setup is secure
- Output artifacts:
proving_key.zkey,verification_key.json, and the on-chain verifier contract
Security Assumption
Groth16's security relies on the assumption that the toxic waste (the secret randomness used during the trusted setup) was properly destroyed. If an attacker obtains the toxic waste, they can forge proofs for arbitrary statements. The multi-party ceremony ensures this cannot happen as long as at least one participant behaved honestly.
Integration with CommitRevealVault
The SP1ProofVerifier is called by CommitRevealVault during every reveal operation:
// Inside CommitRevealVault.reveal()
function reveal(
bytes calldata proof,
uint256 root,
uint256 nullifier,
uint256 withdrawAmount,
address recipient,
uint256 changeCommitment,
uint256 tokenId,
uint256 policyId,
uint256 policyParamsHash
) external {
// 1. Verify the Merkle root is known
require(commitmentTree.isKnownRoot(root), "Unknown root");
// 2. Verify the nullifier has not been spent
require(!nullifierRegistry.isSpent(nullifier), "Nullifier already spent");
// 3. Construct public inputs array
uint256[8] memory publicInputs = [
root,
nullifier,
withdrawAmount,
uint256(uint160(recipient)),
changeCommitment,
tokenId,
policyId,
policyParamsHash
];
// 4. Verify the ZK proof
require(proofVerifier.verifyProof(proof, publicInputs), "Invalid proof");
// 5. Register the nullifier as spent
nullifierRegistry.spend(nullifier);
// 6. Process the withdrawal (mint/transfer to recipient)
// 7. Insert change commitment into the tree (if non-zero withdrawal)
// ...
}
Client-Side Proof Generation
Proofs are generated on the client side using snarkjs compiled to WebAssembly. The client loads the proving key and WASM circuit, provides the private inputs (secret, nullifierSecret, amount, blinding, Merkle path), and snarkjs produces the 256-byte proof along with the 8 public inputs.
import * as snarkjs from "snarkjs";
async function generateRedemptionProof(
secret: bigint,
nullifierSecret: bigint,
amount: bigint,
blinding: bigint,
pathElements: bigint[],
pathIndices: number[],
newBlinding: bigint,
withdrawAmount: bigint,
recipient: string,
tokenId: bigint,
policyId: bigint,
policyParamsHash: bigint
) {
const input = {
// Private inputs
secret,
nullifierSecret,
amount,
blinding,
pathElements,
pathIndices,
newBlinding,
// Public inputs (also provided to circuit)
withdrawAmount,
recipient: BigInt(recipient),
tokenId,
policyId,
policyParamsHash,
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
input,
"/circuits/GhostRedemption.wasm",
"/circuits/GhostRedemption.zkey"
);
// Format proof for on-chain submission
const calldata = await snarkjs.groth16.exportSolidityCallData(
proof,
publicSignals
);
return { proof: calldata.proof, publicInputs: calldata.inputs };
}
Gas Optimization Notes
The ~220k gas verification cost is a fixed overhead on every reveal transaction. Several design decisions minimize this cost:
- Hardcoded verification key: All VK points are contract constants, avoiding SLOAD operations (~2,100 gas each).
- Assembly-level precompile calls: The ecMul, ecAdd, and ecPairing calls use inline assembly with
staticcallto avoid Solidity's ABI encoding overhead. - Single pairing check: The pairing equation is batched into a single
ecPairingcall with 4 point pairs, rather than multiple separate pairing calls. - No proof deserialization storage: Proof points are decoded from calldata directly into memory without intermediate storage writes.
Comparison: Groth16 vs SP1
Specter currently uses Groth16 for production verification and plans to add SP1 as an alternative proving backend:
| Property | Groth16 (Current) | SP1 (Planned) |
|---|---|---|
| Proof size | 256 bytes (constant) | ~1-10 KB (varies) |
| Verification gas | ~220k | ~300-500k (estimated) |
| Trusted setup | Required (circuit-specific) | None (transparent) |
| Prover language | Circom (DSL) | Rust (general-purpose) |
| Quantum resistance | None (BN254 is vulnerable) | Possible with PQ signature schemes |
| Status | Live in production | In development |
The SP1 path eliminates the trusted setup requirement and opens the door to post-quantum proof systems, at the cost of larger proofs and higher verification gas. Both systems will coexist, with Groth16 serving as the default for gas-sensitive operations and SP1 available for users requiring stronger security assumptions.