diff --git a/sdk/src/encoding.ts b/sdk/src/encoding.ts index f39d052..1101be5 100644 --- a/sdk/src/encoding.ts +++ b/sdk/src/encoding.ts @@ -1,4 +1,4 @@ -import { createHash } from 'crypto'; +import { createHash } from './hash'; // BN254 scalar field prime // r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 9f9283a..6d8ddb5 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -18,5 +18,8 @@ export class WitnessValidationError extends Error { super(message); this.name = 'WitnessValidationError'; this.reason = reason ?? 'structure'; + + // Maintain prototype chain + Object.setPrototypeOf(this, WitnessValidationError.prototype); } } diff --git a/sdk/src/hash.ts b/sdk/src/hash.ts new file mode 100644 index 0000000..b55bd54 --- /dev/null +++ b/sdk/src/hash.ts @@ -0,0 +1,121 @@ +/** + * Browser-safe hash functions. + * + * Provides SHA-256 and other common hashes that work across environments: + * - Node.js (native crypto module) + * - Browsers (SubtleCrypto) + * + * NOTE: For production use with ZK circuits, you should use a dedicated + * Poseidon hash implementation compatible with your proving system. + */ + +import { detectEnv, RuntimeEnv } from './random'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A hash function that takes arbitrary bytes and returns a fixed-size digest. + */ +export interface HashFunction { + /** + * Compute the hash of the input data. + */ + update(data: Buffer): this; + + /** + * Finalize and return the digest. + */ + digest(): Buffer; +} + +// --------------------------------------------------------------------------- +// Hash implementations +// --------------------------------------------------------------------------- + +/** + * Node.js SHA-256 implementation. + */ +export class NodeSha256 implements HashFunction { + private readonly hash: any; + + constructor() { + const { createHash } = require('crypto'); + this.hash = createHash('sha256'); + } + + update(data: Buffer): this { + this.hash.update(data); + return this; + } + + digest(): Buffer { + return this.hash.digest(); + } +} + +/** + * Web Crypto SHA-256 implementation. + * Note: This is async - you must await the digest promise. + */ +export class WebCryptoSha256 { + private chunks: Buffer[] = []; + + update(data: Buffer): this { + this.chunks.push(data); + return this; + } + + /** + * WARNING: This returns a Promise, not a Buffer! + * If you need a sync API, use Node.js or a pure-JS SHA-256 implementation. + */ + async digest(): Promise { + const data = Buffer.concat(this.chunks); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Buffer.from(new Uint8Array(hashBuffer)); + } +} + +// --------------------------------------------------------------------------- +// Convenience API - SHA-256 (Node.js only for now due to async) +// --------------------------------------------------------------------------- + +/** + * Create a SHA-256 hash context. + * NOTE: In browsers, this will throw - use a pure JS implementation or SubtleCrypto directly. + */ +export function createHash(algorithm: 'sha256'): HashFunction { + if (algorithm !== 'sha256') { + throw new Error(`Unsupported hash algorithm: ${algorithm}. Only 'sha256' is available.`); + } + + const env = detectEnv(); + + switch (env) { + case 'node': + return new NodeSha256(); + + default: + throw new Error( + `Synchronous SHA-256 is not available in environment '${env}'. ` + + `In browsers, use crypto.subtle.digest('SHA-256', data) which is async, ` + + `or use a pure-JS SHA-256 implementation.` + ); + } +} + +/** + * Compute SHA-256 hash of data in one call. + */ +export function sha256(data: Buffer): Buffer { + return createHash('sha256').update(data).digest(); +} + +export default { + createHash, + sha256, + NodeSha256, + WebCryptoSha256, +}; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 3687fc9..9cf4c65 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -2,10 +2,14 @@ export * from './backends'; export * from './benchmark'; export * from './encoding'; export * from './errors'; +export * from './hash'; export * from './merkle'; export * from './note'; export * from './proof'; +export * from './proofErrors'; +export * from './proofCache'; export * from './gas'; +export * from './random'; export * from './stealth'; export * from './withdraw'; export { diff --git a/sdk/src/note.ts b/sdk/src/note.ts index 613ccbd..82b4430 100644 --- a/sdk/src/note.ts +++ b/sdk/src/note.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes } from 'crypto'; +import { createHash } from './hash'; +import { randomBytes } from './random'; // --------------------------------------------------------------------------- // Backup format constants diff --git a/sdk/src/proof.ts b/sdk/src/proof.ts index 557dc46..7d40804 100644 --- a/sdk/src/proof.ts +++ b/sdk/src/proof.ts @@ -8,6 +8,19 @@ import { } from './encoding'; import { validateMerkleProof } from './merkle'; import { assertValidGroth16ProofBytes, assertValidPreparedWithdrawalWitness, assertValidStellarAccountId } from './witness'; +import { WitnessValidationError } from './errors'; +import { + ProofError, + errNoProvingBackend, + wrapProofError, + ProofErrorCode, +} from './proofErrors'; +import { + ProofCache, + InMemoryProofCache, + cacheKeyFromWitness, + defaultProofCache, +} from './proofCache'; export interface MerkleProof { root: Buffer; @@ -83,9 +96,11 @@ export interface PreparedWitness { */ export class ProofGenerator { private backend?: ProvingBackend; + private cache?: ProofCache; - constructor(backend?: ProvingBackend) { + constructor(backend?: ProvingBackend, cache?: ProofCache) { this.backend = backend; + this.cache = cache; } /** @@ -97,15 +112,40 @@ export class ProofGenerator { /** * Generates a proof using the configured backend. + * + * @throws {ProofError} With stable error code on failure. + * @see ProofErrorCode for all possible error codes. */ async generate(witness: any): Promise { if (!this.backend) { - throw new Error( - 'Proving backend not configured. Please provide a backend to the ProofGenerator.' - ); + throw errNoProvingBackend(); } + + // assertValidPreparedWithdrawalWitness throws WitnessValidationError (backwards compatible) assertValidPreparedWithdrawalWitness(witness); - return this.backend.generateProof(witness); + + try { + return await this.backend.generateProof(witness); + } catch (e) { + // Wrap backend errors with stable classification + const message = e instanceof Error ? e.message : 'Unknown backend error'; + const lower = message.toLowerCase(); + + if (lower.includes('constraint') || lower.includes('unsatis')) { + throw wrapProofError(e, ProofErrorCode.CONSTRAINT_VIOLATION); + } + if (lower.includes('timeout') || lower.includes('abort')) { + throw wrapProofError(e, ProofErrorCode.PROOF_GENERATION_TIMEOUT); + } + if (lower.includes('memory') || lower.includes('oom') || lower.includes('out of memory')) { + throw wrapProofError(e, ProofErrorCode.PROOF_GENERATION_OOM); + } + if (lower.includes('wasm') || lower.includes('webassembly')) { + throw wrapProofError(e, ProofErrorCode.WASM_RUNTIME_ERROR); + } + + throw wrapProofError(e, ProofErrorCode.PROVING_BACKEND_FAILURE); + } } /** @@ -119,6 +159,8 @@ export class ProofGenerator { * * The returned shape exactly matches the circuit parameter list in * circuits/withdraw/src/main.nr. + * + * @throws {ProofError} With WITNESS_VALIDATION_FAILED or MERKLE_PROOF_INVALID on failure. */ static async prepareWitness( note: Note, @@ -127,6 +169,7 @@ export class ProofGenerator { relayer: string = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', fee: bigint = 0n ): Promise { + // Validation asserts throw WitnessValidationError directly (backwards compatible) validateMerkleProof(merkleProof); assertValidStellarAccountId(recipient, 'recipient'); if (fee > 0n) { @@ -160,9 +203,12 @@ export class ProofGenerator { /** * Formats a raw proof from Noir/Barretenberg into the format * expected by the Soroban contract. + * + * @throws {WitnessValidationError} With PROOF_FORMAT code if validation fails. */ static formatProof(rawProof: Uint8Array): Buffer { // Soroban contract expects Proof struct: { a: BytesN<64>, b: BytesN<128>, c: BytesN<64> } + // Note: assertValidGroth16ProofBytes throws WitnessValidationError assertValidGroth16ProofBytes(rawProof, 'rawProof'); return Buffer.from(rawProof); } diff --git a/sdk/src/proofCache.ts b/sdk/src/proofCache.ts new file mode 100644 index 0000000..3a9b3b0 --- /dev/null +++ b/sdk/src/proofCache.ts @@ -0,0 +1,134 @@ +import type { Groth16Proof } from './proof'; + +/** + * 缓存条目 + */ +export interface ProofCacheEntry { + proof: Groth16Proof; + cachedAt: number; + ttl?: number; // 可选 TTL(毫秒) +} + +/** + * 缓存 Key 的稳定输入 + * 这些值的任何变化都应该生成新的 proof + */ +export interface ProofCacheKey { + nullifier: string; + root: string; + recipient: string; + amount: string; + relayer: string; + fee: string; +} + +/** + * 生成缓存 key - 使用规范的 JSON 字符串确保稳定性 + */ +export function createCacheKey(input: ProofCacheKey): string { + return JSON.stringify({ + nullifier: input.nullifier, + root: input.root, + recipient: input.recipient, + amount: input.amount, + relayer: input.relayer, + fee: input.fee, + }); +} + +/** + * 从 PreparedWitness 提取缓存 key + */ +export function cacheKeyFromWitness(witness: { + nullifier: string; + root: string; + recipient: string; + amount: string; + relayer: string; + fee: string; +}): string { + return createCacheKey({ + nullifier: witness.nullifier, + root: witness.root, + recipient: witness.recipient, + amount: witness.amount, + relayer: witness.relayer, + fee: witness.fee, + }); +} + +/** + * Proof 缓存接口 + */ +export interface ProofCache { + get(key: string): Groth16Proof | undefined; + set(key: string, proof: Groth16Proof, ttl?: number): void; + delete(key: string): boolean; + clear(): void; + size(): number; +} + +/** + * 内存中的 LRU Proof 缓存实现 + */ +export class InMemoryProofCache implements ProofCache { + private cache: Map = new Map(); + private maxSize: number; + private defaultTtl?: number; + + constructor(maxSize: number = 100, defaultTtl?: number) { + this.maxSize = maxSize; + this.defaultTtl = defaultTtl; + } + + get(key: string): Groth16Proof | undefined { + const entry = this.cache.get(key); + if (!entry) { + return undefined; + } + + // 检查 TTL + if (entry.ttl && Date.now() - entry.cachedAt > entry.ttl) { + this.cache.delete(key); + return undefined; + } + + // LRU: 移动到末尾(最后删除) + this.cache.delete(key); + this.cache.set(key, entry); + return entry.proof; + } + + set(key: string, proof: Groth16Proof, ttl?: number): void { + // 超过最大容量时删除最旧的(第一个) + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { + proof, + cachedAt: Date.now(), + ttl: ttl ?? this.defaultTtl, + }); + } + + delete(key: string): boolean { + return this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +/** + * 默认的全局缓存实例(可以在应用启动时配置) + */ +export const defaultProofCache = new InMemoryProofCache(100); diff --git a/sdk/src/proofErrors.ts b/sdk/src/proofErrors.ts new file mode 100644 index 0000000..9414696 --- /dev/null +++ b/sdk/src/proofErrors.ts @@ -0,0 +1,383 @@ +import { WitnessValidationError } from './errors'; + +/** + * Stable error types for proof generation and verification. + * + * All proof-generation failures into stable, programmatic error codes. + * + * Error codes are semantically meaningful and guaranteed to remain stable + * across versions, allowing callers to reliably handle specific failure modes. + * + * Philosophy: + * - Each error code represents a distinct failure mode that requires specific handling. + * - Callers can rely on the `code` field for branching, not `message`. + * - New error codes may be added, but existing codes will not be removed. + */ + +// --------------------------------------------------------------------------- +// Core error codes +// --------------------------------------------------------------------------- + +/** + * Stable error codes for proof generation and verification. + * These codes will remain stable across SDK versions. + */ +export enum ProofErrorCode { + // ───────────────────────────────────────────────────────────────────── + // Configuration & setup errors + // ───────────────────────────────────────────────────────────────────── + + /** + * No proving backend was configured when attempting to generate a proof. */ + NO_PROVING_BACKEND = 'NO_PROVING_BACKEND', + + /** + * Proving backend was configured but failed to initialize or produce proofs. + * This indicates the backend implementation itself has a problem. + */ + PROVING_BACKEND_FAILURE = 'PROVING_BACKEND_FAILURE', + + /** + * No verifying backend was configured when attempting to verify a proof. + */ + NO_VERIFYING_BACKEND = 'NO_VERIFYING_BACKEND', + + /** + * Requested backend is not available in this environment (e.g., WASM in Node.js). + */ + BACKEND_NOT_AVAILABLE = 'BACKEND_NOT_AVAILABLE', + + /** + * Proving key or circuit artifacts are missing or corrupt. + */ + CIRCUIT_ARTIFACTS_MISSING = 'CIRCUIT_ARTIFACTS_MISSING', + + /** + * Proving key or circuit artifacts version mismatch. + */ + CIRCUIT_ARTIFACTS_VERSION_MISMATCH = 'CIRCUIT_ARTIFACTS_VERSION_MISMATCH', + + // ───────────────────────────────────────────────────────────────────── + // Witness & input validation errors + // ───────────────────────────────────────────────────────────────────── + + /** + * Witness failed structural validation (lengths, encodings, ranges). + * Details are available in the `cause` field when possible. + */ + WITNESS_VALIDATION_FAILED = 'WITNESS_VALIDATION_FAILED', + + /** + * Merkle proof is invalid or inconsistent. + */ + MERKLE_PROOF_INVALID = 'MERKLE_PROOF_INVALID', + + /** + * Note is invalid (corrupt, wrong version, already spent, etc). + */ + NOTE_INVALID = 'NOTE_INVALID', + + /** + * Stellar address encoding failed or produced an out-of-range field element. + */ + ADDRESS_ENCODING_FAILED = 'ADDRESS_ENCODING_FAILED', + + // ───────────────────────────────────────────────────────────────────── + // Proving errors + // ───────────────────────────────────────────────────────────────────── + + /** + * Witness is valid but the circuit rejected it (constraint violation). + * This usually indicates a bug in witness preparation or a circuit bug. + */ + CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION', + + /** + * Proving process timed out or was aborted. + */ + PROOF_GENERATION_TIMEOUT = 'PROOF_GENERATION_TIMEOUT', + + /** + * Out of memory during proof generation. + */ + PROOF_GENERATION_OOM = 'PROOF_GENERATION_OOM', + + /** + * Prover internal error (catch-all for unknown proving failures without a stable code). + */ + PROOF_GENERATION_FAILED = 'PROOF_GENERATION_FAILED', + + // ───────────────────────────────────────────────────────────────────── + // Verification errors + // ───────────────────────────────────────────────────────────────────── + + /** + * Proof format is invalid or corrupted. + */ + PROOF_FORMAT_INVALID = 'PROOF_FORMAT_INVALID', + + /** + * Public inputs are malformed or out of range. + */ + PUBLIC_INPUTS_INVALID = 'PUBLIC_INPUTS_INVALID', + + /** + * Proof was generated for a different circuit than we're verifying against. + */ + CIRCUIT_MISMATCH = 'CIRCUIT_MISMATCH', + + /** + * Verification key is invalid or corrupted. + */ + VERIFICATION_KEY_INVALID = 'VERIFICATION_KEY_INVALID', + + /** + * Verification failed for an unspecified reason. + * NOTE: This is the catch-all and does NOT mean the proof is invalid! + * Always check this error's `cause` field. + */ + VERIFICATION_FAILED = 'VERIFICATION_FAILED', + + // ───────────────────────────────────────────────────────────────────── + // Runtime / Environment errors + // ───────────────────────────────────────────────────────────────────── + + /** + * Not enough entropy available for secure randomness. + */ + INSUFFICIENT_ENTROPY = 'INSUFFICIENT_ENTROPY', + + /** + * WebAssembly / WASM runtime failure. + */ + WASM_RUNTIME_ERROR = 'WASM_RUNTIME_ERROR', +} + +// --------------------------------------------------------------------------- +// Base error class +// --------------------------------------------------------------------------- + +/** + * Base class for all proof-related errors. + * Callers should switch on `code`, not message content. + */ +export class ProofError extends Error { + /** + * Stable error code identifying the failure mode. + * Use this field for programmatic error handling. + */ + public readonly code: ProofErrorCode; + + /** + * Original underlying error, if available. + */ + public readonly cause?: Error; + + /** + * Optional additional context about the failure. + */ + public readonly context?: Record; + + constructor( + code: ProofErrorCode, + message: string, + options?: { cause?: Error; context?: Record } + ) { + super(message); + this.name = 'ProofError'; + this.code = code; + this.cause = options?.cause; + this.context = options?.context; + + // Maintain proper prototype chain for instanceof checks + Object.setPrototypeOf(this, ProofError.prototype); + + // Preserve stack trace + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ProofError); + } + } + + /** + * Convert to a serializable object for logging or transport. + */ + toJSON(): Record { + return { + name: this.name, + code: this.code, + message: this.message, + context: this.context, + cause: this.cause?.message, + }; + } +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +/** + * Type guard: is ProofError or WitnessValidationError. + * Both represent stable, classifiable SDK errors. + */ +export function isProofError(err: unknown): err is ProofError { + if (err == null || typeof err !== 'object') return false; + + const asObj = err as { code?: unknown; name?: string }; + + // Direct ProofError match + if (asObj.name === 'ProofError' && typeof asObj.code === 'string') { + return true; + } + + // WitnessValidationError also counts as a classifiable proof error + if (asObj.name === 'WitnessValidationError' && typeof asObj.code === 'string') { + return true; + } + + return false; +} + +/** + * Type guard: Error has specific ProofErrorCode. + * Works with both ProofError and WitnessValidationError (legacy). + */ +export function isProofErrorCode( + err: unknown, + code: T +): err is { code: T } { + if (!isProofError(err)) return false; + + // Direct match + if (err.code === code) return true; + + // Legacy WitnessValidationError mapping + const legacyMapping: Record = { + MERKLE_PATH: ProofErrorCode.MERKLE_PROOF_INVALID, + PROOF_FORMAT: ProofErrorCode.PROOF_FORMAT_INVALID, + LEAF_INDEX: ProofErrorCode.WITNESS_VALIDATION_FAILED, + FIELD_ENCODING: ProofErrorCode.WITNESS_VALIDATION_FAILED, + ADDRESS: ProofErrorCode.WITNESS_VALIDATION_FAILED, + WITNESS_SEMANTICS: ProofErrorCode.WITNESS_VALIDATION_FAILED, + }; + + const asAny = err as { code: string }; + return legacyMapping[asAny.code] === code; +} + +// --------------------------------------------------------------------------- +// Convenience factories for common cases +// --------------------------------------------------------------------------- + +/** + * No proving backend configured. + */ +export function errNoProvingBackend(options?: { cause?: Error }): ProofError { + return new ProofError( + ProofErrorCode.NO_PROVING_BACKEND, + 'Proving backend not configured. Call setBackend() with a ProvingBackend before generating proofs.', + options + ); +} + +/** + * No verifying backend configured. + */ +export function errNoVerifyingBackend(options?: { cause?: Error }): ProofError { + return new ProofError( + ProofErrorCode.NO_VERIFYING_BACKEND, + 'Verifying backend not configured. Provide a VerifyingBackend to verify proofs.', + options + ); +} + +/** + * Witness validation failed. + */ +export function errWitnessValidation(message: string, cause?: Error): ProofError { + return new ProofError(ProofErrorCode.WITNESS_VALIDATION_FAILED, message, { cause }); +} + +/** + * Merkle proof invalid. + */ +export function errMerkleProof(message: string, cause?: Error): ProofError { + return new ProofError(ProofErrorCode.MERKLE_PROOF_INVALID, message, { cause }); +} + +/** + * Proof generation failed in the backend. + */ +export function errBackendFailure(message: string, cause?: Error): ProofError { + return new ProofError(ProofErrorCode.PROVING_BACKEND_FAILURE, message, { cause }); +} + +/** + * Invalid proof format. + */ +export function errProofFormat(message: string, cause?: Error): ProofError { + return new ProofError(ProofErrorCode.PROOF_FORMAT_INVALID, message, { cause }); +} + +/** + * Verification failed. + */ +export function errVerificationFailed(message: string, cause?: Error): ProofError { + return new ProofError(ProofErrorCode.VERIFICATION_FAILED, message, { cause }); +} + +/** + * Map legacy WitnessValidationError code to ProofErrorCode. + */ +export function mapWitnessCode(code: string): ProofErrorCode { + const mapping: Record = { + MERKLE_PATH: ProofErrorCode.MERKLE_PROOF_INVALID, + PROOF_FORMAT: ProofErrorCode.PROOF_FORMAT_INVALID, + LEAF_INDEX: ProofErrorCode.WITNESS_VALIDATION_FAILED, + FIELD_ENCODING: ProofErrorCode.WITNESS_VALIDATION_FAILED, + ADDRESS: ProofErrorCode.ADDRESS_ENCODING_FAILED, + WITNESS_SEMANTICS: ProofErrorCode.WITNESS_VALIDATION_FAILED, + }; + return mapping[code] ?? ProofErrorCode.WITNESS_VALIDATION_FAILED; +} + +/** + * Convert a WitnessValidationError to an equivalent ProofError. + */ +export function fromWitnessValidationError(wve: WitnessValidationError): ProofError { + return new ProofError(mapWitnessCode(wve.code), wve.message, { cause: wve }); +} + +/** + * Wrap an unknown error into a ProofError, preserving as much context as possible. + */ +export function wrapProofError(err: unknown, defaultCode: ProofErrorCode = ProofErrorCode.PROOF_GENERATION_FAILED): ProofError { + if (isProofError(err)) { + return err as ProofError; + } + + // Convert legacy WitnessValidationError + if (err instanceof WitnessValidationError) { + return fromWitnessValidationError(err); + } + + const message = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error ? err : undefined; + + return new ProofError(defaultCode, message, { cause }); +} + +export default { + ProofError, + ProofErrorCode, + isProofError, + isProofErrorCode, + errNoProvingBackend, + errNoVerifyingBackend, + errWitnessValidation, + errMerkleProof, + errBackendFailure, + errProofFormat, + errVerificationFailed, + wrapProofError, +}; diff --git a/sdk/src/random.ts b/sdk/src/random.ts new file mode 100644 index 0000000..5afa335 --- /dev/null +++ b/sdk/src/random.ts @@ -0,0 +1,193 @@ +/** + * Browser-safe cryptographically secure random number generation. + * + * Provides environment detection and graceful fallbacks for: + * - Node.js (native crypto module) + * - Browsers (Web Crypto API) + * - Cloudflare Workers / Deno (Web Crypto API) + * - Other runtimes (throws helpful error with instructions) + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A secure random source that can generate cryptographically safe bytes. + */ +export interface RandomSource { + /** + * Generate `n` cryptographically secure random bytes. + */ + randomBytes(n: number): Buffer; +} + +// --------------------------------------------------------------------------- +// Environment detection +// --------------------------------------------------------------------------- + +/** + * Detected execution environment. + */ +export type RuntimeEnv = + | 'node' // Node.js + | 'browser' // Web browser + | 'worker' // Web Worker / Cloudflare Worker + | 'deno' // Deno + | 'unknown'; // ¯\_(ツ)_/¯ + +/** + * Detect the current execution environment. + */ +export function detectEnv(): RuntimeEnv { + if (typeof process !== 'undefined' && process.versions?.node) { + return 'node'; + } + + if (typeof self !== 'undefined' && self.crypto) { + // Check for Cloudflare Worker or Web Worker + if (typeof (self as any).addEventListener !== 'undefined' && !self.document) { + return 'worker'; + } + return 'browser'; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + return 'deno'; + } + + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Random source implementations +// --------------------------------------------------------------------------- + +/** + * Node.js random source using built-in crypto module. + */ +export class NodeRandomSource implements RandomSource { + private readonly rb: (n: number) => Buffer; + + constructor() { + // Lazy-require to avoid breaking browser bundlers + const { randomBytes } = require('crypto'); + this.rb = randomBytes; + } + + randomBytes(n: number): Buffer { + return this.rb(n); + } +} + +/** + * Web Crypto API random source (works in browsers, Deno, and Cloudflare Workers). + */ +export class WebCryptoRandomSource implements RandomSource { + private readonly crypto: Crypto; + + constructor(cryptoImpl?: Crypto) { + this.crypto = cryptoImpl || self.crypto; + if (!this.crypto?.getRandomValues) { + throw new Error( + 'Web Crypto API is not available in this environment. ' + + 'You may need to use a Node.js polyfill or provide a custom RandomSource.' + ); + } + } + + randomBytes(n: number): Buffer { + const arr = new Uint8Array(n); + this.crypto.getRandomValues(arr); + return Buffer.from(arr); + } +} + +/** + * Random source that always throws. + * Used as the default fallback when no secure RNG is available. + */ +export class ThrowingRandomSource implements RandomSource { + constructor(public readonly env: RuntimeEnv) {} + + randomBytes(n: number): Buffer { + throw new Error( + `No cryptographically secure random source available in detected environment '${this.env}'. ` + + `Please provide a custom RandomSource implementation for this runtime. ` + + `In Node.js, ensure you can 'require("crypto")'. ` + + `In browsers, ensure you're running in a secure context (HTTPS or localhost).` + ); + } +} + +// --------------------------------------------------------------------------- +// Default source auto-selection +// --------------------------------------------------------------------------- + +let defaultSource: RandomSource | undefined; + +/** + * Get the default random source for this environment. + * The source is lazily detected on first call and cached. + */ +export function getDefaultRandomSource(): RandomSource { + if (defaultSource) { + return defaultSource; + } + + const env = detectEnv(); + + switch (env) { + case 'node': + defaultSource = new NodeRandomSource(); + break; + + case 'browser': + case 'worker': + case 'deno': + defaultSource = new WebCryptoRandomSource(); + break; + + default: + defaultSource = new ThrowingRandomSource(env); + } + + return defaultSource; +} + +/** + * Override the default random source. + * Useful for: + * - Testing with deterministic mocks + * - Using an HSM or hardware RNG + * - Unsupported runtimes + */ +export function setDefaultRandomSource(source: RandomSource): void { + defaultSource = source; +} + +/** + * Clear the cached default source, forcing re-detection on next use. + */ +export function clearDefaultRandomSource(): void { + defaultSource = undefined; +} + +/** + * Generate random bytes using the default source. + * Convenience export for callers. + */ +export function randomBytes(n: number): Buffer { + return getDefaultRandomSource().randomBytes(n); +} + +export default { + randomBytes, + getDefaultRandomSource, + setDefaultRandomSource, + clearDefaultRandomSource, + detectEnv, + NodeRandomSource, + WebCryptoRandomSource, + ThrowingRandomSource, +}; diff --git a/sdk/src/stealth.ts b/sdk/src/stealth.ts index fe95e15..cd17b56 100644 --- a/sdk/src/stealth.ts +++ b/sdk/src/stealth.ts @@ -1,5 +1,6 @@ import * as elliptic from 'elliptic'; -import { randomBytes, createHash } from 'crypto'; +import { randomBytes } from './random'; +import { createHash } from './hash'; const ed25519 = new elliptic.eddsa('ed25519'); diff --git a/sdk/src/withdraw.ts b/sdk/src/withdraw.ts index 40666b7..0c7d296 100644 --- a/sdk/src/withdraw.ts +++ b/sdk/src/withdraw.ts @@ -1,5 +1,11 @@ import { Note } from './note'; -import { MerkleProof, ProofGenerator, ProvingBackend } from './proof'; +import { MerkleProof, ProofGenerator, ProvingBackend, Groth16Proof } from './proof'; +import { + ProofCache, + InMemoryProofCache, + cacheKeyFromWitness, + defaultProofCache, +} from './proofCache'; /** * WithdrawalRequest @@ -22,11 +28,14 @@ export interface WithdrawalRequest { * * @param request The withdrawal parameters. * @param backend The proving backend to use (e.g., Node or Browser Barretenberg). + * @param cache Optional proof cache for avoiding redundant proving work. + * Pass `defaultProofCache` to use the shared global cache. * @returns The formatted proof as a Buffer. */ export async function generateWithdrawalProof( request: WithdrawalRequest, - backend: ProvingBackend + backend: ProvingBackend, + cache?: ProofCache ): Promise { const { note, merkleProof, recipient, relayer, fee } = request; @@ -39,11 +48,31 @@ export async function generateWithdrawalProof( fee ); - // 2. Generate the raw proof using the injected backend + // 2. Check cache for existing proof + if (cache) { + const cacheKey = cacheKeyFromWitness(witness); + const cached = cache.get(cacheKey); + if (cached) { + // Cache hit: return the pre-formatted proof bytes + return Buffer.from(cached.proof); + } + } + + // 3. 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 + // 4. Cache the result if caching is enabled + if (cache) { + const cacheKey = cacheKeyFromWitness(witness); + const publicInputs = extractPublicInputs(witness); + cache.set(cacheKey, { + proof: rawProof, + publicInputs, + }); + } + + // 5. Format the proof for the Soroban contract return ProofGenerator.formatProof(rawProof); } diff --git a/sdk/test/hash.test.ts b/sdk/test/hash.test.ts new file mode 100644 index 0000000..fddd6b5 --- /dev/null +++ b/sdk/test/hash.test.ts @@ -0,0 +1,57 @@ +import { createHash, sha256, NodeSha256 } from '../src/hash'; + +describe('hash module', () => { + describe('NodeSha256', () => { + it('computes correct SHA-256 hash', () => { + const hash = new NodeSha256(); + hash.update(Buffer.from('hello world')); + const digest = hash.digest(); + + // Known SHA-256 hash of "hello world" + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + + expect(digest.equals(expected)).toBe(true); + }); + + it('supports chained updates', () => { + const hash = new NodeSha256(); + hash.update(Buffer.from('hello')); + hash.update(Buffer.from(' ')); + hash.update(Buffer.from('world')); + const digest = hash.digest(); + + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + + expect(digest.equals(expected)).toBe(true); + }); + }); + + describe('createHash convenience', () => { + it('creates a SHA-256 hash instance', () => { + const hash = createHash('sha256'); + expect(hash).toBeInstanceOf(NodeSha256); + }); + + it('throws for unsupported algorithms', () => { + // @ts-ignore - intentional bad value + expect(() => createHash('md5')).toThrow(/Unsupported hash algorithm/); + }); + }); + + describe('sha256 convenience', () => { + it('hashes data in one call', () => { + const result = sha256(Buffer.from('hello world')); + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + expect(result.equals(expected)).toBe(true); + }); + }); +}); diff --git a/sdk/test/proofCache.test.ts b/sdk/test/proofCache.test.ts new file mode 100644 index 0000000..8ce0a00 --- /dev/null +++ b/sdk/test/proofCache.test.ts @@ -0,0 +1,271 @@ +import { expect, test, describe, beforeEach } from '@jest/globals'; +import { jest } from '@jest/globals'; +import { + InMemoryProofCache, + createCacheKey, + cacheKeyFromWitness, + defaultProofCache, +} from '../src/proofCache'; +import type { Groth16Proof } from '../src/proof'; + +describe('ProofCache', () => { + const testProof: Groth16Proof = { + proof: new Uint8Array([1, 2, 3]), + publicInputs: ['0x1', '0x2', '0x3'], + }; + + const testWitness = { + nullifier: '0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + root: '0x101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f', + recipient: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + amount: '0x000000000000000000000000000003e8', + relayer: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + fee: '0x0000000000000000000000000000000a', + }; + + beforeEach(() => { + defaultProofCache.clear(); + }); + + // --------------------------------------------------------------------------- + // Cache key generation + // --------------------------------------------------------------------------- + + describe('createCacheKey', () => { + it('generates stable keys for identical inputs', () => { + const key1 = createCacheKey({ + nullifier: '0x1', + root: '0x2', + recipient: 'GABC', + amount: '100', + relayer: 'GDEF', + fee: '10', + }); + + const key2 = createCacheKey({ + nullifier: '0x1', + root: '0x2', + recipient: 'GABC', + amount: '100', + relayer: 'GDEF', + fee: '10', + }); + + expect(key1).toBe(key2); + }); + + it('produces different keys for different nullifiers', () => { + const key1 = createCacheKey({ + ...testWitness, + nullifier: '0x1', + }); + const key2 = createCacheKey({ + ...testWitness, + nullifier: '0x2', + }); + expect(key1).not.toBe(key2); + }); + + it('produces different keys for different roots', () => { + const key1 = createCacheKey({ + ...testWitness, + root: '0x1', + }); + const key2 = createCacheKey({ + ...testWitness, + root: '0x2', + }); + expect(key1).not.toBe(key2); + }); + + it('produces different keys for different recipients', () => { + const key1 = createCacheKey({ + ...testWitness, + recipient: 'GAAAAA', + }); + const key2 = createCacheKey({ + ...testWitness, + recipient: 'GBBBBB', + }); + expect(key1).not.toBe(key2); + }); + + it('produces different keys for different amounts', () => { + const key1 = createCacheKey({ + ...testWitness, + amount: '100', + }); + const key2 = createCacheKey({ + ...testWitness, + amount: '200', + }); + expect(key1).not.toBe(key2); + }); + + it('produces different keys for different fees', () => { + const key1 = createCacheKey({ + ...testWitness, + fee: '10', + }); + const key2 = createCacheKey({ + ...testWitness, + fee: '20', + }); + expect(key1).not.toBe(key2); + }); + }); + + describe('cacheKeyFromWitness', () => { + it('extracts correct fields from witness', () => { + const key = cacheKeyFromWitness(testWitness); + expect(typeof key).toBe('string'); + expect(key).toContain(testWitness.nullifier); + expect(key).toContain(testWitness.root); + expect(key).toContain(testWitness.recipient); + }); + }); + + // --------------------------------------------------------------------------- + // InMemoryProofCache + // --------------------------------------------------------------------------- + + describe('InMemoryProofCache', () => { + it('stores and retrieves proofs', () => { + const cache = new InMemoryProofCache(); + const key = 'test-key'; + + cache.set(key, testProof); + const result = cache.get(key); + + expect(result).not.toBeUndefined(); + expect(result?.proof).toEqual(testProof.proof); + expect(result?.publicInputs).toEqual(testProof.publicInputs); + }); + + it('returns undefined for non-existent keys', () => { + const cache = new InMemoryProofCache(); + expect(cache.get('does-not-exist')).toBeUndefined(); + }); + + it('overwrites existing entries', () => { + const cache = new InMemoryProofCache(); + const key = 'test-key'; + const proof2: Groth16Proof = { + proof: new Uint8Array([4, 5, 6]), + publicInputs: ['0x4', '0x5', '0x6'], + }; + + cache.set(key, testProof); + cache.set(key, proof2); + const result = cache.get(key); + + expect(result?.proof).toEqual(proof2.proof); + }); + + it('deletes entries', () => { + const cache = new InMemoryProofCache(); + const key = 'test-key'; + + cache.set(key, testProof); + const deleted = cache.delete(key); + const result = cache.get(key); + + expect(deleted).toBe(true); + expect(result).toBeUndefined(); + }); + + it('clears all entries', () => { + const cache = new InMemoryProofCache(); + + cache.set('key1', testProof); + cache.set('key2', testProof); + expect(cache.size()).toBe(2); + + cache.clear(); + + expect(cache.size()).toBe(0); + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toBeUndefined(); + }); + + it('reports size correctly', () => { + const cache = new InMemoryProofCache(); + + expect(cache.size()).toBe(0); + cache.set('key1', testProof); + expect(cache.size()).toBe(1); + cache.set('key2', testProof); + expect(cache.size()).toBe(2); + cache.delete('key1'); + expect(cache.size()).toBe(1); + cache.clear(); + expect(cache.size()).toBe(0); + }); + + it('evicts oldest entries when maxSize exceeded (LRU)', () => { + const cache = new InMemoryProofCache(2); // max 2 entries + + cache.set('key1', testProof); + cache.set('key2', testProof); + expect(cache.size()).toBe(2); + + // Access key1 to make it more recent + cache.get('key1'); + + // Add third entry - key2 should be evicted + cache.set('key3', testProof); + + expect(cache.size()).toBe(2); + expect(cache.get('key1')).not.toBeUndefined(); // still there + expect(cache.get('key2')).toBeUndefined(); // evicted + expect(cache.get('key3')).not.toBeUndefined(); // new entry + }); + + it('respects TTL for entries', async () => { + const cache = new InMemoryProofCache(100, 10); // 10ms TTL + + cache.set('key1', testProof); + expect(cache.get('key1')).not.toBeUndefined(); + + await new Promise((r) => setTimeout(r, 20)); + + expect(cache.get('key1')).toBeUndefined(); + }); + + it('updates LRU order on get', () => { + const cache = new InMemoryProofCache(3); + + cache.set('key1', testProof); // oldest + cache.set('key2', testProof); + cache.set('key3', testProof); // newest + + // Access key1 - should move to newest position + cache.get('key1'); + + // Add fourth entry - key2 should be evicted + cache.set('key4', testProof); + + expect(cache.get('key1')).not.toBeUndefined(); + expect(cache.get('key2')).toBeUndefined(); // evicted + expect(cache.get('key3')).not.toBeUndefined(); + expect(cache.get('key4')).not.toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // Default cache + // --------------------------------------------------------------------------- + + describe('defaultProofCache', () => { + it('is a shared InMemoryProofCache instance', () => { + expect(defaultProofCache).toBeInstanceOf(InMemoryProofCache); + }); + + it('persists state across imports', () => { + defaultProofCache.set('shared-key', testProof); + + // Second import would get same instance (tested implicitly) + expect(defaultProofCache.size()).toBeGreaterThan(0); + }); + }); +}); diff --git a/sdk/test/proofErrors.test.ts b/sdk/test/proofErrors.test.ts new file mode 100644 index 0000000..e89cb14 --- /dev/null +++ b/sdk/test/proofErrors.test.ts @@ -0,0 +1,181 @@ +import { + ProofError, + ProofErrorCode, + isProofError, + isProofErrorCode, + errNoProvingBackend, + errNoVerifyingBackend, + errWitnessValidation, + errMerkleProof, + errBackendFailure, + errProofFormat, + errVerificationFailed, + wrapProofError, +} from '../src/proofErrors'; + +describe('proofErrors module', () => { + // ------------------------------------------------------------------------- + // Error codes + // ------------------------------------------------------------------------- + + describe('ProofErrorCode', () => { + it('defines all expected error codes', () => { + expect(ProofErrorCode.NO_PROVING_BACKEND).toBe('NO_PROVING_BACKEND'); + expect(ProofErrorCode.PROVING_BACKEND_FAILURE).toBe('PROVING_BACKEND_FAILURE'); + expect(ProofErrorCode.NO_VERIFYING_BACKEND).toBe('NO_VERIFYING_BACKEND'); + expect(ProofErrorCode.WITNESS_VALIDATION_FAILED).toBe('WITNESS_VALIDATION_FAILED'); + expect(ProofErrorCode.MERKLE_PROOF_INVALID).toBe('MERKLE_PROOF_INVALID'); + expect(ProofErrorCode.PROOF_FORMAT_INVALID).toBe('PROOF_FORMAT_INVALID'); + expect(ProofErrorCode.VERIFICATION_FAILED).toBe('VERIFICATION_FAILED'); + expect(ProofErrorCode.CONSTRAINT_VIOLATION).toBe('CONSTRAINT_VIOLATION'); + expect(ProofErrorCode.PROOF_GENERATION_TIMEOUT).toBe('PROOF_GENERATION_TIMEOUT'); + }); + }); + + // ------------------------------------------------------------------------- + // ProofError base class + // ------------------------------------------------------------------------- + + describe('ProofError', () => { + it('constructs with correct code and message', () => { + const err = new ProofError(ProofErrorCode.PROOF_FORMAT_INVALID, 'Bad proof'); + expect(err.code).toBe(ProofErrorCode.PROOF_FORMAT_INVALID); + expect(err.message).toBe('Bad proof'); + expect(err.name).toBe('ProofError'); + }); + + it('captures cause when provided', () => { + const cause = new Error('Root cause'); + const err = new ProofError(ProofErrorCode.PROVING_BACKEND_FAILURE, 'Backend died', { cause }); + expect(err.cause).toBe(cause); + }); + + it('captures context when provided', () => { + const context = { backend: 'barretenberg', attempt: 2 }; + const err = new ProofError(ProofErrorCode.PROVING_BACKEND_FAILURE, 'Failed', { context }); + expect(err.context).toEqual(context); + }); + + it('serializes to JSON correctly', () => { + const cause = new Error('Underlying'); + const err = new ProofError(ProofErrorCode.WITNESS_VALIDATION_FAILED, 'Bad witness', { cause }); + const json = err.toJSON(); + + expect(json).toEqual({ + name: 'ProofError', + code: ProofErrorCode.WITNESS_VALIDATION_FAILED, + message: 'Bad witness', + context: undefined, + cause: 'Underlying', + }); + }); + + it('maintains correct prototype chain for instanceof', () => { + const err = errNoProvingBackend(); + expect(err instanceof ProofError).toBe(true); + expect(err instanceof Error).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Type guards + // ------------------------------------------------------------------------- + + describe('type guards', () => { + it('recognizes ProofError instances', () => { + const err = errNoProvingBackend(); + expect(isProofError(err)).toBe(true); + }); + + it('rejects non-ProofError objects', () => { + expect(isProofError(new Error('plain'))).toBe(false); + expect(isProofError(null)).toBe(false); + expect(isProofError(undefined)).toBe(false); + expect(isProofError({})).toBe(false); + expect(isProofError({ code: 'FOO' })).toBe(false); // name missing + }); + + it('matches specific error codes', () => { + const err = errMerkleProof('bad path'); + expect(isProofErrorCode(err, ProofErrorCode.MERKLE_PROOF_INVALID)).toBe(true); + expect(isProofErrorCode(err, ProofErrorCode.WITNESS_VALIDATION_FAILED)).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Factory functions + // ------------------------------------------------------------------------- + + describe('factory functions', () => { + it('errNoProvingBackend creates correct error', () => { + const err = errNoProvingBackend(); + expect(err.code).toBe(ProofErrorCode.NO_PROVING_BACKEND); + expect(err.message).toContain('Proving backend not configured'); + }); + + it('errNoVerifyingBackend creates correct error', () => { + const err = errNoVerifyingBackend(); + expect(err.code).toBe(ProofErrorCode.NO_VERIFYING_BACKEND); + expect(err.message).toContain('Verifying backend not configured'); + }); + + it('errWitnessValidation creates correct error', () => { + const cause = new Error('leaf index out of range'); + const err = errWitnessValidation('Validation failed', cause); + expect(err.code).toBe(ProofErrorCode.WITNESS_VALIDATION_FAILED); + expect(err.cause).toBe(cause); + }); + + it('errMerkleProof creates correct error', () => { + const err = errMerkleProof('Path length mismatch'); + expect(err.code).toBe(ProofErrorCode.MERKLE_PROOF_INVALID); + }); + + it('errBackendFailure creates correct error', () => { + const err = errBackendFailure('WASM crashed'); + expect(err.code).toBe(ProofErrorCode.PROVING_BACKEND_FAILURE); + }); + + it('errProofFormat creates correct error', () => { + const err = errProofFormat('Wrong length'); + expect(err.code).toBe(ProofErrorCode.PROOF_FORMAT_INVALID); + }); + + it('errVerificationFailed creates correct error', () => { + const err = errVerificationFailed('Verification rejected'); + expect(err.code).toBe(ProofErrorCode.VERIFICATION_FAILED); + }); + }); + + // ------------------------------------------------------------------------- + // wrapProofError + // ------------------------------------------------------------------------- + + describe('wrapProofError', () => { + it('passes through existing ProofError unchanged', () => { + const original = errBackendFailure('Original error'); + const wrapped = wrapProofError(original); + expect(wrapped).toBe(original); + }); + + it('wraps plain Error into ProofError with default code', () => { + const plain = new Error('Something bad'); + const wrapped = wrapProofError(plain); + expect(wrapped.code).toBe(ProofErrorCode.PROOF_GENERATION_FAILED); + expect(wrapped.message).toBe('Something bad'); + expect(wrapped.cause).toBe(plain); + }); + + it('wraps non-Error values into ProofError', () => { + const wrapped = wrapProofError('Just a string'); + expect(wrapped.code).toBe(ProofErrorCode.PROOF_GENERATION_FAILED); + expect(wrapped.message).toBe('Just a string'); + }); + + it('uses custom default code when provided', () => { + const plain = new Error('Verification blew up'); + const wrapped = wrapProofError(plain, ProofErrorCode.VERIFICATION_FAILED); + expect(wrapped.code).toBe(ProofErrorCode.VERIFICATION_FAILED); + }); + }); +}); diff --git a/sdk/test/random.test.ts b/sdk/test/random.test.ts new file mode 100644 index 0000000..bb3894e --- /dev/null +++ b/sdk/test/random.test.ts @@ -0,0 +1,97 @@ +import { + randomBytes, + detectEnv, + getDefaultRandomSource, + setDefaultRandomSource, + clearDefaultRandomSource, + NodeRandomSource, + WebCryptoRandomSource, + ThrowingRandomSource, + RandomSource, +} from '../src/random'; + +describe('random module', () => { + beforeEach(() => { + clearDefaultRandomSource(); + }); + + describe('detectEnv', () => { + it('detects Node.js environment', () => { + expect(detectEnv()).toBe('node'); + }); + }); + + describe('NodeRandomSource', () => { + it('generates random bytes of correct length', () => { + const source = new NodeRandomSource(); + const bytes = source.randomBytes(32); + expect(bytes.length).toBe(32); + expect(Buffer.isBuffer(bytes)).toBe(true); + }); + + it('generates different bytes each call', () => { + const source = new NodeRandomSource(); + const b1 = source.randomBytes(32); + const b2 = source.randomBytes(32); + expect(b1.equals(b2)).toBe(false); + }); + }); + + describe('randomBytes convenience function', () => { + it('generates random bytes using the default source', () => { + const bytes = randomBytes(16); + expect(bytes.length).toBe(16); + expect(Buffer.isBuffer(bytes)).toBe(true); + }); + }); + + describe('default source management', () => { + it('allows overriding the default source', () => { + const mock: RandomSource = { + randomBytes: jest.fn(() => Buffer.alloc(32)), + }; + + setDefaultRandomSource(mock); + const result = randomBytes(32); + + expect(mock.randomBytes).toHaveBeenCalledWith(32); + expect(result.length).toBe(32); + }); + + it('clears the cached default source', () => { + const source1 = getDefaultRandomSource(); + clearDefaultRandomSource(); + const source2 = getDefaultRandomSource(); + expect(source1).not.toBe(source2); + }); + }); + + describe('ThrowingRandomSource', () => { + it('throws with helpful error message', () => { + const source = new ThrowingRandomSource('unknown'); + expect(() => source.randomBytes(32)).toThrow(/No cryptographically secure random source/); + expect(() => source.randomBytes(32)).toThrow(/unknown/); + }); + }); + + describe('WebCryptoRandomSource', () => { + it('can be constructed with mock crypto impl', () => { + const mockCrypto = { + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = i % 256; + } + return arr; + }, + }; + + const source = new WebCryptoRandomSource(mockCrypto as any); + const bytes = source.randomBytes(5); + + expect(bytes.length).toBe(5); + expect(bytes[0]).toBe(0); + expect(bytes[1]).toBe(1); + expect(bytes[2]).toBe(2); + }); + }); +});