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:

  1. Groth16 with snarkjs - Browser-based proving using WebAssembly
  2. 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(&parambytes).to_vec();
    let params = ParamsKZG::<Bn256>::read(
        &mut BufReader::new(&params_vec[..])
    )?;
    
    // Generate proving and verifying keys
    let (pk, vk) = generate_keys(&params, &circuit)?;
    
    // Compute public output
    let output = PoseidonHash::<_, Posbn254<WIDTH, RATE>, ConstantLength<L>, WIDTH, RATE>::init()
        .hash(msg);
    
    // Generate proof
    let proof = generate_proof2(
        &params, 
        &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(&params, &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