Client-Side ZK Proof Generation
Generate Groth16 proofs directly in the browser using snarkjs. This is the most private option — your secret values never leave your device.
Installation
npm install snarkjs circomlibjs
Poseidon hashing
import { buildPoseidon } from 'circomlibjs';
const poseidon = await buildPoseidon();
const F = poseidon.F;
// Hash commitment preimage
const commitment = poseidon([secret, nullifierSecret, tokenIdHash, amount, blinding]);
const commitmentHex = '0x' + F.toString(commitment, 16).padStart(64, '0');
// Hash nullifier
const leafIndex = BigInt(12345); // from Merkle tree
const nullifier = poseidon([nullifierSecret, leafIndex]);
Building a Merkle tree
// Fetch all commitments from chain
const events = await treeContract.queryFilter('CommitmentAdded');
const leaves = events.map(e => e.args.commitment);
// Build tree in-memory
class MerkleTree {
constructor(depth, leaves, poseidon) {
this.depth = depth;
this.poseidon = poseidon;
this.F = poseidon.F;
this.layers = [leaves.map(l => BigInt(l))];
for (let i = 0; i < depth; i++) {
const layer = this.layers[i];
const nextLayer = [];
for (let j = 0; j < layer.length; j += 2) {
const left = layer[j] || 0n;
const right = layer[j + 1] || 0n;
nextLayer.push(this.F.toObject(poseidon([left, right])));
}
this.layers.push(nextLayer);
}
}
getProof(index) {
const pathElements = [];
const pathIndices = [];
for (let i = 0; i < this.depth; i++) {
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
pathElements.push((this.layers[i][siblingIndex] || 0n).toString());
pathIndices.push(index % 2);
index = Math.floor(index / 2);
}
return { pathElements, pathIndices };
}
get root() {
return this.layers[this.depth][0];
}
}
Generating a proof
import * as snarkjs from 'snarkjs';
// Circuit artifacts (fetch from CDN)
const wasmFile = await fetch('/circuits/redemption.wasm');
const zkeyFile = await fetch('/circuits/redemption_final.zkey');
const input = {
root: tree.root.toString(),
nullifier: nullifier.toString(),
withdrawAmount: withdrawAmount.toString(),
recipient: BigInt(recipientAddress).toString(),
changeCommitment: '0',
tokenId: tokenIdHash.toString(),
policyId: '0',
policyParamsHash: '0',
secret: secret.toString(),
nullifierSecret: nullifierSecret.toString(),
amount: amount.toString(),
blinding: blinding.toString(),
pathElements: merkleProof.pathElements,
pathIndices: merkleProof.pathIndices,
newBlinding: '0',
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
input,
wasmBuffer,
zkeyBuffer
);
Formatting for on-chain submission
// Convert proof to Solidity-compatible format
const calldata = await snarkjs.groth16.exportSolidityCallData(proof, publicSignals);
const [proofFormatted, publicInputsFormatted] = JSON.parse('[' + calldata + ']');
// proofFormatted is uint256[8]
// publicInputsFormatted is uint256[]
Performance
| Device | Proof generation time |
|---|---|
| Desktop (M1/M2) | 2–5 seconds |
| Desktop (Intel i7) | 5–10 seconds |
| Mobile (modern) | 10–30 seconds |
| Mobile (older) | 30–60 seconds |
Proof generation is CPU-intensive. Consider showing a loading indicator and generating proofs in a Web Worker to avoid blocking the UI thread.
Web Worker pattern
// worker.js
import * as snarkjs from 'snarkjs';
self.onmessage = async (e) => {
const { input, wasmBuffer, zkeyBuffer } = e.data;
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasmBuffer, zkeyBuffer);
self.postMessage({ proof, publicSignals });
};