Create & verify zero-knowledge SNARK proofs in parallel, using noble cryptography
JavaScript
64
50 commits
updated Aug 31, 2026
Create & verify zero-knowledge SNARK proofs in parallel, using noble cryptography.
npm install micro-zk-proofs
deno add jsr:@paulmillr/micro-zk-proofs
import * as zkp from 'micro-zk-proofs';
import { generateWitness } from 'micro-zk-proofs/witness.js';
// Wrap setup, witness generation, proof creation, and verification once you have a circuit.
const prove = async (circuit: zkp.CircuitInfo, input: Record<string, string>) => {
const setup = zkp.bn254.groth.setup(circuit);
// Legacy circom-js artifacts execute JavaScript; only opt in for trusted circuit files.
const witness = generateWitness(circuit, { unsafeAllowJsEvalCircuit: true })(input);
const proof = await zkp.bn254.groth.createProof(setup.pkey, witness);
const isValid = zkp.bn254.groth.verifyProof(setup.vkey, proof);
return { proof, isValid };
};
// Typed as following:
type Constraint = Record<number, bigint>;
type G1Point = [bigint, bigint, bigint];
type G2Point = [[bigint, bigint], [bigint, bigint], [bigint, bigint]];
type ProvingKey = {
protocol?: 'groth';
nVars: number;
nPublic: number;
domainBits: number;
domainSize: number;
// Polynominals
polsA: Constraint[];
polsB: Constraint[];
polsC: Constraint[];
//
A: G1Point[];
B1: G1Point[];
B2: G2Point[];
C: G1Point[];
//
vk_alfa_1: G1Point;
vk_beta_1: G1Point;
vk_delta_1: G1Point;
vk_beta_2: G2Point;
vk_delta_2: G2Point;
//
hExps: G1Point[];
};
type VerificationKey = {
protocol?: 'groth';
nPublic: number;
IC: G1Point[];
//
vk_alfa_1: G1Point;
vk_beta_2: G2Point;
vk_gamma_2: G2Point;
vk_delta_2: G2Point;
};
type GrothProof = {
protocol: 'groth';
pi_a: G1Point;
pi_b: G2Point;
pi_c: G1Point;
};
interface ProofWithSignals {
proof: GrothProof;
publicSignals: bigint[];
}
There are 4 steps:
Check out examples directory. It contains wasm-v2, wasm-v1 and js circuits.
We need a circuit, and a compiler.
Circuit compilation is outside of scope of our library and depends on a circuit language. Groth16 proofs don't care about language. We use circom in examples below, but you can use anything.
There is no common serialization format for circom, but this is not a big deal.
There are three circom compilers:
We support all versions for backwards-compatibility reasons: v2 programs are different from circom v1, old circuits won't always compile with new compiler, and their output may differ between each other.
Witness generation:
[!NOTE] When using with existing project, proving/verify keys, witness calculation program and circuit info should be provided by authors. Compiling same circuit with slightly different version of compiler will result in incompatible circuit which will generate invalid proofs.
[!WARNING]
.setupmethod is for tests only, in real production setup you need to do multi-party ceremony to avoid leaking of toxic scalars.
R1CS/ZKey parsing, setup, and proving apply default resource limits before artifact metadata can
drive allocations: 64 MiB, 65,536 variables/constraints, and a 131,072-element domain. Trusted
larger circuits can override these with the second argument to getCoders() and the limits option
to buildSnark().
Check out examples directory. It contains wasm-v2, wasm-v1 and js circuits.
We will use a test circuit.
dir='circom-wasm'
git clone https://github.com/iden3/circom $dir
cd $dir
git checkout v2.2.2
cargo build --release
./circom-wasm/target/release/circom -o output --r1cs --sym --wasm --json --wat circuit-v2/sum_test.circom
cd output/sum_test_js
mv witness_calculator.js witness_calculator.cjs
import { bn254 } from '@noble/curves/bn254.js';
import * as zkp from 'micro-zk-proofs';
import * as zkpWitness from 'micro-zk-proofs/witness.js';
import { deepStrictEqual } from 'node:assert';
import { default as calc } from './output/sum_test_js/witness_calculator.cjs';
import { readFileSync } from 'node:fs';
import { dirname, join as pjoin } from 'node:path';
import { fileURLToPath } from 'node:url';
const _dirname = dirname(fileURLToPath(import.meta.url));
const read = (...paths: string[]) => readFileSync(pjoin(_dirname, ...paths));
console.log('# wasm circom v2');
(async () => {
const input = { a: '33', b: '34' };
// 2. setup
const coders = zkpWitness.getCoders(bn254);
const setupWasm = zkp.bn254.groth.setup(
coders.getCircuitInfo(read('output', 'sum_test.r1cs'))
);
// 3. generate witness
// NOTE: circom generates zero-deps witness calculator from wasm.
// In theory we can do small wasm runtime for it, but it depends on compiler version and can change!
const c = await calc(read('output', 'sum_test_js', 'sum_test.wasm'));
const binWitness = await c.calculateBinWitness(input, true);
const wtns = await c.calculateWTNSBin(input, true);
const witness0 = coders.binWitness.decode(binWitness);
const witness1 = coders.WTNS.decode(wtns).sections[1].data; // Or using WTNS circom format
deepStrictEqual(witness0, witness1);
// 4. create proof
console.log('creating proof');
const proofWasm = await zkp.bn254.groth.createProof(setupWasm.pkey, witness0);
console.log('created proof', proofWasm);
// 4. verify proof
console.log('verifying proof');
deepStrictEqual(
zkp.bn254.groth.verifyProof(setupWasm.vkey, proofWasm),
true
);
})();
dir='wasmsnark'
git clone https://github.com/iden3/wasmsnark.git $dir
cd $dir
git checkout v0.0.12
import * as zkp from 'micro-zk-proofs';
import { deepStrictEqual } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join as pjoin } from 'node:path';
import { fileURLToPath } from 'node:url';
const _dirname = dirname(fileURLToPath(import.meta.url));
const read = (...paths: string[]) => readFileSync(pjoin(_dirname, ...paths), 'utf8');
console.log('# wasm circom v1');
(async () => {
const bigjson = (path: string) => zkp.stringBigints.decode(
JSON.parse(read('wasmsnark', 'example', 'bn128', path))
);
const pkey = bigjson('proving_key.json');
const vkey = bigjson('verification_key.json');
const witness = bigjson('witness.json');
const oldProof = bigjson('proof.json');
const oldProofGood = bigjson('proof_good.json');
const oldProofGood0 = bigjson('proof_good0.json');
const oldPublic = bigjson('public.json');
// Generate proofs
console.log('creating proof');
const proofNew = await zkp.bn254.groth.createProof(pkey, witness);
console.log('created proof', proofNew);
console.log('verifying proof');
deepStrictEqual(
zkp.bn254.groth.verifyProof(vkey, proofNew),
true
);
const { publicSignals } = proofNew;
// Verify proofs
console.log('verifying proof 2');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProof, publicSignals }), true);
console.log('verifying proof 3');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProofGood, publicSignals }), true);
console.log('verifying proof 4');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProofGood0, publicSignals }), true);
console.log('all proofs were correct')
})();
circom JS v1 legacy programs produce code which is eval-ed using new Function.
We have to monkey-patch BigInt - otherwise the code won't run.
No patching is being done for WASM programs.
Because the circuit artifact contains executable JavaScript, generateWitness requires the explicit
unsafeAllowJsEvalCircuit option. Only enable it for trusted circuit files.
Witness inputs reject sparse arrays and default to a maximum depth of 64 and
max(1024, nInputs * maxDepth) traversed values. Exceptional trusted circuits can raise these
bounds explicitly:
const generate = zkpWitness.generateWitness(circuit, {
unsafeAllowJsEvalCircuit: true,
inputLimits: { maxDepth: 128, maxNodes: 100_000 },
});
dir='circom-js'
git clone https://github.com/iden3/circom_old $dir
cd $dir
git checkout v0.0.35
npm install
import { bn254 } from '@noble/curves/bn254.js';
import * as zkp from 'micro-zk-proofs';
import * as zkpMsm from 'micro-zk-proofs/msm.js';
import * as zkpWitness from 'micro-zk-proofs/witness.js';
import { deepStrictEqual } from 'node:assert';
import sumCircuit from './sum-circuit.json' with { "type": "json" };
const groth = zkp.bn254.groth;
const input = { a: '33', b: '34' };
const setupJs = groth.setup(sumCircuit);
const witnessJs = zkpWitness.generateWitness(sumCircuit, {
unsafeAllowJsEvalCircuit: true,
})(input);
(async () => {
// 2. setup
// Generate using circom_old circuit
// NOTE: we have this small util to remove dependencies on snarkjs for witness generation
// 3. generate witness
//deepStrictEqual(witness0, witnessJs); // -> will fail, because we have different constrains!
// 4. create proof
const proofJs = await groth.createProof(setupJs.pkey, witnessJs);
console.log('proof created, signals:', proofJs.publicSignals)
// 4. verify proof
deepStrictEqual(
groth.verifyProof(setupJs.vkey, proofJs),
true
);
console.log('proof is valid');
})();
// Fast, parallel proofs
(async () => {
console.log('testing fast parallel proofs, using web workers');
const msm = zkpMsm.initMSM();
const grothp = zkp.buildSnark(bn254, {
G1msm: msm.methods.bn254_msmG1,
G2msm: msm.methods.bn254_msmG2,
}).groth;
// 4. generate proof
const proofJs2 = await grothp.createProof(setupJs.pkey, witnessJs);
console.log('proof created, signals:', proofJs2.publicSignals)
// 4. verify proof
deepStrictEqual(
grothp.verifyProof(setupJs.vkey, proofJs2),
true
);
console.log('proof is valid');
msm.terminate();
})();
Custom/worker MSMs omit zero-scalar entries by default for sparse-proof performance. To keep the submitted workload shape independent of witness sparsity, opt into side-channel hardening:
const hardenedGroth = zkp.buildSnark(bn254, {
G1msm: msm.methods.bn254_msmG1,
G2msm: msm.methods.bn254_msmG2,
hardenSideChannel: true,
}).groth;
This option reduces the coarse payload-length and worker-utilization signal, but JavaScript and
BigInt execution are not guaranteed to be constant-time. The default is false because preserving
zero entries can materially slow sparse MSMs.
Benchmarks measured on Apple M4:
MIT (c) Paul Miller (https://paulmillr.com), see LICENSE file.
50 commits
JavaScript
55.0%
TypeScript
45.0%
Create & verify zero-knowledge SNARK proofs in parallel, using noble cryptography
JavaScript
64
50 commits
updated Aug 31, 2026
Create & verify zero-knowledge SNARK proofs in parallel, using noble cryptography.
npm install micro-zk-proofs
deno add jsr:@paulmillr/micro-zk-proofs
import * as zkp from 'micro-zk-proofs';
import { generateWitness } from 'micro-zk-proofs/witness.js';
// Wrap setup, witness generation, proof creation, and verification once you have a circuit.
const prove = async (circuit: zkp.CircuitInfo, input: Record<string, string>) => {
const setup = zkp.bn254.groth.setup(circuit);
// Legacy circom-js artifacts execute JavaScript; only opt in for trusted circuit files.
const witness = generateWitness(circuit, { unsafeAllowJsEvalCircuit: true })(input);
const proof = await zkp.bn254.groth.createProof(setup.pkey, witness);
const isValid = zkp.bn254.groth.verifyProof(setup.vkey, proof);
return { proof, isValid };
};
// Typed as following:
type Constraint = Record<number, bigint>;
type G1Point = [bigint, bigint, bigint];
type G2Point = [[bigint, bigint], [bigint, bigint], [bigint, bigint]];
type ProvingKey = {
protocol?: 'groth';
nVars: number;
nPublic: number;
domainBits: number;
domainSize: number;
// Polynominals
polsA: Constraint[];
polsB: Constraint[];
polsC: Constraint[];
//
A: G1Point[];
B1: G1Point[];
B2: G2Point[];
C: G1Point[];
//
vk_alfa_1: G1Point;
vk_beta_1: G1Point;
vk_delta_1: G1Point;
vk_beta_2: G2Point;
vk_delta_2: G2Point;
//
hExps: G1Point[];
};
type VerificationKey = {
protocol?: 'groth';
nPublic: number;
IC: G1Point[];
//
vk_alfa_1: G1Point;
vk_beta_2: G2Point;
vk_gamma_2: G2Point;
vk_delta_2: G2Point;
};
type GrothProof = {
protocol: 'groth';
pi_a: G1Point;
pi_b: G2Point;
pi_c: G1Point;
};
interface ProofWithSignals {
proof: GrothProof;
publicSignals: bigint[];
}
There are 4 steps:
Check out examples directory. It contains wasm-v2, wasm-v1 and js circuits.
We need a circuit, and a compiler.
Circuit compilation is outside of scope of our library and depends on a circuit language. Groth16 proofs don't care about language. We use circom in examples below, but you can use anything.
There is no common serialization format for circom, but this is not a big deal.
There are three circom compilers:
We support all versions for backwards-compatibility reasons: v2 programs are different from circom v1, old circuits won't always compile with new compiler, and their output may differ between each other.
Witness generation:
[!NOTE] When using with existing project, proving/verify keys, witness calculation program and circuit info should be provided by authors. Compiling same circuit with slightly different version of compiler will result in incompatible circuit which will generate invalid proofs.
[!WARNING]
.setupmethod is for tests only, in real production setup you need to do multi-party ceremony to avoid leaking of toxic scalars.
R1CS/ZKey parsing, setup, and proving apply default resource limits before artifact metadata can
drive allocations: 64 MiB, 65,536 variables/constraints, and a 131,072-element domain. Trusted
larger circuits can override these with the second argument to getCoders() and the limits option
to buildSnark().
Check out examples directory. It contains wasm-v2, wasm-v1 and js circuits.
We will use a test circuit.
dir='circom-wasm'
git clone https://github.com/iden3/circom $dir
cd $dir
git checkout v2.2.2
cargo build --release
./circom-wasm/target/release/circom -o output --r1cs --sym --wasm --json --wat circuit-v2/sum_test.circom
cd output/sum_test_js
mv witness_calculator.js witness_calculator.cjs
import { bn254 } from '@noble/curves/bn254.js';
import * as zkp from 'micro-zk-proofs';
import * as zkpWitness from 'micro-zk-proofs/witness.js';
import { deepStrictEqual } from 'node:assert';
import { default as calc } from './output/sum_test_js/witness_calculator.cjs';
import { readFileSync } from 'node:fs';
import { dirname, join as pjoin } from 'node:path';
import { fileURLToPath } from 'node:url';
const _dirname = dirname(fileURLToPath(import.meta.url));
const read = (...paths: string[]) => readFileSync(pjoin(_dirname, ...paths));
console.log('# wasm circom v2');
(async () => {
const input = { a: '33', b: '34' };
// 2. setup
const coders = zkpWitness.getCoders(bn254);
const setupWasm = zkp.bn254.groth.setup(
coders.getCircuitInfo(read('output', 'sum_test.r1cs'))
);
// 3. generate witness
// NOTE: circom generates zero-deps witness calculator from wasm.
// In theory we can do small wasm runtime for it, but it depends on compiler version and can change!
const c = await calc(read('output', 'sum_test_js', 'sum_test.wasm'));
const binWitness = await c.calculateBinWitness(input, true);
const wtns = await c.calculateWTNSBin(input, true);
const witness0 = coders.binWitness.decode(binWitness);
const witness1 = coders.WTNS.decode(wtns).sections[1].data; // Or using WTNS circom format
deepStrictEqual(witness0, witness1);
// 4. create proof
console.log('creating proof');
const proofWasm = await zkp.bn254.groth.createProof(setupWasm.pkey, witness0);
console.log('created proof', proofWasm);
// 4. verify proof
console.log('verifying proof');
deepStrictEqual(
zkp.bn254.groth.verifyProof(setupWasm.vkey, proofWasm),
true
);
})();
dir='wasmsnark'
git clone https://github.com/iden3/wasmsnark.git $dir
cd $dir
git checkout v0.0.12
import * as zkp from 'micro-zk-proofs';
import { deepStrictEqual } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join as pjoin } from 'node:path';
import { fileURLToPath } from 'node:url';
const _dirname = dirname(fileURLToPath(import.meta.url));
const read = (...paths: string[]) => readFileSync(pjoin(_dirname, ...paths), 'utf8');
console.log('# wasm circom v1');
(async () => {
const bigjson = (path: string) => zkp.stringBigints.decode(
JSON.parse(read('wasmsnark', 'example', 'bn128', path))
);
const pkey = bigjson('proving_key.json');
const vkey = bigjson('verification_key.json');
const witness = bigjson('witness.json');
const oldProof = bigjson('proof.json');
const oldProofGood = bigjson('proof_good.json');
const oldProofGood0 = bigjson('proof_good0.json');
const oldPublic = bigjson('public.json');
// Generate proofs
console.log('creating proof');
const proofNew = await zkp.bn254.groth.createProof(pkey, witness);
console.log('created proof', proofNew);
console.log('verifying proof');
deepStrictEqual(
zkp.bn254.groth.verifyProof(vkey, proofNew),
true
);
const { publicSignals } = proofNew;
// Verify proofs
console.log('verifying proof 2');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProof, publicSignals }), true);
console.log('verifying proof 3');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProofGood, publicSignals }), true);
console.log('verifying proof 4');
deepStrictEqual(zkp.bn254.groth.verifyProof(vkey, { proof: oldProofGood0, publicSignals }), true);
console.log('all proofs were correct')
})();
circom JS v1 legacy programs produce code which is eval-ed using new Function.
We have to monkey-patch BigInt - otherwise the code won't run.
No patching is being done for WASM programs.
Because the circuit artifact contains executable JavaScript, generateWitness requires the explicit
unsafeAllowJsEvalCircuit option. Only enable it for trusted circuit files.
Witness inputs reject sparse arrays and default to a maximum depth of 64 and
max(1024, nInputs * maxDepth) traversed values. Exceptional trusted circuits can raise these
bounds explicitly:
const generate = zkpWitness.generateWitness(circuit, {
unsafeAllowJsEvalCircuit: true,
inputLimits: { maxDepth: 128, maxNodes: 100_000 },
});
dir='circom-js'
git clone https://github.com/iden3/circom_old $dir
cd $dir
git checkout v0.0.35
npm install
import { bn254 } from '@noble/curves/bn254.js';
import * as zkp from 'micro-zk-proofs';
import * as zkpMsm from 'micro-zk-proofs/msm.js';
import * as zkpWitness from 'micro-zk-proofs/witness.js';
import { deepStrictEqual } from 'node:assert';
import sumCircuit from './sum-circuit.json' with { "type": "json" };
const groth = zkp.bn254.groth;
const input = { a: '33', b: '34' };
const setupJs = groth.setup(sumCircuit);
const witnessJs = zkpWitness.generateWitness(sumCircuit, {
unsafeAllowJsEvalCircuit: true,
})(input);
(async () => {
// 2. setup
// Generate using circom_old circuit
// NOTE: we have this small util to remove dependencies on snarkjs for witness generation
// 3. generate witness
//deepStrictEqual(witness0, witnessJs); // -> will fail, because we have different constrains!
// 4. create proof
const proofJs = await groth.createProof(setupJs.pkey, witnessJs);
console.log('proof created, signals:', proofJs.publicSignals)
// 4. verify proof
deepStrictEqual(
groth.verifyProof(setupJs.vkey, proofJs),
true
);
console.log('proof is valid');
})();
// Fast, parallel proofs
(async () => {
console.log('testing fast parallel proofs, using web workers');
const msm = zkpMsm.initMSM();
const grothp = zkp.buildSnark(bn254, {
G1msm: msm.methods.bn254_msmG1,
G2msm: msm.methods.bn254_msmG2,
}).groth;
// 4. generate proof
const proofJs2 = await grothp.createProof(setupJs.pkey, witnessJs);
console.log('proof created, signals:', proofJs2.publicSignals)
// 4. verify proof
deepStrictEqual(
grothp.verifyProof(setupJs.vkey, proofJs2),
true
);
console.log('proof is valid');
msm.terminate();
})();
Custom/worker MSMs omit zero-scalar entries by default for sparse-proof performance. To keep the submitted workload shape independent of witness sparsity, opt into side-channel hardening:
const hardenedGroth = zkp.buildSnark(bn254, {
G1msm: msm.methods.bn254_msmG1,
G2msm: msm.methods.bn254_msmG2,
hardenSideChannel: true,
}).groth;
This option reduces the coarse payload-length and worker-utilization signal, but JavaScript and
BigInt execution are not guaranteed to be constant-time. The default is false because preserving
zero entries can materially slow sparse MSMs.
Benchmarks measured on Apple M4:
MIT (c) Paul Miller (https://paulmillr.com), see LICENSE file.
50 commits
JavaScript
55.0%
TypeScript
45.0%