diff --git a/packages/client/src/artifacts.ts b/packages/client/src/artifacts.ts index 1efb39c..82f0e22 100644 --- a/packages/client/src/artifacts.ts +++ b/packages/client/src/artifacts.ts @@ -215,4 +215,3 @@ function installIndicator(): void { } installIndicator(); -prefetchMembershipArtifacts(); diff --git a/packages/client/src/prove.test.ts b/packages/client/src/prove.test.ts index 2867e68..14eec39 100644 --- a/packages/client/src/prove.test.ts +++ b/packages/client/src/prove.test.ts @@ -1,8 +1,21 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { validateCircuitInput, type CircuitInput } from "./prove.js"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + validateCircuitInput, + type CircuitInput, + feToBytes, + g1ToBytes, + g2ToBytes, + FP_BYTES, + verificationKeyToContractFormat, +} from "./prove.js"; import { FR_MODULUS } from "./identity.js"; +const __dirname = dirname(fileURLToPath(import.meta.url)); + // ── Helpers ────────────────────────────────────────────────────────── function validInput(): CircuitInput { @@ -242,3 +255,134 @@ test("accepts all values at 0 (lower boundary)", () => { }; validateCircuitInput(input); }); + +// ── feToBytes: known answers ───────────────────────────────────────── +// +// feToBytes encodes an Fp (BLS12-381 base field) coordinate as 48 +// big-endian bytes — the wire format the Sharibo contract deserializes for +// every G1/G2 limb (see contracts/sharibo/src/lib.rs and the comment above +// FP_BYTES in prove.ts). A byte-order or length mistake here makes every +// proof invalid on-chain with an opaque `InvalidProof`, so these are +// known-answer tests pinned against independently-computed expected bytes, +// not just round-trips through the function itself. + +test("feToBytes(0) is 48 zero bytes", () => { + const bytes = feToBytes("0"); + assert.equal(bytes.length, FP_BYTES); + assert.deepEqual(bytes, new Uint8Array(FP_BYTES)); +}); + +test("feToBytes(1) is 47 zero bytes followed by 0x01 (big-endian)", () => { + const bytes = feToBytes("1"); + assert.equal(bytes.length, FP_BYTES); + const expected = new Uint8Array(FP_BYTES); + expected[FP_BYTES - 1] = 0x01; + assert.deepEqual(bytes, expected); +}); + +test("feToBytes encodes the maximum canonical Fp value (BLS12-381 base field modulus - 1)", () => { + // BLS12-381 base field modulus q (spec-known, decimal): + // 4002409555221667393417789825735904156556882819939007885332058136124031650490837864442687629129015664037894272559787 + // q in hex: 0x1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab + const q = + 4002409555221667393417789825735904156556882819939007885332058136124031650490837864442687629129015664037894272559787n; + const max = q - 1n; // largest canonical Fp element, [0, q) + const bytes = feToBytes(max.toString()); + assert.equal(bytes.length, FP_BYTES); + // Known answer: q - 1 in hex, independently written out (last nibble + // decremented from the spec value's ...aaab to ...aaaa). + const expectedHex = + "1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaaa"; + assert.equal(Buffer.from(bytes).toString("hex"), expectedHex); +}); + +// ── g1ToBytes: length and limb placement ───────────────────────────── + +test("g1ToBytes always produces 96 bytes", () => { + const bytes = g1ToBytes(["0", "0", "1"]); + assert.equal(bytes.length, 96); +}); + +test("g1ToBytes places X in bytes 0-47 and Y in bytes 48-95", () => { + // Distinguishable, non-trivial values per limb so a swap would be caught. + const x = "111111"; + const y = "222222"; + const bytes = g1ToBytes([x, y, "1"]); + assert.equal(bytes.length, 96); + assert.deepEqual(bytes.subarray(0, 48), feToBytes(x)); + assert.deepEqual(bytes.subarray(48, 96), feToBytes(y)); +}); + +// ── g2ToBytes: length and limb placement (Xc1||Xc0||Yc1||Yc0) ──────── +// +// This is the highest-risk spot in the encoding: Xc1 is written BEFORE +// Xc0 (and Yc1 before Yc0) — the opposite of the natural [c0, c1] input +// order. A deliberate limb swap is exercised below (see the acceptance +// criterion in issue #48) to confirm this test suite actually catches it. + +test("g2ToBytes always produces 192 bytes", () => { + const bytes = g2ToBytes([ + ["0", "0"], + ["0", "0"], + ["1", "0"], + ]); + assert.equal(bytes.length, 192); +}); + +test("g2ToBytes places limbs in Xc1||Xc0||Yc1||Yc0 order", () => { + // Four distinguishable, non-trivial values — one per limb — so any + // transposition (not just Xc1/Xc0) would be caught. + const xc0 = "111111"; + const xc1 = "222222"; + const yc0 = "333333"; + const yc1 = "444444"; + const bytes = g2ToBytes([ + [xc0, xc1], + [yc0, yc1], + ["1", "0"], + ]); + assert.equal(bytes.length, 192); + assert.deepEqual(bytes.subarray(0, 48), feToBytes(xc1), "bytes 0-47 must be Xc1"); + assert.deepEqual(bytes.subarray(48, 96), feToBytes(xc0), "bytes 48-95 must be Xc0"); + assert.deepEqual(bytes.subarray(96, 144), feToBytes(yc1), "bytes 96-143 must be Yc1"); + assert.deepEqual(bytes.subarray(144, 192), feToBytes(yc0), "bytes 144-191 must be Yc0"); +}); + +// ── verificationKeyToContractFormat: round-trip against the committed ── +// ── circuits/verification_key.json ── + +test("verificationKeyToContractFormat produces the right shapes for the committed verification key (3 public signals)", () => { + const vkPath = join(__dirname, "..", "..", "..", "circuits", "verification_key.json"); + const vkJson = JSON.parse(readFileSync(vkPath, "utf8")); + + // Sanity-check the fixture itself hasn't drifted from what this test + // assumes: 3 public signals (nullifierHash, root, externalNullifier). + assert.equal(vkJson.nPublic, 3); + + const vk = verificationKeyToContractFormat(vkJson); + + // ic.length === public signals + 1 (the constant term) — the + // acceptance criterion from issue #48. + assert.equal(vk.ic.length, 4); + + // G1 fields (alpha, and every ic entry) are 96 bytes; G2 fields (beta, + // gamma, delta) are 192 bytes. + assert.equal(vk.alpha.length, 96); + assert.equal(vk.beta.length, 192); + assert.equal(vk.gamma.length, 192); + assert.equal(vk.delta.length, 192); + for (const ic of vk.ic) { + assert.equal(ic.length, 96); + } + + // Round-trip: re-deriving each field independently via g1ToBytes/ + // g2ToBytes from the same JSON must match exactly what + // verificationKeyToContractFormat produced. + assert.deepEqual(vk.alpha, g1ToBytes(vkJson.vk_alpha_1)); + assert.deepEqual(vk.beta, g2ToBytes(vkJson.vk_beta_2)); + assert.deepEqual(vk.gamma, g2ToBytes(vkJson.vk_gamma_2)); + assert.deepEqual(vk.delta, g2ToBytes(vkJson.vk_delta_2)); + vkJson.IC.forEach((ic: [string, string, string], i: number) => { + assert.deepEqual(vk.ic[i], g1ToBytes(ic)); + }); +}); diff --git a/packages/client/src/prove.ts b/packages/client/src/prove.ts index 973ff23..aeb4dff 100644 --- a/packages/client/src/prove.ts +++ b/packages/client/src/prove.ts @@ -2,7 +2,9 @@ import { groth16 } from "snarkjs"; import { prefetchMembershipArtifacts, type ProverArtifacts, -} from "./artifacts"; +} from "./artifacts.js"; +import { FR_MODULUS } from "./identity.js"; +import { TREE_LEVELS } from "./config.js"; export interface ProofResult { proof: unknown; @@ -53,5 +55,185 @@ export async function prove( return fullProve(input); } -export { prefetchMembershipArtifacts } from "./artifacts"; -export type { ProverArtifacts } from "./artifacts"; +export interface CircuitInput { + identityNullifier: bigint; + identitySecret: bigint; + pathElements: bigint[]; + pathIndices: number[]; + root: bigint; + externalNullifier: bigint; +} + +// Wire format the Sharibo contract expects: G1Affine = 96 raw bytes +// (be_bytes(X) || be_bytes(Y)), G2Affine = 192 raw bytes +// (be_bytes(X_c1) || be_bytes(X_c0) || be_bytes(Y_c1) || be_bytes(Y_c0)) — +// see contracts/sharibo/src/lib.rs and NOTES.md. snarkjs's decimal Fq/Fq2 +// coordinates already are canonical field elements, so a plain big-endian, +// zero-padded encoding is all that's needed (the reserved flag bits happen +// to be 0 for any canonical coordinate, since the BLS12-381 base field +// modulus itself begins with three zero bits). +// +// Exported (rather than module-private) so the encoding can be pinned with +// round-trip/known-answer tests — see prove.test.ts. This is the +// highest-leverage place in the SDK for tests: a single byte-order mistake +// here makes every proof invalid with an opaque `InvalidProof` on-chain. +export const FP_BYTES = 48; + +export function feToBytes(dec: string): Uint8Array { + const hex = BigInt(dec).toString(16).padStart(FP_BYTES * 2, "0"); + const bytes = new Uint8Array(FP_BYTES); + for (let i = 0; i < FP_BYTES; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function concatBytes(...chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +export function g1ToBytes([x, y]: [string, string, string]): Uint8Array { + return concatBytes(feToBytes(x), feToBytes(y)); +} + +export function g2ToBytes([[xc0, xc1], [yc0, yc1]]: [ + [string, string], + [string, string], + [string, string], +]): Uint8Array { + return concatBytes(feToBytes(xc1), feToBytes(xc0), feToBytes(yc1), feToBytes(yc0)); +} + +export interface ContractProof { + a: Uint8Array; + b: Uint8Array; + c: Uint8Array; +} + +export interface ContractVerificationKey { + alpha: Uint8Array; + beta: Uint8Array; + gamma: Uint8Array; + delta: Uint8Array; + ic: Uint8Array[]; +} + +export function verificationKeyToContractFormat(vk: { + vk_alpha_1: [string, string, string]; + vk_beta_2: [[string, string], [string, string], [string, string]]; + vk_gamma_2: [[string, string], [string, string], [string, string]]; + vk_delta_2: [[string, string], [string, string], [string, string]]; + IC: [string, string, string][]; +}): ContractVerificationKey { + return { + alpha: g1ToBytes(vk.vk_alpha_1), + beta: g2ToBytes(vk.vk_beta_2), + gamma: g2ToBytes(vk.vk_gamma_2), + delta: g2ToBytes(vk.vk_delta_2), + ic: vk.IC.map(g1ToBytes), + }; +} + +// Validate CircuitInput before passing it into snarkjs fullProve, so +// malformed input fails with field-name-specific errors instead of opaque +// snarkjs internal failures. +export function validateCircuitInput( + input: CircuitInput, + levels: number = TREE_LEVELS, +): void { + // Circuit depth: pathElements length must match the expected tree depth. + if (input.pathElements.length !== levels) { + throw new Error( + `pathElements: expected ${levels}, got ${input.pathElements.length}`, + ); + } + + // pathIndices must have the same length as pathElements. + if (input.pathIndices.length !== input.pathElements.length) { + throw new Error( + `pathIndices: expected ${input.pathElements.length}, got ${input.pathIndices.length}`, + ); + } + + // Every path index must be a boolean (0 or 1). + for (let i = 0; i < input.pathIndices.length; i++) { + if (input.pathIndices[i] !== 0 && input.pathIndices[i] !== 1) { + throw new Error( + `pathIndices[${i}]: expected 0 or 1, got ${input.pathIndices[i]}`, + ); + } + } + + // Every field element must lie in [0, FR_MODULUS). + function checkField(name: string, value: bigint): void { + if (value < 0n || value >= FR_MODULUS) { + throw new Error(`${name}: must be in [0, FR_MODULUS), got ${value}`); + } + } + + checkField("identityNullifier", input.identityNullifier); + checkField("identitySecret", input.identitySecret); + checkField("root", input.root); + checkField("externalNullifier", input.externalNullifier); + + for (let i = 0; i < input.pathElements.length; i++) { + checkField(`pathElements[${i}]`, input.pathElements[i]); + } +} + +export interface ProveResult { + proof: ContractProof; + nullifierHash: bigint; + root: bigint; + externalNullifier: bigint; +} + +// Public signal order snarkjs actually emits is [nullifierHash, root, +// externalNullifier] — circuit outputs first, then declared public inputs +// in source order. Not [root, externalNullifier, nullifierHash]; see +// NOTES.md (Phase 1 deviation). +export async function generateProof( + input: CircuitInput, + wasmPath: string, + zkeyPath: string, + levels: number = TREE_LEVELS, +): Promise { + validateCircuitInput(input, levels); + const circuitInput = { + identityNullifier: input.identityNullifier.toString(), + identitySecret: input.identitySecret.toString(), + pathElements: input.pathElements.map((e) => e.toString()), + pathIndices: input.pathIndices, + root: input.root.toString(), + externalNullifier: input.externalNullifier.toString(), + }; + + const { proof, publicSignals } = await groth16.fullProve( + circuitInput, + wasmPath, + zkeyPath, + ); + + return { + proof: { + a: g1ToBytes(proof.pi_a as [string, string, string]), + b: g2ToBytes( + proof.pi_b as [[string, string], [string, string], [string, string]], + ), + c: g1ToBytes(proof.pi_c as [string, string, string]), + }, + nullifierHash: BigInt(publicSignals[0]), + root: BigInt(publicSignals[1]), + externalNullifier: BigInt(publicSignals[2]), + }; +} + +export { prefetchMembershipArtifacts } from "./artifacts.js"; +export type { ProverArtifacts } from "./artifacts.js"; diff --git a/packages/client/src/tree.ts b/packages/client/src/tree.ts index 41d0af7..7b75615 100644 --- a/packages/client/src/tree.ts +++ b/packages/client/src/tree.ts @@ -1,5 +1,10 @@ import { poseidon, FR_MODULUS } from "./identity.js"; +// Must match `component main = Sharibo(4)` in circuits/membership.circom +// (generated from circuits/config.json). The circuit is the source of truth. +export const TREE_LEVELS = 4; +export const MAX_CIRCLE_SIZE = 2 ** TREE_LEVELS; + /** * Fixed placeholder for unused leaves when padding the tree out to full capacity (2**levels). *