Skip to main content

Client-Side Proofs

Specter's privacy model relies on zero-knowledge proofs generated entirely on the client. No server ever sees the user's secret data. This guide covers how to generate Groth16 proofs using circomlibjs and snarkjs in JavaScript, format them for on-chain submission, and handle BN254 field arithmetic.

Overview

The reveal flow requires a Groth16 proof over the BN254 (alt-bn128) elliptic curve. The proof demonstrates that:

  1. The prover knows a valid commitment (secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash) whose Poseidon7 hash exists as a leaf in the on-chain Merkle tree.
  2. The prover correctly derived the nullifier from the nullifierSecret.
  3. The Merkle proof is valid (the commitment is in the tree at the claimed leaf index).

The proof reveals nothing about which commitment in the tree belongs to the prover.

Prerequisites

Install the required packages:

npm install snarkjs circomlibjs

You also need the circuit artifacts:

  • Circuit WASM (ghostRedemption.wasm) — the compiled circuit for witness generation.
  • Proving key (ghostRedemption_final.zkey) — the Groth16 proving key from the trusted setup.
  • Verification key (verification_key.json) — for local proof verification (optional; on-chain verification uses the deployed verifier contract).

These artifacts are distributed with the Specter webapp and are also available from the project's release assets.

Step 1: Poseidon Hashing with circomlibjs

The circomlibjs library provides a JavaScript implementation of the Poseidon hash function that is compatible with the on-chain Poseidon contracts and the circuit.

Build the Poseidon Instance

import { buildPoseidon } from "circomlibjs";

// buildPoseidon() is async — it initializes the WASM module
const poseidon = await buildPoseidon();

The poseidon object is a function that accepts an array of BigInt inputs and returns a field element. Use poseidon.F.toObject() to convert the result to a JavaScript BigInt:

// Poseidon hash of two inputs (Poseidon2 / T3)
const hash2 = poseidon.F.toObject(poseidon([input1, input2]));

// Poseidon hash of four inputs (Poseidon4 / T5)
const hash4 = poseidon.F.toObject(poseidon([a, b, c, d]));

// Poseidon hash of seven inputs (Poseidon7 / T8)
const hash7 = poseidon.F.toObject(poseidon([a, b, c, d, e, f, g]));

Compute the Commitment

const commitment = poseidon.F.toObject(
poseidon([
secret,
nullifierSecret,
blinding,
tokenIdHash,
amount,
policyId,
policyParamsHash,
])
);

Compute the Nullifier

The nullifier prevents double-reveals. It is derived from the nullifierSecret and the leafIndex:

const nullifier = poseidon.F.toObject(
poseidon([nullifierSecret, BigInt(leafIndex)])
);

Compute the Access Tag

The access tag is used for stealth/persistent key lookups:

const accessTag = poseidon.F.toObject(
poseidon([secret, tokenIdHash])
);

Step 2: Build the Merkle Proof

The circuit requires a Merkle proof demonstrating that the commitment exists in the on-chain CommitmentTree. You need:

  • The leaf (your commitment).
  • The leaf index in the tree.
  • The sibling hashes (path elements) from the leaf to the root.
  • The path indices (0 for left, 1 for right at each level).

Fetch the Merkle proof from the Specter relayer or reconstruct it from on-chain data:

// Option 1: Fetch from the relayer API
const response = await fetch(
`https://relayer.specterchain.com/api/merkle-proof?leafIndex=${leafIndex}`
);
const { root, pathElements, pathIndices } = await response.json();

// Option 2: Reconstruct from on-chain events
// Query CommitmentInserted events from CommitmentTree (0xE29DD14998f6FE8e7862571c883090d14FE29475)
// and rebuild the tree locally using Poseidon2 for internal nodes.

Step 3: Generate the Groth16 Proof

Use snarkjs to generate the proof. The groth16.fullProve function takes the circuit inputs, WASM file, and zkey file:

import * as snarkjs from "snarkjs";

const circuitInputs = {
// Private inputs (not revealed on-chain)
secret: secret.toString(),
nullifierSecret: nullifierSecret.toString(),
blinding: blinding.toString(),
amount: amount.toString(),
policyId: policyId.toString(),
policyParamsHash: policyParamsHash.toString(),
leafIndex: leafIndex.toString(),
pathElements: pathElements.map((e) => e.toString()),
pathIndices: pathIndices.map((i) => i.toString()),

// Public inputs (revealed on-chain, verified by the contract)
root: root.toString(),
nullifier: nullifier.toString(),
tokenIdHash: tokenIdHash.toString(),
recipient: BigInt(recipientAddress).toString(),
};

