From af75c2399d322958102cad4b071c2ad9019b4602 Mon Sep 17 00:00:00 2001 From: Abraham Anavheoba Date: Sat, 25 Apr 2026 03:51:42 -0700 Subject: [PATCH] Revert "Implement ZK-057..060 in SDK" --- .codex | 0 sdk/src/deposit.ts | 51 +++--- sdk/src/index.ts | 8 +- sdk/src/merkle.ts | 258 ++++++----------------------- sdk/src/note.ts | 163 +++++++++--------- sdk/src/proof.ts | 125 +++++++------- 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, 240 insertions(+), 902 deletions(-) delete mode 100644 .codex delete mode 100644 sdk/src/stable.ts delete mode 100644 sdk/test/merkle_checkpoint.test.ts delete mode 100644 sdk/test/note_randomness.test.ts delete mode 100644 sdk/test/proof_cache.test.ts delete mode 100644 sdk/test/zk_integration.test.ts diff --git a/.codex b/.codex deleted file mode 100644 index e69de29..0000000 diff --git a/sdk/src/deposit.ts b/sdk/src/deposit.ts index 3159eb9..13139a1 100644 --- a/sdk/src/deposit.ts +++ b/sdk/src/deposit.ts @@ -1,32 +1,45 @@ import { Note } from './note'; +import { fieldToHex, bufferToField } from './encoding'; -export interface DepositRequest { - poolId: string; - amount: bigint; - note?: Note; -} - +/** + * DepositPayload + * + * Contains the note material (to be saved by the user) and the commitment + * (to be submitted to the Soroban contract). + */ export interface DepositPayload { + /** The private note material. Must be backed up by the user. */ note: Note; - poolId: string; - amount: bigint; - commitment: Buffer; + /** The note commitment (Hash(nullifier, secret, poolId)) as a hex field element. */ + commitment: string; } /** - * Creates deposit payload data from either a supplied note or a new note. + * generateDepositPayload + * + * Orchestrates the creation of a new shielded note for a deposit. + * It generates the random secrets, computes the on-chain commitment, + * and packages them for the caller. + * + * @param poolId The 32-byte hex identifier for the target shielded pool. + * @param amount The bigint amount (in stroops/base units) being deposited. + * @returns A promise resolving to the deposit payload. */ -export function createDeposit(request: DepositRequest): DepositPayload { - const note = request.note ?? Note.generate(request.poolId, request.amount); +export async function generateDepositPayload( + poolId: string, + amount: bigint +): Promise { + // 1. Generate the note material (random nullifier and secret) + const note = Note.generate(poolId, amount); + + // 2. Compute the commitment + // Note.getCommitment() returns a 32-byte Buffer. + // We convert it to a canonical field hex string for the ZK circuit/contract. + const commitmentBuffer = note.getCommitment(); + const commitment = fieldToHex(bufferToField(commitmentBuffer)); return { note, - poolId: note.poolId, - amount: note.amount, - commitment: note.getCommitment() + commitment, }; } - -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 25a7be1..3687fc9 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -8,5 +8,9 @@ export * from './proof'; export * from './gas'; export * from './stealth'; export * from './withdraw'; -export * from './deposit'; -export * from './merkle'; +export { + assertValidGroth16ProofBytes, + assertValidPreparedWithdrawalWitness, + assertValidStellarAccountId, + GROTH16_PROOF_BYTE_LENGTH, +} from './witness'; diff --git a/sdk/src/merkle.ts b/sdk/src/merkle.ts index 8f733c7..8117a1d 100644 --- a/sdk/src/merkle.ts +++ b/sdk/src/merkle.ts @@ -1,219 +1,57 @@ -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}` +import type { MerkleProof } from './proof'; +import { WitnessValidationError } from './errors'; + +/** Matches `hash_path: [Field; 20]` in `circuits/withdraw/src/main.nr`. */ +export const MERKLE_TREE_DEPTH = 20; +export const MERKLE_MAX_LEAF_INDEX = (1 << MERKLE_TREE_DEPTH) - 1; + +/** + * Validate the Merkle proof object before it is encoded for the prover. + * Catches truncated / overlong paths and invalid index range early. + */ +export function validateMerkleProof(merkleProof: MerkleProof, depth: number = MERKLE_TREE_DEPTH): void { + if (merkleProof.root.length !== 32) { + throw new WitnessValidationError( + `Merkle root must be 32 bytes, got ${merkleProof.root.length}`, + 'MERKLE_PATH', + 'structure' + ); + } + if (merkleProof.pathElements.length !== depth) { + throw new WitnessValidationError( + `Merkle path must have ${depth} elements, got ${merkleProof.pathElements.length}`, + 'MERKLE_PATH', + 'structure' + ); + } + for (let i = 0; i < merkleProof.pathElements.length; i++) { + const el = merkleProof.pathElements[i]; + if (el.length !== 32) { + throw new WitnessValidationError( + `Merkle path element at index ${i} must be 32 bytes, got ${el.length}`, + 'MERKLE_PATH', + 'structure' ); } - - 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; + if (!Number.isInteger(merkleProof.leafIndex) || merkleProof.leafIndex < 0) { + throw new WitnessValidationError('leafIndex must be a non-negative integer', 'LEAF_INDEX', 'structure'); } - - insertBatch(leaves: CommitmentLike[]): number[] { - const indices: number[] = []; - for (const leaf of leaves) { - indices.push(this.insert(leaf)); - } - return indices; + if (merkleProof.leafIndex > MERKLE_MAX_LEAF_INDEX) { + throw new WitnessValidationError( + `leafIndex out of range for tree depth (max ${MERKLE_MAX_LEAF_INDEX})`, + 'LEAF_INDEX', + 'structure' + ); } - - /** - * 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 pidx = merkleProof.pathIndices; + if (pidx !== undefined && pidx.length > 0) { + if (pidx.length !== merkleProof.pathElements.length) { + throw new WitnessValidationError( + 'pathIndices length does not match path length', + 'MERKLE_PATH', + 'structure' ); } - - 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 8efa699..82516bc 100644 --- a/sdk/src/note.ts +++ b/sdk/src/note.ts @@ -1,70 +1,46 @@ -import { stableHash32 } from './stable'; +import { createHash, randomBytes } from 'crypto'; -type CryptoLike = { - getRandomValues(array: T): T; -}; +// --------------------------------------------------------------------------- +// Backup format constants +// --------------------------------------------------------------------------- -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; - } +const BACKUP_VERSION = 0x01; +const BACKUP_PREFIX = 'privacylayer-note:'; - 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') - } - } +// Payload layout (107 bytes): +// version 1 byte +// nullifier 31 bytes +// secret 31 bytes +// poolId 32 bytes +// amount 8 bytes (BigUInt64BE) +// checksum 4 bytes (first 4 bytes of SHA-256 over all preceding bytes) +const BACKUP_PAYLOAD_LENGTH = 107; - throw new Error( - 'Secure randomness unavailable: no crypto.getRandomValues implementation found in this runtime.' - ); -} +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- /** - * RuntimeRandomnessSource uses secure randomness in browser and Node runtimes. + * Structured error returned when a note backup cannot be imported. */ -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; +export class NoteBackupError extends Error { + constructor( + message: string, + public readonly code: + | 'INVALID_PREFIX' + | 'INVALID_VERSION' + | 'INVALID_LENGTH' + | 'CORRUPT_DATA' + | 'CHECKSUM_MISMATCH' + ) { + super(message); + this.name = 'NoteBackupError'; } } -let defaultRandomnessSource: RandomnessSource = new RuntimeRandomnessSource(); - -export function setDefaultRandomnessSource(source: RandomnessSource): void { - defaultRandomnessSource = source; -} - -export function resetDefaultRandomnessSource(): void { - defaultRandomnessSource = new RuntimeRandomnessSource(); -} +// --------------------------------------------------------------------------- +// Note +// --------------------------------------------------------------------------- /** * PrivacyLayer Note @@ -88,34 +64,67 @@ export class Note { /** * Create a new random note for a specific pool. */ - static generate(poolId: string, amount: bigint, randomnessSource: RandomnessSource = defaultRandomnessSource): Note { - return new Note( - Buffer.from(randomnessSource.randomBytes(31)), - Buffer.from(randomnessSource.randomBytes(31)), - poolId, - amount - ); + static generate(poolId: string, amount: bigint): Note { + return new Note(randomBytes(31), randomBytes(31), poolId, amount); } /** - * Deterministic derivation for fixtures/testing only. - * Keep this separate from production randomness. + * In a real implementation, this would use a WASM-based Poseidon hash + * compatible with the Noir circuit and Soroban host function. + * + * Preimage: [nullifier, secret, poolId] */ - 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); + getCommitment(): Buffer { + // Structural stand-in for Poseidon(nullifier, secret, poolId) + // In production, use @noir-lang/barretenberg for the real BN254 Poseidon. + const input = Buffer.concat([ + this.nullifier, + this.secret, + Buffer.from(this.poolId, 'hex'), + ]); + return createHash('sha256').update(input).digest(); } + // --------------------------------------------------------------------------- + // Backup API (stable, versioned, integrity-checked) + // --------------------------------------------------------------------------- + /** - * In a real implementation, this would use a WASM-based Poseidon hash - * compatible with the Noir circuit and Soroban host function. + * Export this note as a portable backup string. + * + * Format: `privacylayer-note:` + * Payload (107 bytes): + * [0] version byte (0x01) + * [1..31] nullifier (31 bytes) + * [32..62] secret (31 bytes) + * [63..94] poolId (32 bytes, decoded from hex) + * [95..102] amount (8 bytes, BigUInt64BE) + * [103..106] SHA-256 checksum over bytes [0..102] (first 4 bytes) */ - getCommitment(): Buffer { - // Placeholder commitment derivation for SDK plumbing tests. - // Production should replace this with Poseidon(nullifier, secret). - return stableHash32('commitment', this.nullifier, this.secret); + exportBackup(): string { + const payload = Buffer.alloc(BACKUP_PAYLOAD_LENGTH); + let offset = 0; + + payload[offset++] = BACKUP_VERSION; + note_nullifier: { + this.nullifier.copy(payload, offset); + offset += 31; + } + note_secret: { + this.secret.copy(payload, offset); + offset += 31; + } + note_poolid: { + Buffer.from(this.poolId, 'hex').copy(payload, offset); + offset += 32; + } + payload.writeBigUInt64BE(this.amount, offset); + offset += 8; + + const checksum = createHash('sha256').update(payload.subarray(0, offset)).digest(); + checksum.copy(payload, offset, 0, 4); + + return BACKUP_PREFIX + payload.toString('hex'); } /** diff --git a/sdk/src/proof.ts b/sdk/src/proof.ts index 8e36e92..fa33803 100644 --- a/sdk/src/proof.ts +++ b/sdk/src/proof.ts @@ -1,5 +1,36 @@ import { Note } from './note'; -import { normalizeHex, stableHash32 } from './stable'; +import { + computeNullifierHash, + fieldToHex, + merkleNodeToField, + noteScalarToField, + poolIdToField, + stellarAddressToField, +} from './encoding'; +import { validateMerkleProof } from './merkle'; +import { assertValidGroth16ProofBytes, assertValidPreparedWithdrawalWitness, assertValidStellarAccountId } from './witness'; + +export type ProvingErrorCode = + | 'ARTIFACT_ERROR' + | 'WITNESS_ERROR' + | 'BACKEND_ERROR' + | 'FORMATTING_ERROR'; + +/** + * ProvingError + * + * A stable error model for proof generation failures. + */ +export class ProvingError extends Error { + constructor( + message: string, + public readonly code: ProvingErrorCode, + public readonly cause?: any + ) { + super(message); + this.name = 'ProvingError'; + } +} export interface MerkleProof { root: Buffer; @@ -14,52 +45,6 @@ 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 * @@ -175,23 +160,39 @@ export class ProofGenerator { recipient: string, relayer: string = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', fee: bigint = 0n - ): Promise { - const rootHex = merkleProof.root.toString('hex'); - const nullifierHex = note.nullifier.toString('hex'); - - return { - root: rootHex, - nullifier_hash: computeNullifierHashHex(nullifierHex, rootHex), - recipient: recipient, + ): Promise { + try { + validateMerkleProof(merkleProof); + assertValidStellarAccountId(recipient, 'recipient'); + if (fee > 0n) { + assertValidStellarAccountId(relayer, 'relayer'); + } + } catch (e: any) { + throw new ProvingError(`Formatting error during witness prep: ${e.message}`, 'FORMATTING_ERROR', e); + } + + const nullifierField = noteScalarToField(note.nullifier); + const secretField = noteScalarToField(note.secret); + const poolIdField = poolIdToField(note.poolId); + const rootField = merkleNodeToField(merkleProof.root); + const nullifierHash = computeNullifierHash(nullifierField, rootField); + const recipientField = stellarAddressToField(recipient); + const relayerField = fee === 0n ? fieldToHex(0n) : stellarAddressToField(relayer); + + const witness: PreparedWitness = { + // Private witnesses + nullifier: nullifierField, + secret: secretField, + leaf_index: merkleProof.leafIndex.toString(), + hash_path: merkleProof.pathElements.map(merkleNodeToField), + // Public inputs + pool_id: poolIdField, + root: rootField, + nullifier_hash: nullifierHash, + recipient: recipientField, amount: note.amount.toString(), - relayer: relayer, + relayer: relayerField, fee: fee.toString(), - pool_id: note.poolId, - nullifier: nullifierHex, - secret: note.secret.toString('hex'), - leaf_index: merkleProof.leafIndex.toString(), - path_elements: merkleProof.pathElements.map((e) => e.toString('hex')), - path_indices: merkleProof.pathIndices.map((i) => i.toString()) }; try { diff --git a/sdk/src/stable.ts b/sdk/src/stable.ts deleted file mode 100644 index 854a00c..0000000 --- a/sdk/src/stable.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 95108bc..7c6c486 100644 --- a/sdk/src/withdraw.ts +++ b/sdk/src/withdraw.ts @@ -1,7 +1,5 @@ import { Note } from './note'; -import { MerkleProof, ProofCache, ProofGenerator, ProvingBackend, VerifyingBackend, WithdrawalWitness } from './proof'; -import { BatchSyncResult, CommitmentLike, LocalMerkleTree, MerkleCheckpoint, syncCommitmentBatch } from './merkle'; -import { stableHash32, stableStringify } from './stable'; +import { MerkleProof, ProofGenerator, ProvingBackend } from './proof'; /** * WithdrawalRequest @@ -16,75 +14,6 @@ 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 * @@ -97,8 +26,7 @@ export function restoreWithdrawalTree(checkpoint: MerkleCheckpoint): LocalMerkle */ export async function generateWithdrawalProof( request: WithdrawalRequest, - backend: ProvingBackend, - options: WithdrawalProofGenerationOptions = {} + backend: ProvingBackend ): Promise { const { note, merkleProof, recipient, relayer, fee } = request; @@ -111,24 +39,12 @@ 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 - const proof = ProofGenerator.formatProof(rawProof); - if (options.cache) { - await options.cache.set(key, proof); - } - return proof; + return ProofGenerator.formatProof(rawProof); } /** @@ -137,7 +53,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: WithdrawalWitness): string[] { +export function extractPublicInputs(witness: any): string[] { // Ordered according to circuits/withdraw/src/main.nr: // 1. pool_id // 2. root @@ -171,7 +87,7 @@ export async function verifyWithdrawalProof( proof: Uint8Array, publicInputs: string[], artifacts: any, - backend: VerifyingBackend + backend: import('./proof').VerifyingBackend ): Promise { return backend.verifyProof(proof, publicInputs, artifacts); } diff --git a/sdk/test/merkle_checkpoint.test.ts b/sdk/test/merkle_checkpoint.test.ts deleted file mode 100644 index 8ba0667..0000000 --- a/sdk/test/merkle_checkpoint.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 998bfea..0000000 --- a/sdk/test/note_randomness.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 939d255..0000000 --- a/sdk/test/proof_cache.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 2a64993..0000000 --- a/sdk/test/zk_integration.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -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'); - }); -});