Client-Side Proof Generation
This guide covers generating zero-knowledge proofs in the browser using Groth16 and Halo2, based on the Kusama Shield Interface implementation.
Overview
Client-side proof generation enables users to create ZK proofs locally without trusting a server. The Kusama Shield Interface demonstrates two approaches:
- Groth16 with snarkjs - Browser-based proving using WebAssembly
- Halo2 with Rust/WASM - High-performance proving using wasm-bindgen
┌─────────────────────────────────────────────────────────────┐
│ User's Browser │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Frontend (React/Vite) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Proof Worker (Web Worker) │ │
│ │ │ │ │
│ │ ├─ snarkjs (Groth16) │ │
│ │ │ - Load .wasm circuit │ │
│ │ │ - Load .zkey proving key │ │
│ │ │ - Generate witness │ │
│ │ │ - Create Groth16 proof │ │
│ │ │ │ │
│ │ └─ Rust WASM (Halo2) │ │
│ │ - Load Halo2 params │ │
│ │ - Generate proving key │ │
│ │ - Create Halo2 proof │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Submit to Smart Contract │
└─────────────────────────────────────────────────────────────┘
Groth16 with snarkjs
Setup
# Install snarkjs
npm install snarkjs
# Install circomlib for Poseidon
npm install circomlibjs
Basic Proof Generation
import * as snarkjs from 'snarkjs';
// Generate commitment proof
export async function generateCommitment(
secret: string,
asset: string,
amount: string,
leafIndex: string,
siblings: string[]
) {
const inputs = {
secret,
asset,
amount,
leafIndex,
siblings,
recipient: "0" // Public parameter
};
// Generate proof with full proving (witness + proof)
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
inputs,
"asset.wasm", // Circuit WASM file
"asset_0001.zkey" // Proving key
);
// Export Solidity call data
const calldata = await snarkjs.groth16.exportSolidityCallData(
proof,
publicSignals
);
return {
proof,
publicSignals,
calldata: JSON.parse("[" + calldata + "]")
};
}
Format for Ethereum/PVM
function toEthHex(input: string): string {
return "0x" + BigInt(input).toString(16);
}
// Format proof for Solidity contract
const formattedProof = [
// pi_a
[toEthHex(proof.pi_a[0]), toEthHex(proof.pi_a[1])],
// pi_b (flattened 2x2 matrix)
[
[toEthHex(proof.pi_b[0][0]), toEthHex(proof.pi_b[0][1])],
[toEthHex(proof.pi_b[1][0]), toEthHex(proof.pi_b[1][1])]
],
// pi_c
[toEthHex(proof.pi_c[0]), toEthHex(proof.pi_c[1])],
// public signals
publicSignals.map(signal => toEthHex(signal))
];
Withdrawal Proof
export async function generateWithdrawProof({
secret,
asset,
amount,
recipient,
leafIndex,
siblings,
}: {
secret: string;
asset: string;
amount: string;
recipient: string;
leafIndex: string;
siblings: string[];
}) {
if (siblings.length !== 256) {
throw new Error("Siblings array must have exactly 256 elements");
}
const input = {
secret,
asset,
amount,
recipient,
leafIndex,
siblings,
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
input,
"main.wasm",
"main_0000.zkey"
);
const calldata = await snarkjs.groth16.exportSolidityCallData(
proof,
publicSignals
);
return {
proof: formattedProof,
calldata: JSON.parse("[" + calldata + "]"),
publicSignals,
};
}
Merkle Tree Proof Generation
import { LeanIMT } from '@zk-kit/lean-imt';
import { poseidon } from 'circomlibjs';
class ProofGenerator {
private tree: LeanIMT;
constructor() {
// Initialize LeanIMT with Poseidon hash
this.tree = new LeanIMT((a, b) => poseidon([a, b]));
}
// Insert commitment into tree
insertCommitment(commitment: bigint) {
this.tree.insert(commitment);
}
// Generate Merkle proof for withdrawal
async generateMerkleProof(commitment: bigint) {
const { root, siblings, index } = this.tree.generateProof(commitment);
return {
root: root.toString(),
siblings: siblings.map(s => s.toString()),
indices: index.toString(2).padStart(20, '0').split('').map(Number),
leafIndex: index.toString()
};
}
// Generate full withdrawal proof
async generateWithdrawalProof(
nullifier: bigint,
secret: bigint,
commitment: bigint,
amount: bigint,
recipient: string,
asset: string
) {
// Get Merkle proof
const merkleProof = await this.generateMerkleProof(commitment);
// Compute context (replay protection)
const context = this.computeContext(recipient, asset);
// Generate ZK proof
const zkProof = await generateWithdrawProof({
secret: secret.toString(),
asset: asset,
amount: amount.toString(),
recipient: context,
leafIndex: merkleProof.leafIndex,
siblings: merkleProof.siblings,
});
return {
merkleProof,
zkProof,
publicSignals: zkProof.publicSignals
};
}
private computeContext(recipient: string, asset: string): string {
const hash = ethers.keccak256(
ethers.solidityPacked(['address', 'address'], [recipient, asset])
);
return (BigInt(hash) % SNARK_SCALAR_FIELD).toString();
}
}
Halo2 with Rust/WASM
Project Structure
Interface/
├── src/
│ ├── lib.rs # WASM bindings
│ ├── zk.rs # Halo2 circuit implementation
│ └── proof-worker.js # Web Worker for proving
├── public/
│ └── pkg/ # Compiled WASM package
└── package.json
Rust Circuit Implementation
#![allow(unused)] fn main() { // src/zk.rs use halo2_proofs::{ circuit::{Layouter, SimpleFloorPlanner, Value}, plonk::{Advice, Column, ConstraintSystem, Error, Circuit}, poly::kzg::commitment::ParamsKZG, halo2curves::bn256::{Bn256, Fr as Fp, G1Affine}, }; use halo2_poseidon::poseidon::{ primitives::{ConstantLength, Hash}, Hash as PoseidonHash, }; // Poseidon specification for BN254 #[derive(Debug, Clone, Copy)] pub struct Posbn254<const WIDTH: usize, const RATE: usize>; impl<const WIDTH: usize, const RATE: usize> Spec<Fp, WIDTH, RATE> for Posbn254<WIDTH, RATE> { fn full_rounds() -> usize { 8 } fn partial_rounds() -> usize { 56 } fn sbox(val: Fp) -> Fp { val.pow_vartime(&[5]) } fn secure_mds() -> usize { 0 } fn constants() -> (Vec<[Fp; WIDTH]>, Mds<Fp, WIDTH>, Mds<Fp, WIDTH>) { generate_constants::<_, Self, WIDTH, RATE>() } } // Hash circuit #[derive(Clone, Copy, Default)] pub struct HashCircuit<S, const WIDTH: usize, const RATE: usize, const L: usize> where S: Spec<Fp, WIDTH, RATE> + Clone + Copy, { message: Value<[Fp; L]>, _spec: PhantomData<S>, } impl<S, const WIDTH: usize, const RATE: usize, const L: usize> Circuit<Fp> for HashCircuit<S, WIDTH, RATE, L> where S: Spec<Fp, WIDTH, RATE> + Copy + Clone, { type Config = MyConfig<WIDTH, RATE, L>; type FloorPlanner = SimpleFloorPlanner; fn without_witnesses(&self) -> Self { Self { message: Value::unknown(), _spec: PhantomData, } } fn configure(meta: &mut ConstraintSystem<Fp>) -> Self::Config { // Configure Poseidon chip let state = (0..WIDTH).map(|_| meta.advice_column()).collect::<Vec<_>>(); let expected = meta.instance_column(); meta.enable_equality(expected); MyConfig { input: state[..RATE].try_into().unwrap(), expected, poseidon_config: Pow5Chip::configure::<S>( meta, state.try_into().unwrap(), partial_sbox, rc_a.try_into().unwrap(), rc_b.try_into().unwrap(), ), } } fn synthesize( &self, config: Self::Config, mut layouter: impl Layouter<Fp>, ) -> Result<(), Error> { // Assign message and compute hash let chip = Pow5Chip::construct(config.poseidon_config.clone()); let message = layouter.assign_region( || "load message", |mut region| { // Assign input values // ... }, )?; let hasher = Hash::<_, _, S, ConstantLength<L>, WIDTH, RATE>::init( chip, layouter.namespace(|| "init"), )?; let output = hasher.hash( layouter.namespace(|| "hash"), message )?; // Constrain output to public instance layouter.constrain_instance(output.cell(), config.expected, 0) } } }
WASM Bindings
#![allow(unused)] fn main() { // src/lib.rs use wasm_bindgen::prelude::*; use halo2_proofs::halo2curves::bn256::Fr as Fp; use halo2_proofs::poly::kzg::commitment::ParamsKZG; use std::io::BufReader; #[wasm_bindgen] pub fn generate_commitment(secret: &str) -> Result<String, JsError> { // Convert secret to field element let m2 = Fp::from_str_vartime(secret) .ok_or_else(|| JsError::new("Invalid secret - must be numeric"))?; // Create message let msg = [m2, m2]; // Compute Poseidon hash let output = Hash::<_, Posbn254<3, 2>, ConstantLength<2>, 3, 2>::init() .hash(msg); Ok(format!("{:?}", output)) } #[wasm_bindgen] pub async fn generate_proof_data( secret: &str, parambytes: JsValue ) -> Result<String, JsError> { // Parse secret let m2 = Fp::from_str_vartime(secret) .ok_or_else(|| JsError::new("Invalid secret"))?; let msg = [m2, m2]; // Create circuit const K: u32 = 8; const L: usize = 2; const WIDTH: usize = 3; const RATE: usize = 2; let circuit = HashCircuit::<Posbn254<WIDTH, RATE>, WIDTH, RATE, L>::new(msg); // Load params from bytes let params_vec = Uint8Array::new(¶mbytes).to_vec(); let params = ParamsKZG::<Bn256>::read( &mut BufReader::new(¶ms_vec[..]) )?; // Generate proving and verifying keys let (pk, vk) = generate_keys(¶ms, &circuit)?; // Compute public output let output = PoseidonHash::<_, Posbn254<WIDTH, RATE>, ConstantLength<L>, WIDTH, RATE>::init() .hash(msg); // Generate proof let proof = generate_proof2( ¶ms, &pk, circuit, vec![vec![output]] )?; Ok(proof) // Hex-encoded proof } }
Web Worker Integration
// src/proof-worker.js
import { initThreadPool, generate_proof_data } from '../pkg/generate_zk_wasm';
// Initialize worker with thread pool
async function init() {
const threads = navigator.hardwareConcurrency;
console.log(`Initializing ${threads} threads`);
await initThreadPool(threads);
return {
generate_proof_data
};
}
const worker = { init };
export default worker;
React Integration
// src/components/ProofGenerator.tsx
import { useEffect, useState } from 'react';
import Comlink from 'comlink';
interface ProofWorker {
init: () => Promise<{
generate_proof_data: (
secret: string,
params: Uint8Array
) => Promise<string>;
}>;
}
export function ProofGenerator() {
const [worker, setWorker] = useState<any>(null);
const [generating, setGenerating] = useState(false);
const [proof, setProof] = useState<string | null>(null);
useEffect(() => {
// Initialize worker on mount
async function initWorker() {
const proofWorker = new Worker(
new URL('../src/proof-worker.js', import.meta.url)
);
const wrapped = Comlink.wrap<ProofWorker>(proofWorker);
const api = await wrapped.init();
setWorker(api);
}
initWorker();
}, []);
const handleGenerateProof = async (secret: string) => {
if (!worker) return;
setGenerating(true);
try {
// Fetch Halo2 params
const response = await fetch(
'https://trusted-setup-halo2kzg.s3.eu-central-1.amazonaws.com/hermez-raw-11'
);
const paramsBuffer = await response.arrayBuffer();
const params = new Uint8Array(paramsBuffer);
// Generate proof in worker
const proofResult = await worker.generate_proof_data(
secret,
params
);
setProof(proofResult);
} catch (error) {
console.error('Proof generation failed:', error);
} finally {
setGenerating(false);
}
};
return (
<div>
<button
onClick={() => handleGenerateProof('12345')}
disabled={generating || !worker}
>
{generating ? 'Generating...' : 'Generate Proof'}
</button>
{proof && <div>Proof: {proof}</div>}
</div>
);
}
Performance Optimization
Multi-threaded Proving
// Use wasm-bindgen-rayon for multi-threading
import { initThreadPool } from './pkg';
// Initialize with hardware concurrency
await initThreadPool(navigator.hardwareConcurrency);
// Proving now uses all available cores
const proof = await generate_proof_data(secret, params);
Memory Management
#![allow(unused)] fn main() { // src/lib.rs - Check and grow WASM memory #[wasm_bindgen] pub fn generate_proof_data(secret: &str, parambytes: JsValue) -> Result<String, JsError> { let memory = wasm_bindgen::memory() .dyn_into::<WebAssembly::Memory>() .unwrap(); // Check current memory allocation let current_pages = memory.grow(0); if current_pages < 600 { // If less than ~28MB memory.grow(64); // Grow by 64 pages (4MB) } // Generate proof with panic recovery let proof = match std::panic::catch_unwind(|| { generate_proof2(¶ms, &pk, circuit, public_inputs) }) { Ok(Ok(p)) => p, Ok(Err(e)) => return Err(JsError::new(&format!("Proof error: {}", e))), Err(_) => return Err(JsError::new("Proof generation panicked")), }; Ok(proof) } }
Witness Caching
// Cache witness for repeated proofs
class WitnessCache {
private cache = new Map<string, any>();
async getOrCompute(
inputs: any,
circuitPath: string,
zkeyPath: string
) {
const key = JSON.stringify(inputs);
if (this.cache.has(key)) {
return this.cache.get(key);
}
const witness = await snarkjs.witness_calculator(
await fetch(circuitPath).then(r => r.arrayBuffer())
).calculateWitness(inputs);
this.cache.set(key, witness);
return witness;
}
}
Submitting Proofs to Contract
Using ethers.js
import { ethers } from 'ethers';
async function submitWithdrawal(
provider: ethers.Provider,
signer: ethers.Signer,
poolAddress: string,
proof: any
) {
const pool = new ethers.Contract(
poolAddress,
SHIELDED_POOL_ABI,
signer
);
// Format proof for contract
const formattedProof = {
a: [proof.proof[0][0], proof.proof[0][1]],
b: [
[proof.proof[1][0][0], proof.proof[1][0][1]],
[proof.proof[1][1][0], proof.proof[1][1][1]]
],
c: [proof.proof[2][0], proof.proof[2][1]],
};
// Submit withdrawal transaction
const tx = await pool.withdraw(
formattedProof.a,
formattedProof.b,
formattedProof.c,
proof.publicSignals,
assetAddress,
recipientAddress
);
await tx.wait();
console.log('Withdrawal submitted:', tx.hash);
}
Using polkadot.js
import { ApiPromise } from '@polkadot/api';
async function submitToPvm(
api: ApiPromise,
signer: any,
contractAddress: string,
proof: any
) {
// Encode proof data
const callData = encodeProofData(proof);
// Submit via contracts.call extrinsic
const tx = api.tx.contracts.call(
contractAddress,
0, // value
1000000000000, // gasLimit
null, // storageDepositLimit
callData
);
await tx.signAndSend(signer);
}
Debugging
Browser Console Logging
// Enable detailed logging
snarkjs.logger = console.log.bind(console);
// Log proof generation steps
console.log('Starting proof generation...');
console.log('Inputs:', inputs);
console.log('Witness calculated');
console.log('Proof generated:', proof);
Error Handling
try {
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
inputs,
circuitWasm,
zkey
);
} catch (error) {
if (error.message.includes('Constraint not satisfied')) {
console.error('Circuit constraint failed - check inputs');
} else if (error.message.includes('Memory limit')) {
console.error('WASM memory exhausted - reduce circuit size');
} else {
console.error('Proof generation failed:', error);
}
}
Resources
Previous: Shielded Pools | Next: Circom Guide