micro-zk-proofs
Version:
Create & verify zero-knowledge SNARK proofs in parallel, using noble cryptography
398 lines • 18.6 kB
JavaScript
import { pippenger } from '@noble/curves/abstract/curve.js';
import { FFT, poly as polyCurves, rootsOfUnity } from '@noble/curves/abstract/fft.js';
import { bn254 as nobleBn254 } from '@noble/curves/bn254.js';
import { bytesToNumberBE } from '@noble/curves/utils.js';
import { randomBytes } from '@noble/hashes/utils.js';
import { modifyArgs } from "./msm.js";
const U32_MAX = 0xffffffff;
const MAX_DOMAIN_BITS = 30;
const _0n = /* @__PURE__ */ BigInt(0);
const _1n = /* @__PURE__ */ BigInt(1);
function log2(n) {
// Domain sizes are serialized as U32LE in witness artifacts, and clz32 truncates above uint32.
if (!Number.isSafeInteger(n) || n <= 0 || n > U32_MAX)
throw new Error(`expected uint32 positive integer, got ${n}`);
return 31 - Math.clz32(n);
}
// Basic utility to deep convert bigints to strings and back
function deepConvert(o, mapper) {
const t = mapper(o);
if (t !== undefined)
return t;
if (o === null)
return o;
if (Array.isArray(o))
return o.map((i) => deepConvert(i, mapper));
if (typeof o == 'object') {
return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, deepConvert(v, mapper)]));
}
return o;
}
/**
* Helper to serialize bigint-heavy objects through JSON-compatible strings.
* @example
* Encode bigint-heavy data for JSON transport, then decode it back.
* ```ts
* const encoded = stringBigints.encode({ value: 1n });
* const decoded = stringBigints.decode(encoded);
* ```
*/
export const stringBigints = /* @__PURE__ */ Object.freeze({
encode: (o) => {
return deepConvert(o, (o) => {
if (typeof o !== 'bigint')
return undefined;
// Old proof/key JSON is unsigned field-element data; signed values would not decode back.
if (o < _0n)
throw new Error(`expected non-negative bigint, got ${o}`);
return o.toString(10);
});
},
decode: (o) => {
// Only unsigned base-10 strings are decoded for old proof/key JSON compatibility.
return deepConvert(o, (o) => typeof o == 'string' && /^[0-9]+$/.test(o) ? BigInt(o) : undefined);
},
});
function pointCoder(cons, coder) {
return Object.freeze({
encode: (p) => {
const { X: px, Y: py, Z: pz } = cons.fromAffine(p.toAffine());
return [px, py, pz].map(coder.encode);
},
decode: (p) => {
// snarkjs-style proving keys use null placeholders for zero C-query entries.
if (!p)
return cons.ZERO; // sometimes can be null?
const [x, y, z] = p.map(coder.decode);
// TODO: validation increases time 3x
// res.assertValidity();
return new cons(x, y, z);
},
});
}
/**
* Builds Groth16 helpers for a pairing-friendly curve.
* @param curve - Pairing-friendly curve implementation.
* @param opts - Options for FFT setup and optional MSM backends. See {@link GrothOpts}.
* @returns Snark setup, proof, and verification helpers.
* @throws If the curve root table exceeds the supported FFT domain size. {@link Error}
* @example
* Build curve-specific Groth16 helpers, then run a tiny self-contained proof round-trip.
* ```ts
* import { buildSnark, type CircuitInfo } from 'micro-zk-proofs';
* const { bn254: nobleBn254 } = await import('@noble/curves/bn254.js');
* const snark = buildSnark(nobleBn254);
* const circuit: CircuitInfo = {
* nVars: 2,
* nPubInputs: 0,
* nOutputs: 0,
* constraints: [[{}, {}, {}]],
* };
* const setup = snark.groth.setup(circuit);
* const proof = await snark.groth.createProof(setup.pkey, [1n, 0n]);
* snark.groth.verifyProof(setup.vkey, proof);
* ```
*/
export function buildSnark(curve, opts = {}) {
// Utils
const G1 = curve.G1.Point;
const G2 = curve.G2.Point;
const { Fr, Fp, Fp2, Fp12 } = curve.fields;
const Fpc = {
encode: (from) => from,
decode: (to) => Fp.create(to),
};
const Fp2c = {
encode: (from) => [from.c0, from.c1],
decode: (to) => Fp2.create({ c0: Fp.create(to[0]), c1: Fp.create(to[1]) }),
};
const G1c = pointCoder(G1, Fpc);
const G2c = pointCoder(G2, Fp2c);
const G1msm = !opts.G1msm
? (p, s) => pippenger(curve.G1.Point, p, s)
: modifyArgs(Fr, G1, opts.G1msm);
const G2msm = !opts.G2msm
? (p, s) => pippenger(curve.G2.Point, p, s)
: modifyArgs(Fr, G2, opts.G2msm);
const Frandom = (rnd = randomBytes) => {
// Reduce random bytes once so unsafePreserveToxic exposes the same field elements setup uses.
return Fr.create(bytesToNumberBE(rnd(Fr.BYTES)));
};
const roots = rootsOfUnity(Fr, opts.nqr ? BigInt(opts.nqr) : undefined);
// Some internal FFT paths still use signed JS shifts; reject roots that could reach bit 31+.
if (roots.info.powerOfTwo > MAX_DOMAIN_BITS)
throw new Error(`expected roots powerOfTwo <= ${MAX_DOMAIN_BITS}, got ${roots.info.powerOfTwo}`);
const fftFr = FFT(roots, Fr);
const polyFr = polyCurves(Fr, roots, undefined, fftFr);
const checkDomainBits = (bits) => {
if (!Number.isSafeInteger(bits) || bits < 0 || bits > roots.info.powerOfTwo)
throw new Error(`expected domainBits <= ${roots.info.powerOfTwo}, got ${bits}`);
};
// TODO: cleanup more later
const poly = {
reduce(p) {
while (p.length > 0 && Fr.is0(p[p.length - 1]))
p.pop();
return p;
},
sub(a, b) {
const len = Math.max(a.length, b.length);
return poly.reduce(polyFr.sub(polyFr.extend(a, len), polyFr.extend(b, len)));
},
fft(p, bits) {
const n = 1 << bits;
while (p.length < n)
p.push(Fr.ZERO);
return fftFr.direct(p);
},
ifft(p) {
if (p.length <= 1)
return p;
return fftFr.inverse(p);
},
// Polynomial multiplication via FFT.
mul(a, b) {
if (a.length !== b.length || a.length < 2)
throw new Error('wrong polynominal length');
return poly.reduce(polyFr.convolve(a, b));
},
evaluateLagrangePolynomials(bits, t) {
return polyFr.lagrange.basis(t, 2 ** bits);
},
};
function sumABC(size, weights, A, B, C, transpose = false) {
function build(constraints) {
const res = new Array(size).fill(Fr.ZERO);
for (let s = 0; s < weights.length; s++) {
for (let c in constraints[s]) {
// Setup stores sparse matrices by signal, while proving uses row-major proving-key matrices.
const idx = transpose ? s : +c;
res[idx] = Fr.add(res[idx], Fr.mul(transpose ? weights[+c] : weights[s], constraints[s][c]));
}
}
return res;
}
return { pA: build(A), pB: build(B), pC: build(C) };
}
function calculateH(proof, witness) {
const m = proof.domainSize;
const bits = log2(m);
checkDomainBits(bits);
// new snarkjs omit polsC and re-construct them via coset stuff & shifts.
if (proof.ccoefs) {
const pols = [];
for (let i = 0; i < 3; i++)
pols.push(new Array(m).fill(Fr.ZERO));
for (const { matrix, constraint, signal, value } of proof.ccoefs) {
pols[matrix][constraint] = Fr.add(pols[matrix][constraint], Fr.mul(value, witness[signal]));
}
const [pA, pB] = pols; // ignore polC
const pC = polyFr.dot(pA, pB);
// FFT to the shifted (coset) domain
// A(x)·B(x) − C(x) = H(x)·Z_H(x) -> H(g·ω^i) = (Acos[i]·Bcos[i] − Ccos[i]) / Z_H(g·ω^i)
const shift = bits === roots.info.powerOfTwo ? Fr.mul(roots.info.G, roots.info.G) : roots.omega(bits + 1);
const Acos = poly.fft(polyFr.shift(poly.ifft(pA), shift), bits);
const Bcos = poly.fft(polyFr.shift(poly.ifft(pB), shift), bits);
const Ccos = poly.fft(polyFr.shift(poly.ifft(pC), shift), bits);
return polyFr.sub(polyFr.dot(Acos, Bcos), Ccos);
}
else if (proof.polsA && proof.polsB && proof.polsC) {
const { pA, pB, pC } = sumABC(m, witness, proof.polsA, proof.polsB, proof.polsC);
// FFT only needed to optimize multiplication O(n²) to O(n log n)
// pA * pB - pC
return poly.sub(poly.mul(poly.ifft(pA), poly.ifft(pB)), poly.ifft(pC)).slice(m);
}
throw new Error('wrong proving key: no polynomials');
}
const utils = Object.freeze({ G1, G2, G1c, G2c });
// TODO: add other proofs, which re-use many polynomial operations
// * We don't export alfabeta_12! It is only used for optimization, and is specific to
// pairing implementation (different values after final exponentiation).
// * We accept raw circuit json here, no need for Circuit object!
return Object.freeze({
utils: utils,
groth: Object.freeze({
setup(circuit, rnd = randomBytes) {
// Sizes
const nConstraints = circuit.constraints.length;
const domainBits = log2(nConstraints + circuit.nPubInputs + circuit.nOutputs + 1 - 1) + 1;
checkDomainBits(domainBits);
const domainSize = 2 ** domainBits;
const nPublic = circuit.nPubInputs + circuit.nOutputs;
const maxH = domainSize + 1;
// Toxic
const toxic = {
t: Frandom(rnd),
kalfa: Frandom(rnd),
kbeta: Frandom(rnd),
kgamma: Frandom(rnd),
kdelta: Frandom(rnd),
};
// Zero toxic scalars make degenerate setup data and currently hit inverse paths for gamma/delta.
for (const [k, v] of Object.entries(toxic))
if (Fr.is0(v))
throw new Error(`expected non-zero toxic ${k}`);
// G1
const alfaP1 = G1c.encode(G1.BASE.multiplyUnsafe(Fr.create(toxic.kalfa)));
const betaP1 = G1c.encode(G1.BASE.multiplyUnsafe(Fr.create(toxic.kbeta)));
const deltaP1 = G1c.encode(G1.BASE.multiplyUnsafe(Fr.create(toxic.kdelta)));
// G2
const betaP2 = G2c.encode(G2.BASE.multiplyUnsafe(Fr.create(toxic.kbeta)));
const deltaP2 = G2c.encode(G2.BASE.multiplyUnsafe(Fr.create(toxic.kdelta)));
const gammaP2 = G2c.encode(G2.BASE.multiplyUnsafe(Fr.create(toxic.kgamma)));
// Pols
const pols = [0, 1, 2].map((side) => Array.from({ length: circuit.nVars }, (_, s) => Object.fromEntries(circuit.constraints
.map((constraint, c) => [c, constraint[side]?.[s]])
.filter(([, v]) => v !== undefined)
.map(([c, v]) => [c, BigInt(v)]))));
const [polsA, polsB, polsC] = pols;
for (let i = 0; i < circuit.nPubInputs + circuit.nOutputs + 1; i++)
polsA[i][nConstraints + i] = Fr.ONE;
// Evaluate
const zt = Fr.sub(Fr.pow(toxic.t, BigInt(domainSize)), Fr.ONE);
const u = poly.evaluateLagrangePolynomials(domainBits, toxic.t);
const { pA, pB, pC } = sumABC(circuit.nVars, u, polsA, polsB, polsC, true);
// C
const C = new Array(circuit.nVars);
const invDelta = Fr.inv(toxic.kdelta);
for (let s = nPublic + 1; s < circuit.nVars; s++) {
C[s] = G1c.encode(G1.BASE.multiplyUnsafe(Fr.mul(invDelta, Fr.add(Fr.add(Fr.mul(pA[s], toxic.kbeta), Fr.mul(pB[s], toxic.kalfa)), pC[s]))));
}
// IC
const IC = [];
const invGamma = Fr.inv(toxic.kgamma);
for (let s = 0; s <= nPublic; s++) {
IC.push(G1c.encode(G1.BASE.multiplyUnsafe(Fr.mul(invGamma, Fr.add(Fr.add(Fr.mul(pA[s], toxic.kbeta), Fr.mul(pB[s], toxic.kalfa)), pC[s])))));
}
// hExps
const zod = Fr.mul(invDelta, zt);
const hExps = [G1c.encode(G1.BASE.multiplyUnsafe(zod))];
for (let i = 1, eT = toxic.t; i < maxH; i++, eT = Fr.mul(eT, toxic.t))
hExps.push(G1c.encode(G1.BASE.multiplyUnsafe(Fr.mul(eT, zod))));
const pkey = {
protocol: 'groth',
nVars: circuit.nVars,
nPublic,
domainBits,
domainSize,
// Polynominals
polsA,
polsB,
polsC,
//
A: Array.from({ length: circuit.nVars }, (_, j) => G1.BASE.multiplyUnsafe(pA[j])).map(G1c.encode),
B1: Array.from({ length: circuit.nVars }, (_, j) => G1.BASE.multiplyUnsafe(pB[j])).map(G1c.encode),
B2: Array.from({ length: circuit.nVars }, (_, j) => G2.BASE.multiplyUnsafe(pB[j])).map(G2c.encode),
C,
//
vk_alfa_1: alfaP1,
vk_beta_1: betaP1,
vk_delta_1: deltaP1,
vk_beta_2: betaP2,
vk_delta_2: deltaP2,
//
hExps,
};
const vkey = {
protocol: 'groth',
nPublic: circuit.nPubInputs + circuit.nOutputs,
IC,
//
vk_alfa_1: alfaP1,
vk_beta_2: betaP2,
vk_gamma_2: gammaP2,
vk_delta_2: deltaP2,
};
return {
pkey,
vkey,
toxic: opts.unsafePreserveToxic ? toxic : undefined,
};
},
async createProof(pkey, witness, rnd = randomBytes) {
witness = witness.map((i) => Fr.create(i));
// Blinding salt for zero-knowledge
const r = Frandom(rnd);
const s = Frandom(rnd);
const A = pkey.A.map(G1c.decode);
const B1 = pkey.B1.map(G1c.decode);
const B2 = pkey.B2.map(G2c.decode);
const C = pkey.C.map(G1c.decode);
const hExps = pkey.hExps.map(G1c.decode);
const vk_alfa_1 = G1c.decode(pkey.vk_alfa_1);
const vk_beta_1 = G1c.decode(pkey.vk_beta_1);
const vk_beta_2 = G2c.decode(pkey.vk_beta_2);
const vk_delta_1 = G1c.decode(pkey.vk_delta_1);
const vk_delta_2 = G2c.decode(pkey.vk_delta_2);
// Actual algorithm
// pi_a = WITNESS_A + delta1*r
const pi_a_msm = await G1msm(A, witness);
const pi_a = pi_a_msm.add(vk_alfa_1).add(vk_delta_1.multiplyUnsafe(r));
// pi_b = WITNESS_B + delta2*s
const pi_b_msm = await G2msm(B2, witness);
const pi_b = pi_b_msm.add(vk_beta_2).add(vk_delta_2.multiplyUnsafe(s));
const pib1n_msm = await G1msm(B1, witness);
const pib1n = pib1n_msm.add(vk_beta_1).add(vk_delta_1.multiplyUnsafe(s));
const cOffset = pkey.nPublic + 1;
const h = calculateH(pkey, witness).map((i) => Fr.create(i));
//WITNESS3 + pi_a * s + WITNESS4 * r
const pi_c_msm = await G1msm(C.slice(cOffset).concat(hExps.slice(0, h.length)), witness.slice(cOffset).concat(h));
const pi_c = pi_c_msm
.add(pi_a.multiplyUnsafe(s))
.add(pib1n.multiplyUnsafe(r))
.add(vk_delta_1.multiplyUnsafe(Fr.create(Fr.neg(Fr.mul(r, s)))));
return {
proof: {
protocol: 'groth',
pi_a: G1c.encode(pi_a),
pi_b: G2c.encode(pi_b),
pi_c: G1c.encode(pi_c),
},
publicSignals: witness.slice(1, pkey.nPublic + 1),
};
},
verifyProof(vkey, proofWithSignals) {
const { proof, publicSignals, commitments } = proofWithSignals;
let cpub = pippenger(G1, vkey.IC.map(G1c.decode), [_1n, ...publicSignals]);
if (commitments) {
commitments.forEach((cm) => {
cpub = cpub.add(G1c.decode(cm));
});
}
// old e(pi_a, pi_b) = alfa_beta * e(cpub, gamma_2) * e(pi_c, delta_2)
// new: e(-pi_a, pi_b) * e(cpub, gamma_2) * e(pi_c, delta_2) * e(alfa_1, beta_2) = 1
// Major difference: old version uses pre-computed alfa_beta,
// but this makes it incompatible with noble, because we use cyclomatic exp
// (Fp12 values different even if math is same).
const newRes = curve.pairingBatch([
{ g1: G1c.decode(proof.pi_a).negate(), g2: G2c.decode(proof.pi_b) },
{ g1: cpub, g2: G2c.decode(vkey.vk_gamma_2) },
{ g1: G1c.decode(proof.pi_c), g2: G2c.decode(vkey.vk_delta_2) },
{ g1: G1c.decode(vkey.vk_alfa_1), g2: G2c.decode(vkey.vk_beta_2) },
]);
return Fp12.eql(newRes, Fp12.ONE);
},
}),
});
}
/**
* ZK Snarks over bn254 (aka bn128) curve.
* @example
* Build the bundled bn254 helpers and run a tiny self-contained Groth16 round-trip.
* ```ts
* import { bn254, type CircuitInfo } from 'micro-zk-proofs';
* const circuit: CircuitInfo = {
* nVars: 2,
* nPubInputs: 0,
* nOutputs: 0,
* constraints: [[{}, {}, {}]],
* };
* const setup = bn254.groth.setup(circuit);
* const proof = await bn254.groth.createProof(setup.pkey, [1n, 0n]);
* bn254.groth.verifyProof(setup.vkey, proof);
* ```
*/
export const bn254 = /* @__PURE__ */ buildSnark(nobleBn254, {});
// NOTE: this is unsafe and may not work (untested for now)
//export const bls12_381 = buildSnark(nobleBls12, {});
//# sourceMappingURL=index.js.map