Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/client/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,3 @@ function installIndicator(): void {
}

installIndicator();
prefetchMembershipArtifacts();
146 changes: 145 additions & 1 deletion packages/client/src/prove.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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));
});
});
188 changes: 185 additions & 3 deletions packages/client/src/prove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ProveResult> {
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";
5 changes: 5 additions & 0 deletions packages/client/src/tree.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down