From bac6b09d74598832666e082bbbe6642374a31ff7 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 24 Apr 2026 16:29:55 +0100 Subject: [PATCH] Implement ZK-057..060 in SDK --- .codex | 0 sdk/src/deposit.ts | 32 +++++ sdk/src/index.ts | 2 + sdk/src/merkle.ts | 219 +++++++++++++++++++++++++++++ sdk/src/note.ts | 91 +++++++++++- sdk/src/proof.ts | 65 ++++++++- sdk/src/stable.ts | 92 ++++++++++++ sdk/src/withdraw.ts | 94 ++++++++++++- sdk/test/merkle_checkpoint.test.ts | 53 +++++++ sdk/test/note_randomness.test.ts | 72 ++++++++++ sdk/test/proof_cache.test.ts | 99 +++++++++++++ sdk/test/zk_integration.test.ts | 127 +++++++++++++++++ 12 files changed, 928 insertions(+), 18 deletions(-) create mode 100644 .codex create mode 100644 sdk/src/deposit.ts create mode 100644 sdk/src/merkle.ts create mode 100644 sdk/src/stable.ts create mode 100644 sdk/test/merkle_checkpoint.test.ts create mode 100644 sdk/test/note_randomness.test.ts create mode 100644 sdk/test/proof_cache.test.ts create mode 100644 sdk/test/zk_integration.test.ts diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/sdk/src/deposit.ts b/sdk/src/deposit.ts new file mode 100644 index 0000000..3159eb9 --- /dev/null +++ b/sdk/src/deposit.ts @@ -0,0 +1,32 @@ +import { Note } from './note'; + +export interface DepositRequest { + poolId: string; + amount: bigint; + note?: Note; +} + +export interface DepositPayload { + note: Note; + poolId: string; + amount: bigint; + commitment: Buffer; +} + +/** + * Creates deposit payload data from either a supplied note or a new note. + */ +export function createDeposit(request: DepositRequest): DepositPayload { + const note = request.note ?? Note.generate(request.poolId, request.amount); + + return { + note, + poolId: note.poolId, + amount: note.amount, + commitment: note.getCommitment() + }; +} + +export function createBatchCommitments(notes: Note[]): Buffer[] { + return notes.map((note) => note.getCommitment()); +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 8340806..8fc460c 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -3,3 +3,5 @@ export * from './proof'; export * from './gas'; export * from './stealth'; export * from './withdraw'; +export * from './deposit'; +export * from './merkle'; diff --git a/sdk/src/merkle.ts b/sdk/src/merkle.ts new file mode 100644 index 0000000..8f733c7 --- /dev/null +++ b/sdk/src/merkle.ts @@ -0,0 +1,219 @@ +import { MerkleProof } from './proof'; +import { normalizeHex, stableHash32 } from './stable'; + +export type CommitmentLike = Buffer | Uint8Array | string; + +export interface MerkleCheckpoint { + version: 1; + depth: number; + nextIndex: number; + root: string; + frontier: Array; + leaves?: string[]; +} + +export interface BatchSyncResult { + insertedLeafIndices: number[]; + checkpoint: MerkleCheckpoint; + root: Buffer; +} + +function toLeaf(commitment: CommitmentLike): Buffer { + if (Buffer.isBuffer(commitment) || commitment instanceof Uint8Array) { + const bytes = Buffer.from(commitment); + return bytes.length === 32 ? bytes : stableHash32('leaf-bytes', bytes); + } + + const normalized = normalizeHex(commitment); + if (/^[0-9a-f]+$/i.test(normalized) && normalized.length % 2 === 0) { + const bytes = Buffer.from(normalized, 'hex'); + return bytes.length === 32 ? bytes : stableHash32('leaf-hex', bytes); + } + + return stableHash32('leaf-text', commitment); +} + +export class LocalMerkleTree { + readonly depth: number; + private readonly zeroes: Buffer[]; + private readonly frontier: Array; + private trackedLeaves: Buffer[]; + private nextIndex: number; + private root: Buffer; + + constructor(depth: number = 20) { + if (!Number.isInteger(depth) || depth <= 0 || depth > 31) { + throw new Error(`Merkle depth must be an integer in [1, 31], received ${depth}`); + } + + this.depth = depth; + this.zeroes = this.buildZeroes(depth); + this.frontier = new Array(depth).fill(null); + this.trackedLeaves = []; + this.nextIndex = 0; + this.root = Buffer.from(this.zeroes[depth]); + } + + static fromCheckpoint(checkpoint: MerkleCheckpoint): LocalMerkleTree { + if (checkpoint.frontier.length !== checkpoint.depth) { + throw new Error( + `Invalid checkpoint: frontier length ${checkpoint.frontier.length} does not match depth ${checkpoint.depth}` + ); + } + + const tree = new LocalMerkleTree(checkpoint.depth); + tree.nextIndex = checkpoint.nextIndex; + tree.root = Buffer.from(normalizeHex(checkpoint.root), 'hex'); + + for (let i = 0; i < checkpoint.frontier.length; i += 1) { + const entry = checkpoint.frontier[i]; + tree.frontier[i] = entry ? Buffer.from(normalizeHex(entry), 'hex') : null; + } + + if (checkpoint.leaves) { + tree.trackedLeaves = checkpoint.leaves.map((leaf) => Buffer.from(normalizeHex(leaf), 'hex')); + } + + return tree; + } + + get leafCount(): number { + return this.nextIndex; + } + + getRoot(): Buffer { + return Buffer.from(this.root); + } + + insert(leaf: CommitmentLike): number { + const capacity = 2 ** this.depth; + if (this.nextIndex >= capacity) { + throw new Error(`Merkle tree is full at depth ${this.depth}`); + } + + const normalizedLeaf = toLeaf(leaf); + this.trackedLeaves.push(normalizedLeaf); + + let index = this.nextIndex; + let current = normalizedLeaf; + + for (let level = 0; level < this.depth; level += 1) { + if ((index & 1) === 0) { + this.frontier[level] = current; + current = this.hashPair(current, this.zeroes[level]); + } else { + const left = this.frontier[level] ?? this.zeroes[level]; + current = this.hashPair(left, current); + } + index >>= 1; + } + + const insertedAt = this.nextIndex; + this.nextIndex += 1; + this.root = current; + return insertedAt; + } + + insertBatch(leaves: CommitmentLike[]): number[] { + const indices: number[] = []; + for (const leaf of leaves) { + indices.push(this.insert(leaf)); + } + return indices; + } + + /** + * Generates a Merkle proof for a tracked leaf index. + * This requires that leaves are available in memory. + */ + generateProof(leafIndex: number): MerkleProof { + if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex >= this.nextIndex) { + throw new Error(`Leaf index ${leafIndex} is out of range for tree size ${this.nextIndex}`); + } + if (this.trackedLeaves.length < this.nextIndex) { + throw new Error( + 'Cannot generate Merkle proof from checkpoint-only tree state; tracked leaves are unavailable.' + ); + } + + const pathElements: Buffer[] = []; + const pathIndices: number[] = []; + const memo = new Map(); + + let index = leafIndex; + for (let level = 0; level < this.depth; level += 1) { + const siblingIndex = index ^ 1; + pathElements.push(this.nodeAt(level, siblingIndex, memo)); + pathIndices.push(index & 1); + index >>= 1; + } + + return { + root: this.getRoot(), + pathElements, + pathIndices, + leafIndex + }; + } + + createCheckpoint(options: { includeLeaves?: boolean } = {}): MerkleCheckpoint { + return { + version: 1, + depth: this.depth, + nextIndex: this.nextIndex, + root: this.root.toString('hex'), + frontier: this.frontier.map((entry) => (entry ? entry.toString('hex') : null)), + leaves: options.includeLeaves ? this.trackedLeaves.map((leaf) => leaf.toString('hex')) : undefined + }; + } + + private hashPair(left: Buffer, right: Buffer): Buffer { + return stableHash32('merkle-node', left, right); + } + + private buildZeroes(depth: number): Buffer[] { + const zeroes: Buffer[] = [Buffer.alloc(32, 0)]; + for (let i = 0; i < depth; i += 1) { + zeroes.push(this.hashPair(zeroes[i], zeroes[i])); + } + return zeroes; + } + + private nodeAt(level: number, index: number, memo: Map): Buffer { + const key = `${level}:${index}`; + const existing = memo.get(key); + if (existing) { + return existing; + } + + const span = 2 ** level; + const startLeaf = index * span; + + if (startLeaf >= this.trackedLeaves.length) { + return this.zeroes[level]; + } + + if (level === 0) { + return this.trackedLeaves[index] ?? this.zeroes[0]; + } + + const left = this.nodeAt(level - 1, index * 2, memo); + const right = this.nodeAt(level - 1, index * 2 + 1, memo); + const node = this.hashPair(left, right); + memo.set(key, node); + return node; + } +} + +export function syncCommitmentBatch( + tree: LocalMerkleTree, + commitments: CommitmentLike[], + checkpointOptions: { includeLeaves?: boolean } = {} +): BatchSyncResult { + const insertedLeafIndices = tree.insertBatch(commitments); + return { + insertedLeafIndices, + checkpoint: tree.createCheckpoint(checkpointOptions), + root: tree.getRoot() + }; +} diff --git a/sdk/src/note.ts b/sdk/src/note.ts index 63714ec..0a1bc82 100644 --- a/sdk/src/note.ts +++ b/sdk/src/note.ts @@ -1,4 +1,70 @@ -import { randomBytes } from 'crypto'; +import { stableHash32 } from './stable'; + +type CryptoLike = { + getRandomValues(array: T): T; +}; + +export interface RandomnessSource { + randomBytes(length: number): Uint8Array; +} + +export interface RuntimeRandomnessSourceOptions { + runtime?: { crypto?: CryptoLike }; + enableNodeFallback?: boolean; +} + +function resolveRuntimeCrypto(options: RuntimeRandomnessSourceOptions = {}): CryptoLike { + const runtime = options.runtime ?? (globalThis as RuntimeRandomnessSourceOptions['runtime']); + if (runtime?.crypto && typeof runtime.crypto.getRandomValues === 'function') { + return runtime.crypto; + } + + if (options.enableNodeFallback !== false) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require('crypto') as { webcrypto?: CryptoLike }; + if (nodeCrypto.webcrypto && typeof nodeCrypto.webcrypto.getRandomValues === 'function') { + return nodeCrypto.webcrypto; + } + } catch { + // Runtime does not support require('crypto') + } + } + + throw new Error( + 'Secure randomness unavailable: no crypto.getRandomValues implementation found in this runtime.' + ); +} + +/** + * RuntimeRandomnessSource uses secure randomness in browser and Node runtimes. + */ +export class RuntimeRandomnessSource implements RandomnessSource { + private options: RuntimeRandomnessSourceOptions; + + constructor(options: RuntimeRandomnessSourceOptions = {}) { + this.options = options; + } + + randomBytes(length: number): Uint8Array { + if (!Number.isInteger(length) || length <= 0) { + throw new Error(`Random byte length must be a positive integer, received: ${length}`); + } + const out = new Uint8Array(length); + resolveRuntimeCrypto(this.options).getRandomValues(out); + return out; + } +} + +let defaultRandomnessSource: RandomnessSource = new RuntimeRandomnessSource(); + +export function setDefaultRandomnessSource(source: RandomnessSource): void { + defaultRandomnessSource = source; +} + +export function resetDefaultRandomnessSource(): void { + defaultRandomnessSource = new RuntimeRandomnessSource(); +} /** * PrivacyLayer Note @@ -22,23 +88,34 @@ export class Note { /** * Create a new random note for a specific pool. */ - static generate(poolId: string, amount: bigint): Note { + static generate(poolId: string, amount: bigint, randomnessSource: RandomnessSource = defaultRandomnessSource): Note { return new Note( - randomBytes(31), - randomBytes(31), + Buffer.from(randomnessSource.randomBytes(31)), + Buffer.from(randomnessSource.randomBytes(31)), poolId, amount ); } + /** + * Deterministic derivation for fixtures/testing only. + * Keep this separate from production randomness. + */ + static deriveDeterministic(seed: Uint8Array | Buffer | string, poolId: string, amount: bigint): Note { + const seedBytes = typeof seed === 'string' ? Buffer.from(seed, 'utf8') : Buffer.from(seed); + const nullifier = stableHash32('note-nullifier', seedBytes, poolId, amount).subarray(0, 31); + const secret = stableHash32('note-secret', seedBytes, poolId, amount).subarray(0, 31); + return new Note(Buffer.from(nullifier), Buffer.from(secret), poolId, amount); + } + /** * In a real implementation, this would use a WASM-based Poseidon hash * compatible with the Noir circuit and Soroban host function. */ getCommitment(): Buffer { - // Placeholder: In production, use @noir-lang/barretenberg or similar - // for Poseidon(nullifier, secret) - return Buffer.alloc(32); + // Placeholder commitment derivation for SDK plumbing tests. + // Production should replace this with Poseidon(nullifier, secret). + return stableHash32('commitment', this.nullifier, this.secret); } /** diff --git a/sdk/src/proof.ts b/sdk/src/proof.ts index 4bd462e..514eba4 100644 --- a/sdk/src/proof.ts +++ b/sdk/src/proof.ts @@ -1,4 +1,5 @@ import { Note } from './note'; +import { normalizeHex, stableHash32 } from './stable'; export interface MerkleProof { root: Buffer; @@ -12,6 +13,52 @@ export interface Groth16Proof { publicInputs: string[]; } +export interface WithdrawalWitness { + root: string; + nullifier_hash: string; + recipient: string; + amount: string; + relayer: string; + fee: string; + pool_id: string; + nullifier: string; + secret: string; + leaf_index: string; + path_elements: string[]; + path_indices: string[]; +} + +export interface ProofCache { + get(key: string): Promise | Uint8Array | Buffer | undefined; + set(key: string, proof: Uint8Array | Buffer): Promise | void; + delete?(key: string): Promise | void; +} + +/** + * Lightweight in-memory cache implementation for environments + * that do not provide their own storage adapter. + */ +export class InMemoryProofCache implements ProofCache { + private readonly entries = new Map(); + + get(key: string): Buffer | undefined { + const entry = this.entries.get(key); + return entry ? Buffer.from(entry) : undefined; + } + + set(key: string, proof: Uint8Array | Buffer): void { + this.entries.set(key, Buffer.from(proof)); + } + + delete(key: string): void { + this.entries.delete(key); + } +} + +export function computeNullifierHashHex(nullifierHex: string, rootHex: string): string { + return stableHash32('nullifier-hash', normalizeHex(nullifierHex), normalizeHex(rootHex)).toString('hex'); +} + /** * ProvingBackend * @@ -82,17 +129,23 @@ export class ProofGenerator { recipient: string, relayer: string = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', // Zero address fee: bigint = 0n - ) { + ): Promise { + const rootHex = merkleProof.root.toString('hex'); + const nullifierHex = note.nullifier.toString('hex'); + return { - root: merkleProof.root.toString('hex'), - nullifier_hash: '...', // Hash(nullifier) - ideally this should be computed correctly + root: rootHex, + nullifier_hash: computeNullifierHashHex(nullifierHex, rootHex), recipient: recipient, + amount: note.amount.toString(), relayer: relayer, fee: fee.toString(), - nullifier: note.nullifier.toString('hex'), + pool_id: note.poolId, + nullifier: nullifierHex, secret: note.secret.toString('hex'), - path_elements: merkleProof.pathElements.map(e => e.toString('hex')), - path_indices: merkleProof.pathIndices.map(i => i.toString()) + leaf_index: merkleProof.leafIndex.toString(), + path_elements: merkleProof.pathElements.map((e) => e.toString('hex')), + path_indices: merkleProof.pathIndices.map((i) => i.toString()) }; } diff --git a/sdk/src/stable.ts b/sdk/src/stable.ts new file mode 100644 index 0000000..854a00c --- /dev/null +++ b/sdk/src/stable.ts @@ -0,0 +1,92 @@ +export type StableHashChunk = string | number | bigint | Uint8Array | Buffer; + +const FNV_OFFSET_BASIS = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +function toBuffer(chunk: StableHashChunk): Buffer { + if (typeof chunk === 'string') { + return Buffer.from(chunk, 'utf8'); + } + + if (typeof chunk === 'number' || typeof chunk === 'bigint') { + return Buffer.from(chunk.toString(), 'utf8'); + } + + return Buffer.from(chunk); +} + +function packChunks(chunks: StableHashChunk[]): Buffer { + const packed: Buffer[] = []; + + for (const chunk of chunks) { + const bytes = toBuffer(chunk); + const len = Buffer.alloc(4); + len.writeUInt32BE(bytes.length, 0); + packed.push(len, bytes); + } + + return Buffer.concat(packed); +} + +function fnv1a(bytes: Buffer, seed: number): number { + let hash = seed >>> 0; + for (const byte of bytes) { + hash ^= byte; + hash = Math.imul(hash, FNV_PRIME) >>> 0; + } + return hash >>> 0; +} + +/** + * Deterministic 32-byte hash utility. + * This is for SDK stability and testing; it is NOT a replacement for Poseidon. + */ +export function stableHash32(...chunks: StableHashChunk[]): Buffer { + const payload = packChunks(chunks); + const out = Buffer.alloc(32); + + for (let i = 0; i < 8; i += 1) { + const seed = (FNV_OFFSET_BASIS ^ Math.imul(i + 1, 0x9e3779b1)) >>> 0; + const part = fnv1a(payload, seed); + out.writeUInt32BE(part, i * 4); + } + + return out; +} + +export function stableStringify(value: unknown): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + + if (typeof value === 'bigint') { + return JSON.stringify(value.toString()); + } + + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + return JSON.stringify(Buffer.from(value).toString('hex')); + } + + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(',')}]`; + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); + const body = entries + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(','); + return `{${body}}`; + } + + return JSON.stringify(String(value)); +} + +export function normalizeHex(value: string): string { + const normalized = value.startsWith('0x') ? value.slice(2) : value; + return normalized.toLowerCase(); +} diff --git a/sdk/src/withdraw.ts b/sdk/src/withdraw.ts index 40666b7..6b9f171 100644 --- a/sdk/src/withdraw.ts +++ b/sdk/src/withdraw.ts @@ -1,5 +1,7 @@ import { Note } from './note'; -import { MerkleProof, ProofGenerator, ProvingBackend } from './proof'; +import { MerkleProof, ProofCache, ProofGenerator, ProvingBackend, VerifyingBackend, WithdrawalWitness } from './proof'; +import { BatchSyncResult, CommitmentLike, LocalMerkleTree, MerkleCheckpoint, syncCommitmentBatch } from './merkle'; +import { stableHash32, stableStringify } from './stable'; /** * WithdrawalRequest @@ -14,6 +16,75 @@ export interface WithdrawalRequest { fee?: bigint; } +export interface WithdrawalProofGenerationOptions { + cache?: ProofCache; + cacheKey?: string; +} + +interface WithdrawalCacheMaterial { + note: { + nullifier: string; + secret: string; + pool: string; + denomination: string; + }; + root: string; + pool: string; + publicInputs: { + root: string; + nullifier_hash: string; + recipient: string; + amount: string; + relayer: string; + fee: string; + }; +} + +function buildCacheMaterial(request: WithdrawalRequest, witness: WithdrawalWitness): WithdrawalCacheMaterial { + return { + note: { + nullifier: witness.nullifier, + secret: witness.secret, + pool: request.note.poolId, + denomination: witness.amount + }, + root: witness.root, + pool: request.note.poolId, + publicInputs: { + root: witness.root, + nullifier_hash: witness.nullifier_hash, + recipient: witness.recipient, + amount: witness.amount, + relayer: witness.relayer, + fee: witness.fee + } + }; +} + +export function buildWithdrawalProofCacheKey( + request: WithdrawalRequest, + witness: WithdrawalWitness +): string { + const material = buildCacheMaterial(request, witness); + const canonical = stableStringify(material); + return `withdraw-proof:${stableHash32('withdraw-proof-cache-v1', canonical).toString('hex')}`; +} + +/** + * Transport-agnostic helper for syncing new deposit commitments into a local tree. + */ +export function syncWithdrawalTree( + tree: LocalMerkleTree, + commitments: CommitmentLike[], + checkpointOptions: { includeLeaves?: boolean } = {} +): BatchSyncResult { + return syncCommitmentBatch(tree, commitments, checkpointOptions); +} + +export function restoreWithdrawalTree(checkpoint: MerkleCheckpoint): LocalMerkleTree { + return LocalMerkleTree.fromCheckpoint(checkpoint); +} + /** * generateWithdrawalProof * @@ -26,7 +97,8 @@ export interface WithdrawalRequest { */ export async function generateWithdrawalProof( request: WithdrawalRequest, - backend: ProvingBackend + backend: ProvingBackend, + options: WithdrawalProofGenerationOptions = {} ): Promise { const { note, merkleProof, recipient, relayer, fee } = request; @@ -39,12 +111,24 @@ export async function generateWithdrawalProof( fee ); + const key = options.cacheKey ?? buildWithdrawalProofCacheKey(request, witness); + if (options.cache) { + const cached = await options.cache.get(key); + if (cached) { + return Buffer.from(cached); + } + } + // 2. Generate the raw proof using the injected backend const proofGenerator = new ProofGenerator(backend); const rawProof = await proofGenerator.generate(witness); // 3. Format the proof for the Soroban contract - return ProofGenerator.formatProof(rawProof); + const proof = ProofGenerator.formatProof(rawProof); + if (options.cache) { + await options.cache.set(key, proof); + } + return proof; } /** @@ -53,7 +137,7 @@ export async function generateWithdrawalProof( * Extracts the public inputs from a witness object in the order * expected by the circuit and the verifier. */ -export function extractPublicInputs(witness: any): string[] { +export function extractPublicInputs(witness: WithdrawalWitness): string[] { // Ordered according to circuits/withdraw/src/main.nr: // 1. root // 2. nullifier_hash @@ -85,7 +169,7 @@ export async function verifyWithdrawalProof( proof: Uint8Array, publicInputs: string[], artifacts: any, - backend: import('./proof').VerifyingBackend + backend: VerifyingBackend ): Promise { return backend.verifyProof(proof, publicInputs, artifacts); } diff --git a/sdk/test/merkle_checkpoint.test.ts b/sdk/test/merkle_checkpoint.test.ts new file mode 100644 index 0000000..8ba0667 --- /dev/null +++ b/sdk/test/merkle_checkpoint.test.ts @@ -0,0 +1,53 @@ +import { LocalMerkleTree } from '../src/merkle'; +import { stableHash32 } from '../src/stable'; +import { restoreWithdrawalTree, syncWithdrawalTree } from '../src/withdraw'; + +function leaf(i: number): Buffer { + return stableHash32('leaf', i); +} + +describe('Local merkle sync and checkpoints', () => { + it('batch ingestion preserves root determinism', () => { + const commitments = Array.from({ length: 64 }, (_, i) => leaf(i)); + + const sequential = new LocalMerkleTree(); + for (const commitment of commitments) { + sequential.insert(commitment); + } + + const batched = new LocalMerkleTree(); + batched.insertBatch(commitments); + + expect(batched.getRoot().equals(sequential.getRoot())).toBe(true); + }); + + it('restores from checkpoint and continues syncing without replaying full history', () => { + const commitments = Array.from({ length: 96 }, (_, i) => leaf(i)); + + const tree = new LocalMerkleTree(); + tree.insertBatch(commitments.slice(0, 40)); + const checkpoint = tree.createCheckpoint(); + + const resumed = LocalMerkleTree.fromCheckpoint(checkpoint); + resumed.insertBatch(commitments.slice(40)); + + const rebuilt = new LocalMerkleTree(); + rebuilt.insertBatch(commitments); + + expect(resumed.leafCount).toBe(rebuilt.leafCount); + expect(resumed.getRoot().equals(rebuilt.getRoot())).toBe(true); + }); + + it('withdraw helpers stay transport-agnostic and checkpoint-compatible', () => { + const tree = new LocalMerkleTree(); + const first = syncWithdrawalTree(tree, [leaf(1), leaf(2), leaf(3)]); + + expect(first.insertedLeafIndices).toEqual([0, 1, 2]); + + const resumed = restoreWithdrawalTree(first.checkpoint); + const second = syncWithdrawalTree(resumed, [leaf(4)]); + + expect(second.insertedLeafIndices).toEqual([3]); + expect(second.root.equals(resumed.getRoot())).toBe(true); + }); +}); diff --git a/sdk/test/note_randomness.test.ts b/sdk/test/note_randomness.test.ts new file mode 100644 index 0000000..998bfea --- /dev/null +++ b/sdk/test/note_randomness.test.ts @@ -0,0 +1,72 @@ +import { + Note, + RandomnessSource, + RuntimeRandomnessSource, + resetDefaultRandomnessSource, + setDefaultRandomnessSource +} from '../src/note'; + +class FixedRandomnessSource implements RandomnessSource { + constructor(private readonly byte: number) {} + + randomBytes(length: number): Uint8Array { + return new Uint8Array(length).fill(this.byte); + } +} + +describe('Note randomness boundary', () => { + const poolId = '11'.repeat(32); + + afterEach(() => { + resetDefaultRandomnessSource(); + }); + + it('supports runtime crypto selection in browser-like environments', () => { + const browserLikeCrypto = { + getRandomValues(array: T): T { + if (!array) { + return array; + } + const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); + bytes.fill(0xaa); + return array; + } + }; + + const source = new RuntimeRandomnessSource({ + runtime: { crypto: browserLikeCrypto }, + enableNodeFallback: false + }); + + const out = source.randomBytes(8); + expect(Buffer.from(out).toString('hex')).toBe('aa'.repeat(8)); + }); + + it('fails clearly when secure randomness is unavailable', () => { + const source = new RuntimeRandomnessSource({ + runtime: {}, + enableNodeFallback: false + }); + + expect(() => source.randomBytes(31)).toThrow('Secure randomness unavailable'); + }); + + it('uses injected production randomness source in note generation', () => { + const source = new FixedRandomnessSource(0x7b); + setDefaultRandomnessSource(source); + + const note = Note.generate(poolId, 42n); + + expect(note.nullifier.equals(Buffer.alloc(31, 0x7b))).toBe(true); + expect(note.secret.equals(Buffer.alloc(31, 0x7b))).toBe(true); + }); + + it('keeps deterministic derivation isolated and stable for fixtures', () => { + const a = Note.deriveDeterministic('fixture-seed', poolId, 42n); + const b = Note.deriveDeterministic('fixture-seed', poolId, 42n); + const c = Note.deriveDeterministic('other-seed', poolId, 42n); + + expect(a.serialize()).toBe(b.serialize()); + expect(a.serialize()).not.toBe(c.serialize()); + }); +}); diff --git a/sdk/test/proof_cache.test.ts b/sdk/test/proof_cache.test.ts new file mode 100644 index 0000000..939d255 --- /dev/null +++ b/sdk/test/proof_cache.test.ts @@ -0,0 +1,99 @@ +import { Note } from '../src/note'; +import { InMemoryProofCache, MerkleProof, ProofGenerator, ProvingBackend } from '../src/proof'; +import { buildWithdrawalProofCacheKey, generateWithdrawalProof, WithdrawalRequest } from '../src/withdraw'; +import { stableHash32 } from '../src/stable'; + +class CountingBackend implements ProvingBackend { + public calls = 0; + + async generateProof(witness: any): Promise { + this.calls += 1; + const digest = stableHash32('proof', JSON.stringify(witness)); + const proof = new Uint8Array(64); + proof.set(digest, 0); + proof.set(digest, 32); + return proof; + } +} + +function makeRequest(overrides: Partial = {}): WithdrawalRequest { + const note = new Note( + Buffer.from('01'.repeat(31), 'hex'), + Buffer.from('02'.repeat(31), 'hex'), + '03'.repeat(32), + 1000n + ); + + const merkleProof: MerkleProof = { + root: Buffer.from('04'.repeat(32), 'hex'), + pathElements: Array.from({ length: 20 }, (_, i) => Buffer.from((5 + i).toString(16).padStart(2, '0').repeat(32), 'hex')), + pathIndices: Array.from({ length: 20 }, () => 0), + leafIndex: 0 + }; + + return { + note, + merkleProof, + recipient: '0xrecipient', + relayer: '0xrelayer', + fee: 0n, + ...overrides + }; +} + +describe('Withdrawal proof cache', () => { + it('reuses proof for repeated canonical inputs (cache hit)', async () => { + const backend = new CountingBackend(); + const cache = new InMemoryProofCache(); + const request = makeRequest(); + + const first = await generateWithdrawalProof(request, backend, { cache }); + const second = await generateWithdrawalProof(request, backend, { cache }); + + expect(backend.calls).toBe(1); + expect(first.equals(second)).toBe(true); + }); + + it('misses cache when no cache adapter is provided', async () => { + const backend = new CountingBackend(); + const request = makeRequest(); + + await generateWithdrawalProof(request, backend); + await generateWithdrawalProof(request, backend); + + expect(backend.calls).toBe(2); + }); + + it('invalidates cached proof when public inputs change', async () => { + const backend = new CountingBackend(); + const cache = new InMemoryProofCache(); + + const firstRequest = makeRequest({ recipient: '0xalice' }); + const secondRequest = makeRequest({ recipient: '0xbob' }); + + const witnessA = await ProofGenerator.prepareWitness( + firstRequest.note, + firstRequest.merkleProof, + firstRequest.recipient, + firstRequest.relayer, + firstRequest.fee + ); + const witnessB = await ProofGenerator.prepareWitness( + secondRequest.note, + secondRequest.merkleProof, + secondRequest.recipient, + secondRequest.relayer, + secondRequest.fee + ); + + const keyA = buildWithdrawalProofCacheKey(firstRequest, witnessA); + const keyB = buildWithdrawalProofCacheKey(secondRequest, witnessB); + + expect(keyA).not.toBe(keyB); + + await generateWithdrawalProof(firstRequest, backend, { cache }); + await generateWithdrawalProof(secondRequest, backend, { cache }); + + expect(backend.calls).toBe(2); + }); +}); diff --git a/sdk/test/zk_integration.test.ts b/sdk/test/zk_integration.test.ts new file mode 100644 index 0000000..2a64993 --- /dev/null +++ b/sdk/test/zk_integration.test.ts @@ -0,0 +1,127 @@ +import { createDeposit } from '../src/deposit'; +import { LocalMerkleTree } from '../src/merkle'; +import { Note } from '../src/note'; +import { ProofGenerator, ProvingBackend, VerifyingBackend, WithdrawalWitness } from '../src/proof'; +import { extractPublicInputs, generateWithdrawalProof, verifyWithdrawalProof } from '../src/withdraw'; +import { stableHash32, stableStringify } from '../src/stable'; + +class IntegrationProvingBackend implements ProvingBackend { + async generateProof(witness: WithdrawalWitness): Promise { + const amount = BigInt(witness.amount); + const fee = BigInt(witness.fee); + + if (fee > amount) { + throw new Error('invalid witness: fee exceeds amount'); + } + + if (!Array.isArray(witness.path_elements) || witness.path_elements.length === 0) { + throw new Error('invalid witness: missing merkle path'); + } + + const digest = stableHash32('integration-proof', stableStringify(witness)); + const proof = new Uint8Array(64); + proof.set(digest, 0); + proof.set(digest, 32); + proof[0] = 0xab; + return proof; + } +} + +class IntegrationVerifyingBackend implements VerifyingBackend { + async verifyProof(proof: Uint8Array, publicInputs: string[]): Promise { + if (proof.length !== 64 || proof[0] !== 0xab) { + return false; + } + + const amount = BigInt(publicInputs[3]); + const fee = BigInt(publicInputs[5]); + return fee <= amount; + } +} + +const FIXTURES = { + valid: { + seed: 'zk-valid-fixture', + poolId: '44'.repeat(32), + amount: 1000n, + recipient: '0xrecipient-valid', + relayer: '0xrelayer-valid', + fee: 5n + }, + invalid: { + seed: 'zk-invalid-fixture', + poolId: '55'.repeat(32), + amount: 500n, + recipient: '0xrecipient-invalid', + relayer: '0xrelayer-invalid', + fee: 501n + } +}; + +describe('SDK ZK integration flow', () => { + it('completes note -> tree -> witness -> proof -> verify round trip', async () => { + const fixture = FIXTURES.valid; + + const note = Note.deriveDeterministic(fixture.seed, fixture.poolId, fixture.amount); + const deposit = createDeposit({ poolId: fixture.poolId, amount: fixture.amount, note }); + + const tree = new LocalMerkleTree(); + const [leafIndex] = tree.insertBatch([deposit.commitment]); + const merkleProof = tree.generateProof(leafIndex); + + const proof = await generateWithdrawalProof( + { + note, + merkleProof, + recipient: fixture.recipient, + relayer: fixture.relayer, + fee: fixture.fee + }, + new IntegrationProvingBackend() + ); + + const witness = await ProofGenerator.prepareWitness( + note, + merkleProof, + fixture.recipient, + fixture.relayer, + fixture.fee + ); + + const publicInputs = extractPublicInputs(witness); + const isValid = await verifyWithdrawalProof( + proof, + publicInputs, + { fixture: 'withdraw-artifact' }, + new IntegrationVerifyingBackend() + ); + + expect(isValid).toBe(true); + expect(proof.length).toBe(64); + expect(stableHash32('pi', stableStringify(publicInputs)).length).toBe(32); + }); + + it('fails invalid flow with the expected reason', async () => { + const fixture = FIXTURES.invalid; + + const note = Note.deriveDeterministic(fixture.seed, fixture.poolId, fixture.amount); + const deposit = createDeposit({ poolId: fixture.poolId, amount: fixture.amount, note }); + + const tree = new LocalMerkleTree(); + tree.insert(deposit.commitment); + const merkleProof = tree.generateProof(0); + + await expect( + generateWithdrawalProof( + { + note, + merkleProof, + recipient: fixture.recipient, + relayer: fixture.relayer, + fee: fixture.fee + }, + new IntegrationProvingBackend() + ) + ).rejects.toThrow('invalid witness: fee exceeds amount'); + }); +});