Skip to main content

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

PropertyValue
Proof systemGroth16
CurveBN254 (alt_bn128)
Verification gas cost~220,000 gas
Proof size256 bytes (constant)
Public inputs8 field elements
EVM precompileEIP-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).

IndexNameDescription
0rootMerkle tree root at the time of proof generation. Must exist in the CommitmentTree's 100-root ring buffer.
1nullifierUnique spend tag derived from Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex). Registered in NullifierRegistry to prevent double-spending.
2withdrawAmountThe amount being withdrawn from the commitment. May be less than or equal to the committed amount (partial withdrawals produce change).
3recipientThe address receiving the withdrawn funds, reduced to a field element. Binding the recipient inside the proof prevents front-running attacks.
4changeCommitmentA new commitment for the remaining balance (amount - withdrawAmount). If the full amount is withdrawn, this is a commitment to zero.
5tokenIdToken identifier derived as Poseidon2(tokenAddress, 0). Ensures the proof is bound to a specific asset type.
6policyIdIdentifier of the policy contract governing this commitment. 0 indicates no policy.
7policyParamsHashkeccak256(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:

PointCurve GroupSizeDescription
AG164 bytesFirst proof element (2 x 32-byte coordinates)
BG2128 bytesSecond proof element (2 x 2 x 32-byte coordinates, on the twist curve)
CG164 bytesThird 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

  1. Deserialize the proof into curve points A (G1), B (G2), C (G1).

  2. 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.

  1. Execute the pairing check using the ecPairing precompile at address 0x08:
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:

AddressPrecompileOperationGas Cost
0x06ecAddBN254 G1 point addition150 gas
0x07ecMulBN254 G1 scalar multiplication6,000 gas
0x08ecPairingBN254 pairing check45,000 + 34,000 per pair

The total verification cost breaks down approximately as:

OperationCountGas
ecMul (public input accumulation)848,000
ecAdd (public input accumulation)81,200
ecPairing (4 pairs)1181,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:

ComponentTypeDescription
alpha1G1 pointTrusted setup element
beta2G2 pointTrusted setup element
gamma2G2 pointTrusted setup element
delta2G2 pointTrusted 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:

  1. Hardcoded verification key: All VK points are contract constants, avoiding SLOAD operations (~2,100 gas each).
  2. Assembly-level precompile calls: The ecMul, ecAdd, and ecPairing calls use inline assembly with staticcall to avoid Solidity's ABI encoding overhead.
  3. Single pairing check: The pairing equation is batched into a single ecPairing call with 4 point pairs, rather than multiple separate pairing calls.
  4. 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:

PropertyGroth16 (Current)SP1 (Planned)
Proof size256 bytes (constant)~1-10 KB (varies)
Verification gas~220k~300-500k (estimated)
Trusted setupRequired (circuit-specific)None (transparent)
Prover languageCircom (DSL)Rust (general-purpose)
Quantum resistanceNone (BN254 is vulnerable)Possible with PQ signature schemes
StatusLive in productionIn 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.