const { proof, publicSignals } = await snarkjs.groth16.fullProve(
circuitInputs,
"ghostRedemption.wasm", // Path to circuit WASM
"ghostRedemption_final.zkey" // Path to proving key
);

console.log("Proof generated successfully");
console.log("Public signals:", publicSignals);

Proof Generation Time

Proof generation is CPU-intensive. Expect:

  • Desktop browser: 3-8 seconds
  • Mobile browser: 10-30 seconds
  • Node.js: 2-5 seconds

The webapp runs proof generation in a Web Worker to avoid blocking the UI.

Step 4: Format the Proof for On-Chain Submission

The on-chain GhostRedemptionVerifier (0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee) expects the proof in a specific format. Convert the snarkjs output to Solidity-compatible calldata:

// Method 1: Use snarkjs exportSolidityCallData
const calldataRaw = await snarkjs.groth16.exportSolidityCallData(
proof,
publicSignals
);

// Parse the calldata string into structured values
const calldata = JSON.parse("[" + calldataRaw + "]");

const proofA = calldata[0]; // uint256[2] - point on G1
const proofB = calldata[1]; // uint256[2][2] - point on G2
const proofC = calldata[2]; // uint256[2] - point on G1
const publicInputs = calldata[3]; // uint256[] - public signals

Manual Formatting

If you need more control, format the proof manually:

function formatProofForContract(proof) {
return {
a: [BigInt(proof.pi_a[0]), BigInt(proof.pi_a[1])],
b: [
[BigInt(proof.pi_b[0][1]), BigInt(proof.pi_b[0][0])], // Note: reversed order for BN254
[BigInt(proof.pi_b[1][1]), BigInt(proof.pi_b[1][0])],
],
c: [BigInt(proof.pi_c[0]), BigInt(proof.pi_c[1])],
};
}

const formattedProof = formatProofForContract(proof);
BN254 G2 Point Ordering

The pi_b coordinates are in reversed order compared to snarkjs output. The Solidity verifier expects [y, x] order for each G2 coordinate pair, while snarkjs outputs [x, y]. The exportSolidityCallData function handles this automatically.

Step 5: Submit the Reveal Transaction

import { ethers } from "ethers";

const vaultAbi = [
"function reveal(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] publicInputs, address token, uint256 amount, address recipient, address policyId, bytes policyParams) external"
];

const vault = new ethers.Contract(
"0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a",
vaultAbi,
signer
);

const tx = await vault.reveal(
proofA,
proofB,
proofC,
publicInputs,
tokenAddress,
amount,
recipientAddress,
policyId,
policyParams
);

const receipt = await tx.wait();
console.log("Reveal confirmed:", receipt.hash);

BN254 Field Operations

All values in the circuit operate within the BN254 scalar field:

p = 21888242871839275222246405745257275088548364400416034343698204186575808495617

Field Reduction

Any value used as a circuit input must be reduced modulo p. This is particularly important for values derived from keccak256, which produces 256-bit outputs that can exceed the ~254-bit field:

const BN254_FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;

function toField(value) {
const v = BigInt(value);
return ((v % BN254_FIELD_PRIME) + BN254_FIELD_PRIME) % BN254_FIELD_PRIME;
}

// Example: reduce a keccak256 hash to a field element
const hash = ethers.keccak256(someData); // 256-bit
const fieldElement = toField(hash);

Address Conversion

Ethereum addresses (160-bit) always fit within the BN254 field without reduction:

const addressAsBigInt = BigInt("0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3");
// No reduction needed — 160-bit < 254-bit

Local Proof Verification (Optional)

Before submitting on-chain, you can verify the proof locally:

const vkey = JSON.parse(fs.readFileSync("verification_key.json", "utf8"));
const valid = await snarkjs.groth16.verify(vkey, publicSignals, proof);

if (!valid) {
throw new Error("Proof verification failed locally — do not submit on-chain");
}

Troubleshooting

  • "Scalar size does not match" error: A circuit input exceeds the BN254 field. Apply toField() to all values before passing them to fullProve.
  • Proof verification fails on-chain but passes locally: The public signals order may differ from what the contract expects. Check that publicSignals matches the contract's expected ordering (root, nullifier, tokenIdHash, recipient, etc.).
  • Out-of-memory in browser: The zkey file can be large (50-100 MB). Use streaming reads or serve the zkey from a CDN with range request support. snarkjs supports groth16.fullProve with a URL for the zkey.
  • Slow proof generation: Ensure the WASM file is being loaded correctly. Mismatched WASM and zkey versions will cause either errors or extreme slowness.