diff --git a/package-lock.json b/package-lock.json index 9f64b426..07c13b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "abitype": "^1.3.0", "chalk": "^6.0.0", "commander": "^15.0.0", "prompts": "^2.4.2", @@ -1076,6 +1077,7 @@ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2526,6 +2528,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2704,6 +2707,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -2809,6 +2813,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index e154c703..ac8f634b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "import": "./dist/esm/providers/index.js", "require": "./dist/cjs/providers/index.js" }, + "./zk": { + "types": "./dist/esm/zk/index.d.ts", + "import": "./dist/esm/zk/index.js", + "require": "./dist/cjs/zk/index.js" "./wallet": { "types": "./dist/esm/wallet/index.d.ts", "import": "./dist/esm/wallet/index.js", @@ -67,6 +71,7 @@ "author": "", "license": "MIT", "dependencies": { + "abitype": "^1.3.0", "@noble/curves": "^1.9.0", "abitype": "^1.3.0", "chalk": "^6.0.0", @@ -82,6 +87,15 @@ "@vitest/coverage-v8": "^1.6.1", "typedoc": "^0.28.20", "typescript": "^5.4.0", + "vitest": "^1.3.1" + }, + "peerDependencies": { + "snarkjs": ">=0.7.0" + }, + "peerDependenciesMeta": { + "snarkjs": { + "optional": true + } "vitest": "^1.3.1", "ws": "^8.16.0" } diff --git a/src/core/Contract.ts b/src/core/Contract.ts index 71604b6e..39358bfd 100644 --- a/src/core/Contract.ts +++ b/src/core/Contract.ts @@ -1,3 +1,6 @@ +import type { Address, PublicClient, WalletClient, Hash } from 'viem' +import type { Abi, ExtractAbiFunctionNames, ExtractAbiFunction, AbiParametersToPrimitiveTypes, AbiStateMutability } from 'abitype' +import { ValidationError } from '../errors/index.js' import type { Abi, Address, PublicClient } from 'viem' import { WhiteChainError } from '../types.js' import type { Address, PublicClient, WalletClient, Hash } from 'viem' @@ -106,6 +109,9 @@ export class Contract< public readonly publicClient?: PublicClient, public readonly walletClient?: WalletClient, ) {} + + /** + * Strongly typed wrapper for publicClient.readContract /** * Representation of a deployed smart contract bound to an address, ABI, and optional clients. * @@ -222,6 +228,16 @@ export class Contract< const _args = args.length > 0 ? (args[0] as unknown[]) : [] + return (this.publicClient as any).readContract({ + address: this.address, + abi: this.abi, + functionName, + args: _args, + }) + } + + /** + * Strongly typed wrapper for walletClient.writeContract try { return await (this.publicClient as any).readContract({ address: this.address, @@ -250,6 +266,12 @@ export class Contract< const _args = args.length > 0 ? (args[0] as unknown[]) : [] + return (this.walletClient as any).writeContract({ + address: this.address, + abi: this.abi, + functionName, + args: _args, + }) try { return await (this.walletClient as any).writeContract({ address: this.address, diff --git a/src/index.ts b/src/index.ts index e8b53922..e7817659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export * from './constants.js' export * from './config/networks.js' export * from './network/provider.js' export * from './network/BatchProvider.js' +export { Contract } from './core/Contract.js' export * from './core/TransactionHelper.js' export { NetworkContext, type NetworkObserver, type NetworkState } from './core/NetworkContext.js' export { AbiCache, abiCache } from './core/AbiCache.js' @@ -63,6 +64,8 @@ export type { export { TODO } from './types.js' export * from './errors/index.js' +export * from './storage/index.js' +export * from './zk/index.js' export * from './errors/WhitechainErrors.js' export { parseContractError } from './utils/errorHandler.js' export * from './storage/index.js' @@ -87,6 +90,9 @@ export { } from './providers/IpcProvider.js' export { + MockProvider, + returns, +} from './testing/MockProvider.js' RpcProvider, createRpcProvider, type RpcProviderOptions, diff --git a/src/network/provider.ts b/src/network/provider.ts index 70396c36..e4bd365c 100644 --- a/src/network/provider.ts +++ b/src/network/provider.ts @@ -1,6 +1,7 @@ import { http, type Transport } from 'viem' import type { WhiteChainConfig, WhiteChainAddresses } from '../types.js' import type { NetworkProfile } from '../config/networks.js' +import { ValidationError } from '../errors/index.js' import { TimeoutError, ValidationError } from '../errors/index.js' type RateLimitListener = () => void diff --git a/src/zk/Prover.ts b/src/zk/Prover.ts new file mode 100644 index 00000000..7e4f2474 --- /dev/null +++ b/src/zk/Prover.ts @@ -0,0 +1,152 @@ +/** + * ZkProver — the main public facade for generating Groth16 zk-SNARK proofs. + * + * Abstracts all complexity: + * - Fetching + caching .wasm and .zkey files from a CDN + * - Verifying artifact integrity (SHA-256) before use + * - Offloading proof generation to a Web Worker (no UI freezing) + * - Falling back to main-thread execution in Node.js / environments without Worker + * - Formatting proof output into Solidity-ready calldata + * + * @example + * ```ts + * import { ZkProver } from 'whitechain-sdk' + * + * const prover = new ZkProver({ + * wasmUrl: 'https://cdn.example.com/vote.wasm', + * zkeyUrl: 'https://cdn.example.com/vote_final.zkey', + * wasmHash: 'abc123...', // optional SHA-256 integrity hash + * zkeyHash: 'def456...', // optional SHA-256 integrity hash + * }) + * + * // Runs in a Web Worker — UI stays responsive + * const calldata = await prover.prove({ + * nullifier: '123456', + * voteOption: '1', + * merkleProof: [...], + * }) + * + * // Pass directly to the on-chain verifier + * await governanceContract.castVote(calldata.pA, calldata.pB, calldata.pC, calldata.pubSignals) + * ``` + */ + +import { fetchArtifact } from './artifacts.js' +import { formatCalldata } from './format.js' +import { createWorkerBlobUrl } from './worker.js' +import type { ZkProverOptions, ProofCalldata, SnarkJSModule, Groth16Proof } from './types.js' + +export class ZkProver { + private readonly options: ZkProverOptions + + constructor(options: ZkProverOptions) { + if (!options.wasmUrl) throw new Error('ZkProver: wasmUrl is required') + if (!options.zkeyUrl) throw new Error('ZkProver: zkeyUrl is required') + this.options = options + } + + /** + * Generates a Groth16 zk-SNARK proof for the given circuit input. + * + * The proving pipeline: + * 1. Downloads .wasm and .zkey files in parallel (cached after first call). + * 2. Optionally verifies SHA-256 hashes. + * 3. If `Worker` is available (browser): runs `snarkjs.groth16.fullProve` in a + * Web Worker using Transferable buffers for zero-copy performance. + * 4. If `Worker` is not available (Node.js/SSR): falls back to direct main-thread execution. + * 5. Formats the raw SnarkJS output into Solidity `uint256` calldata. + * + * @param input The private and public inputs required by the circuit. + * @returns `ProofCalldata` ready to pass to the on-chain Groth16 verifier. + */ + async prove(input: Record): Promise { + // Step 1 & 2: Fetch and verify artifacts in parallel + const [wasm, zkey] = await Promise.all([ + fetchArtifact(this.options.wasmUrl, this.options.wasmHash), + fetchArtifact(this.options.zkeyUrl, this.options.zkeyHash), + ]) + + // Step 3: Run in Web Worker if available, else fall back to main thread + const { proof, publicSignals } = typeof Worker !== 'undefined' + ? await this._proveInWorker(wasm, zkey, input) + : await this._proveMainThread(wasm, zkey, input) + + // Step 4: Format calldata + return formatCalldata(proof, publicSignals) + } + + /** + * Runs proof generation in a Web Worker using a Blob URL. + * Buffers are transferred (zero-copy) to the worker thread. + */ + private _proveInWorker( + wasm: Uint8Array, + zkey: Uint8Array, + input: Record + ): Promise<{ proof: Groth16Proof; publicSignals: string[] }> { + return new Promise((resolve, reject) => { + let blobUrl: string | null = null + + try { + blobUrl = createWorkerBlobUrl() + } catch { + // If Blob URL creation fails (e.g. missing Blob API), fall back to main thread + this._proveMainThread(wasm, zkey, input).then(resolve).catch(reject) + return + } + + const worker = new Worker(blobUrl, { type: 'module' }) + + worker.onmessage = (event) => { + URL.revokeObjectURL(blobUrl!) + worker.terminate() + + const { proof, publicSignals, error } = event.data + if (error) { + reject(new Error(`ZkProver worker error: ${error}`)) + } else { + resolve({ proof, publicSignals }) + } + } + + worker.onerror = (err) => { + URL.revokeObjectURL(blobUrl!) + worker.terminate() + reject(new Error(`ZkProver worker crashed: ${err.message}`)) + } + + // Transfer buffers (zero-copy) — originals become detached + const wasmCopy = wasm.slice() + const zkeyCopy = zkey.slice() + + worker.postMessage( + { wasmBuffer: wasmCopy.buffer, zkeyBuffer: zkeyCopy.buffer, input }, + [wasmCopy.buffer, zkeyCopy.buffer] + ) + }) + } + + /** + * Main-thread fallback for Node.js and SSR environments without Worker. + * Dynamically imports snarkjs so non-ZK users never pay the load cost. + */ + private async _proveMainThread( + wasm: Uint8Array, + zkey: Uint8Array, + input: Record + ): Promise<{ proof: Groth16Proof; publicSignals: string[] }> { + let snarkjs: SnarkJSModule + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + snarkjs = (await import('snarkjs' as any)) as SnarkJSModule + } catch { + throw new Error( + 'snarkjs is not installed. Run: npm install snarkjs\n' + + 'snarkjs is an optional peer dependency required only for ZK proof generation.' + ) + } + + return snarkjs.groth16.fullProve(input, wasm, zkey) + } +} diff --git a/src/zk/artifacts.ts b/src/zk/artifacts.ts new file mode 100644 index 00000000..a3c97bc1 --- /dev/null +++ b/src/zk/artifacts.ts @@ -0,0 +1,93 @@ +/** + * Secure fetching and in-memory caching of large ZK proving artifacts + * (.wasm circuit files and .zkey proving keys). + */ + +import { verifyIntegrity } from './integrity.js' + +/** Module-level cache to prevent redundant CDN downloads. */ +const _artifactCache = new Map() + +/** + * Downloads a proving artifact (wasm/zkey) from a URL, optionally verifies + * its SHA-256 hash, and caches the result in memory. + * + * On subsequent calls with the same URL, the cached buffer is returned + * immediately without any network request. + * + * @param url The URL to fetch the artifact from. + * @param expectedHash Optional SHA-256 hex digest to verify after download. + * @returns The file contents as a `Uint8Array`. + * + * @throws If the network request fails or the integrity check fails. + */ +export async function fetchArtifact( + url: string, + expectedHash?: string +): Promise { + // Return from cache if available + const cached = _artifactCache.get(url) + if (cached) return cached + + let lastError: unknown + + // Exponential backoff retry (mirrors fetchWithRetry from storage) + let delay = 1000 + for (let attempt = 0; attempt <= 3; attempt++) { + try { + const response = await fetch(url) + + if (response.status === 429 && attempt < 3) { + await new Promise((r) => setTimeout(r, delay)) + delay *= 2 + continue + } + + if (!response.ok) { + throw new Error( + `Failed to fetch artifact from ${url}: HTTP ${response.status} ${response.statusText}` + ) + } + + const buffer = new Uint8Array(await response.arrayBuffer()) + + // Integrity check + if (expectedHash) { + const valid = await verifyIntegrity(buffer, expectedHash) + if (!valid) { + throw new Error( + `Integrity check failed for ${url}. ` + + 'The downloaded file does not match the expected SHA-256 hash. ' + + 'This may indicate a compromised CDN or corrupted file.' + ) + } + } + + _artifactCache.set(url, buffer) + return buffer + } catch (err) { + lastError = err + if ( + err instanceof Error && + (err.message.includes('Integrity check') || + err.message.includes('Failed to fetch artifact')) + ) { + throw err + } + if (attempt < 3) { + await new Promise((r) => setTimeout(r, delay)) + delay *= 2 + } + } + } + + throw lastError ?? new Error(`Failed to fetch artifact from ${url}`) +} + +/** + * Clears the in-memory artifact cache. + * Useful for testing or when proving keys are rotated. + */ +export function clearArtifactCache(): void { + _artifactCache.clear() +} diff --git a/src/zk/format.ts b/src/zk/format.ts new file mode 100644 index 00000000..d1205ff2 --- /dev/null +++ b/src/zk/format.ts @@ -0,0 +1,71 @@ +/** + * Formats raw SnarkJS Groth16 proof output into Solidity-compatible calldata. + * + * The standard Groth16 verifier contract expects: + * verifyProof(uint[2] pA, uint[2][2] pB, uint[2] pC, uint[] pubSignals) + * + * SnarkJS outputs BN254 curve points as decimal strings. This module converts + * them to bigints and applies the coordinate reversal required for pB. + */ + +import type { Groth16Proof, ProofCalldata } from './types.js' + +/** + * Converts a decimal string (as returned by SnarkJS) to a bigint. + * Throws a descriptive error if the string is not a valid non-negative integer. + */ +function toUint256(value: string, field: string): bigint { + if (!/^\d+$/.test(value.trim())) { + throw new Error( + `ZK formatCalldata: expected a non-negative decimal integer for field "${field}", got: "${value}"` + ) + } + return BigInt(value.trim()) +} + +/** + * Converts a raw SnarkJS Groth16 proof and public signals into the + * exact `uint256` array structure required by the Solidity verifier. + * + * **Note on pB coordinate reversal:** + * SnarkJS outputs BN254 G2 points as `[[x1, x0], [y1, y0], ...]` but the + * EVM Groth16 verifier (based on the snarkjs `verifier.sol` template) expects + * the coordinates in reversed order: `[[x0, x1], [y0, y1]]`. + * + * @param proof Raw proof object from `snarkjs.groth16.fullProve`. + * @param publicSignals Public signals array from `snarkjs.groth16.fullProve`. + * @returns On-chain ready `ProofCalldata`. + * + * @example + * ```ts + * const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasm, zkey) + * const calldata = formatCalldata(proof, publicSignals) + * await verifier.verifyProof(calldata.pA, calldata.pB, calldata.pC, calldata.pubSignals) + * ``` + */ +export function formatCalldata( + proof: Groth16Proof, + publicSignals: string[] +): ProofCalldata { + const pA: [bigint, bigint] = [ + toUint256(proof.pi_a[0], 'pi_a[0]'), + toUint256(proof.pi_a[1], 'pi_a[1]'), + ] + + // BN254 convention: G2 point coordinates are reversed for the EVM verifier + const pB: [[bigint, bigint], [bigint, bigint]] = [ + [toUint256(proof.pi_b[0][1], 'pi_b[0][1]'), toUint256(proof.pi_b[0][0], 'pi_b[0][0]')], + [toUint256(proof.pi_b[1][1], 'pi_b[1][1]'), toUint256(proof.pi_b[1][0], 'pi_b[1][0]')], + ] + + const pC: [bigint, bigint] = [ + toUint256(proof.pi_c[0], 'pi_c[0]'), + toUint256(proof.pi_c[1], 'pi_c[1]'), + ] + + const pubSignals: bigint[] = publicSignals.map((s, i) => + toUint256(s, `publicSignals[${i}]`) + ) + + return { pA, pB, pC, pubSignals } +} diff --git a/src/zk/index.ts b/src/zk/index.ts new file mode 100644 index 00000000..ac9b59a7 --- /dev/null +++ b/src/zk/index.ts @@ -0,0 +1,22 @@ +/** + * ZK Prover module — barrel export. + * + * Provides Groth16 zk-SNARK proof generation with Web Worker offloading, + * CDN artifact fetching with integrity verification, and Solidity calldata formatting. + * + * snarkjs must be installed separately as a peer dependency: + * npm install snarkjs + */ + +export { ZkProver } from './Prover.js' +export { formatCalldata } from './format.js' +export { verifyIntegrity } from './integrity.js' +export { fetchArtifact, clearArtifactCache } from './artifacts.js' + +export type { + ZkProverOptions, + Groth16Proof, + ProofCalldata, + FetchedArtifacts, + SnarkJSModule, +} from './types.js' diff --git a/src/zk/integrity.ts b/src/zk/integrity.ts new file mode 100644 index 00000000..1a55ccd5 --- /dev/null +++ b/src/zk/integrity.ts @@ -0,0 +1,49 @@ +/** + * Runtime integrity verification using the Web Crypto API (SHA-256). + * Works identically in Node.js (>=18) and browser environments via globalThis.crypto. + */ + +/** + * Converts an ArrayBuffer to a lowercase hex string. + */ +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Verifies the SHA-256 integrity of a buffer against an expected hex hash. + * + * @param buffer The downloaded file content. + * @param expectedHash The expected lowercase SHA-256 hex digest. + * @returns `true` if the hash matches, `false` otherwise. + * + * @example + * ```ts + * const ok = await verifyIntegrity(wasmBuffer, '0xabc123...') + * if (!ok) throw new Error('WASM integrity check failed') + * ``` + */ +export async function verifyIntegrity( + buffer: Uint8Array, + expectedHash: string +): Promise { + const crypto = globalThis.crypto + if (!crypto?.subtle) { + throw new Error( + 'Web Crypto API is not available in this environment. ' + + 'Upgrade to Node.js >= 18 or use a modern browser.' + ) + } + + // Copy into a plain ArrayBuffer — SubtleCrypto requires ArrayBuffer, not ArrayBufferLike + const plainBuffer = buffer.slice().buffer as ArrayBuffer + const hashBuffer = await crypto.subtle.digest('SHA-256', plainBuffer) + const actualHash = bufferToHex(hashBuffer) + + // Normalise the expected hash — strip optional 0x prefix, lowercase + const normalised = expectedHash.replace(/^0x/i, '').toLowerCase() + + return actualHash === normalised +} diff --git a/src/zk/types.ts b/src/zk/types.ts new file mode 100644 index 00000000..b4647fb4 --- /dev/null +++ b/src/zk/types.ts @@ -0,0 +1,82 @@ +/** + * Shared TypeScript interfaces and types for the ZK Prover module. + */ + +/** + * Options for configuring the ZkProver. + */ +export interface ZkProverOptions { + /** + * URL to the compiled `.wasm` file for the circuit. + * Will be fetched at runtime and cached in memory. + */ + wasmUrl: string + + /** + * URL to the `.zkey` proving key file for the circuit. + * Will be fetched at runtime and cached in memory. + */ + zkeyUrl: string + + /** + * Optional SHA-256 hex digest of the `.wasm` file. + * If provided, the downloaded file will be verified before use. + * Prevents supply-chain attacks via CDN compromise. + */ + wasmHash?: string + + /** + * Optional SHA-256 hex digest of the `.zkey` file. + * If provided, the downloaded file will be verified before use. + */ + zkeyHash?: string +} + +/** + * Raw Groth16 proof as returned by SnarkJS. + */ +export interface Groth16Proof { + pi_a: [string, string, string] + pi_b: [[string, string], [string, string], [string, string]] + pi_c: [string, string, string] + protocol: string + curve: string +} + +/** + * On-chain ready calldata for the Solidity Groth16 verifier. + * + * Maps directly to the verifier's `verifyProof(uint[2] pA, uint[2][2] pB, uint[2] pC, uint[] pubSignals)`. + */ +export interface ProofCalldata { + /** G1 point A: [x, y] */ + pA: [bigint, bigint] + /** G2 point B: [[x1, x0], [y1, y0]] — note: coordinates are reversed per BN254 convention */ + pB: [[bigint, bigint], [bigint, bigint]] + /** G1 point C: [x, y] */ + pC: [bigint, bigint] + /** Public circuit signals */ + pubSignals: bigint[] +} + +/** + * Internal type holding downloaded artifact buffers. + */ +export interface FetchedArtifacts { + wasm: Uint8Array + zkey: Uint8Array +} + +/** + * SnarkJS module shape (subset used by the prover). + * Typed loosely to avoid requiring @types/snarkjs. + */ +export interface SnarkJSModule { + groth16: { + fullProve( + input: Record, + wasmFile: Uint8Array | string, + zkeyFile: Uint8Array | string + ): Promise<{ proof: Groth16Proof; publicSignals: string[] }> + } +} diff --git a/src/zk/worker.ts b/src/zk/worker.ts new file mode 100644 index 00000000..54b1161c --- /dev/null +++ b/src/zk/worker.ts @@ -0,0 +1,55 @@ +/** + * Web Worker script for offloading Groth16 proof generation off the main thread. + * + * This module is inlined as a string and spawned as a Blob URL worker by Prover.ts, + * which makes it portable — no bundler configuration or separate worker file needed. + * + * Message protocol: + * IN: { wasmBuffer: ArrayBuffer, zkeyBuffer: ArrayBuffer, input: Record } + * OUT: { proof: Groth16Proof, publicSignals: string[] } (success) + * OUT: { error: string } (failure) + */ + +/** + * The inline Web Worker source code as a string. + * snarkjs is dynamically imported inside the worker so that: + * 1. Non-ZK users who never call prove() are never charged the snarkjs load cost. + * 2. The worker can be hosted as a blob URL without a build step. + */ +export const WORKER_SOURCE = /* js */ ` +self.onmessage = async function(event) { + const { wasmBuffer, zkeyBuffer, input } = event.data; + + try { + // Dynamic import — snarkjs must be installed as a peer dependency + let snarkjs; + try { + snarkjs = await import('snarkjs'); + } catch { + throw new Error( + 'snarkjs is not installed. Run: npm install snarkjs\\n' + + 'snarkjs is an optional peer dependency required only for ZK proof generation.' + ); + } + + const { proof, publicSignals } = await snarkjs.groth16.fullProve( + input, + new Uint8Array(wasmBuffer), + new Uint8Array(zkeyBuffer) + ); + + self.postMessage({ proof, publicSignals }); + } catch (err) { + self.postMessage({ error: err instanceof Error ? err.message : String(err) }); + } +}; +` + +/** + * Creates a short-lived Blob URL for the worker source. + * The caller is responsible for calling URL.revokeObjectURL() after the worker terminates. + */ +export function createWorkerBlobUrl(): string { + const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' }) + return URL.createObjectURL(blob) +} diff --git a/tests/core/AbiCache.test.ts b/tests/core/AbiCache.test.ts index 56fab60d..af4c4bb8 100644 --- a/tests/core/AbiCache.test.ts +++ b/tests/core/AbiCache.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { AbiCache } from '../../src/core/AbiCache' import { AbiCache } from '../../src/core/AbiCache.js' import type { Abi } from 'viem' diff --git a/tests/core/Contract.test.ts b/tests/core/Contract.test.ts index 5ec376ab..b9aca1a4 100644 --- a/tests/core/Contract.test.ts +++ b/tests/core/Contract.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest' +import { Contract } from '../../src/core/Contract' import { Contract } from '../../src/core/Contract.js' import { describe, expect, it, vi } from 'vitest' import { Contract } from '../../src/core/Contract.js' @@ -120,6 +121,7 @@ const mockAbi = [ }, ] as const +describe('Contract', () => { describe('Contract — read() / write()', () => { it('calls readContract on publicClient for view functions', async () => { const publicClient = { @@ -127,6 +129,10 @@ describe('Contract — read() / write()', () => { } as any const contract = new Contract('0x1234567890123456789012345678901234567890', mockAbi, publicClient) + + // Type checking ensures 'balanceOf' and ['0x...'] are required + const result = await contract.read('balanceOf', ['0xabcdef1234567890abcdef1234567890abcdef12']) + const result = await contract.read('balanceOf', ['0xabcdef1234567890abcdef1234567890abcdef12']) @@ -145,6 +151,10 @@ describe('Contract — read() / write()', () => { } as any const contract = new Contract('0x1234567890123456789012345678901234567890', mockAbi, undefined, walletClient) + + // Type checking ensures 'transfer' and correct arguments are required + const result = await contract.write('transfer', ['0xabcdef1234567890abcdef1234567890abcdef12', 50n]) + const result = await contract.write('transfer', ['0xabcdef1234567890abcdef1234567890abcdef12', 50n]) @@ -156,6 +166,10 @@ describe('Contract — read() / write()', () => { args: ['0xabcdef1234567890abcdef1234567890abcdef12', 50n], }) }) + + it('throws if clients are missing', async () => { + const contract = new Contract('0x1234567890123456789012345678901234567890', mockAbi) + it('throws if clients are missing', async () => { const contract = new Contract('0x1234567890123456789012345678901234567890', mockAbi) diff --git a/tests/storage/IPFSClient.test.ts b/tests/storage/IPFSClient.test.ts index f8d6bc33..9d8dd08c 100644 --- a/tests/storage/IPFSClient.test.ts +++ b/tests/storage/IPFSClient.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { IPFSClient } from '../../src/storage/IPFSClient' +import type { IPFSAdapter } from '../../src/storage/adapters/types' import { IPFSClient } from '../../src/storage/IPFSClient.js' import type { IPFSAdapter } from '../../src/storage/adapters/types.js' diff --git a/tests/storage/adapters.test.ts b/tests/storage/adapters.test.ts index 18f67353..9d470148 100644 --- a/tests/storage/adapters.test.ts +++ b/tests/storage/adapters.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { PinataAdapter, NFTStorageAdapter } from '../../src/storage/adapters' +import { RpcError } from '../../src/errors' import { PinataAdapter, NFTStorageAdapter } from '../../src/storage/adapters/index.js' import { RpcError } from '../../src/errors/index.js' diff --git a/tests/testing/MockProvider.test.ts b/tests/testing/MockProvider.test.ts index a27d7780..9f5e63df 100644 --- a/tests/testing/MockProvider.test.ts +++ b/tests/testing/MockProvider.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { MockProvider, returns } from '../../src/testing/MockProvider' import { MockProvider, returns } from '../../src/testing/MockProvider.js' describe('MockProvider', () => { diff --git a/tests/utils/address.test.ts b/tests/utils/address.test.ts index d923e9c6..5cbb3435 100644 --- a/tests/utils/address.test.ts +++ b/tests/utils/address.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest' +import { toChecksumAddress, isAddress, assertChecksumAddress } from '../../src/utils/address' +import { ValidationError } from '../../src/errors' import { toChecksumAddress, isAddress, assertChecksumAddress } from '../../src/utils/address.js' import { ValidationError } from '../../src/errors/index.js' diff --git a/tests/zk/Prover.test.ts b/tests/zk/Prover.test.ts new file mode 100644 index 00000000..9c4ed298 --- /dev/null +++ b/tests/zk/Prover.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ZkProver } from '../../src/zk/Prover.js' +import { clearArtifactCache } from '../../src/zk/artifacts.js' +import type { Groth16Proof } from '../../src/zk/types.js' + +const MOCK_WASM = new Uint8Array([0x00, 0x61, 0x73, 0x6d]) +const MOCK_ZKEY = new Uint8Array([0x7a, 0x6b, 0x65, 0x79]) + +const MOCK_PROOF: Groth16Proof = { + pi_a: ['11', '22', '1'], + pi_b: [ + ['33', '44'], + ['55', '66'], + ['1', '0'], + ], + pi_c: ['77', '88', '1'], + protocol: 'groth16', + curve: 'bn128', +} + +const MOCK_SIGNALS = ['1', '0'] + +function mockFetchArtifacts() { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(MOCK_WASM.buffer, { status: 200 }) + ) +} + +beforeEach(() => { + clearArtifactCache() + vi.restoreAllMocks() + // Ensure Worker is NOT available so we always exercise the main-thread path + vi.stubGlobal('Worker', undefined) +}) + +describe('ZkProver constructor', () => { + it('throws if wasmUrl is missing', () => { + expect( + () => new ZkProver({ wasmUrl: '', zkeyUrl: 'https://example.com/zkey' }) + ).toThrow('wasmUrl is required') + }) + + it('throws if zkeyUrl is missing', () => { + expect( + () => new ZkProver({ wasmUrl: 'https://example.com/wasm', zkeyUrl: '' }) + ).toThrow('zkeyUrl is required') + }) +}) + +describe('ZkProver.prove() — main-thread fallback', () => { + const PROVER_OPTIONS = { + wasmUrl: 'https://cdn.example.com/vote.wasm', + zkeyUrl: 'https://cdn.example.com/vote.zkey', + } + + it('calls snarkjs and returns formatted calldata', async () => { + // Mock fetch for both wasm and zkey + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(MOCK_WASM.buffer, { status: 200 })) + .mockResolvedValueOnce(new Response(MOCK_ZKEY.buffer, { status: 200 })) + + // Mock snarkjs dynamic import + vi.doMock('snarkjs', () => ({ + groth16: { + fullProve: vi.fn().mockResolvedValue({ + proof: MOCK_PROOF, + publicSignals: MOCK_SIGNALS, + }), + }, + })) + + const prover = new ZkProver(PROVER_OPTIONS) + const calldata = await prover.prove({ nullifier: '42', voteOption: '1' }) + + expect(calldata.pA).toHaveLength(2) + expect(calldata.pB).toHaveLength(2) + expect(calldata.pC).toHaveLength(2) + expect(calldata.pubSignals).toEqual([1n, 0n]) + }) + + it('pB coordinates are correctly reversed', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(MOCK_WASM.buffer, { status: 200 })) + .mockResolvedValueOnce(new Response(MOCK_ZKEY.buffer, { status: 200 })) + + vi.doMock('snarkjs', () => ({ + groth16: { + fullProve: vi.fn().mockResolvedValue({ + proof: MOCK_PROOF, + publicSignals: MOCK_SIGNALS, + }), + }, + })) + + const prover = new ZkProver(PROVER_OPTIONS) + const calldata = await prover.prove({}) + + // pi_b[0] = ['33', '44'] → pB[0] = [44n, 33n] + expect(calldata.pB[0][0]).toBe(44n) + expect(calldata.pB[0][1]).toBe(33n) + // pi_b[1] = ['55', '66'] → pB[1] = [66n, 55n] + expect(calldata.pB[1][0]).toBe(66n) + expect(calldata.pB[1][1]).toBe(55n) + }) + + it('throws a helpful error when snarkjs is not installed', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(MOCK_WASM.buffer, { status: 200 })) + .mockResolvedValueOnce(new Response(MOCK_ZKEY.buffer, { status: 200 })) + + vi.doMock('snarkjs', () => { + throw new Error('Cannot find module snarkjs') + }) + + const prover = new ZkProver(PROVER_OPTIONS) + await expect(prover.prove({})).rejects.toThrow('npm install snarkjs') + }) + + it('propagates artifact integrity failure', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(MOCK_WASM.buffer, { status: 200 })) + .mockResolvedValueOnce(new Response(MOCK_ZKEY.buffer, { status: 200 })) + + const prover = new ZkProver({ + ...PROVER_OPTIONS, + wasmHash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }) + + await expect(prover.prove({})).rejects.toThrow('Integrity check failed') + }) +}) diff --git a/tests/zk/artifacts.test.ts b/tests/zk/artifacts.test.ts new file mode 100644 index 00000000..cc156c57 --- /dev/null +++ b/tests/zk/artifacts.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fetchArtifact, clearArtifactCache } from '../../src/zk/artifacts.js' + +// Helper: compute SHA-256 of a buffer +async function sha256Hex(data: Uint8Array): Promise { + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +const MOCK_URL = 'https://cdn.example.com/vote.wasm' +const MOCK_DATA = new Uint8Array([0x00, 0x61, 0x73, 0x6d]) // WASM magic bytes + +beforeEach(() => { + clearArtifactCache() + vi.restoreAllMocks() +}) + +describe('fetchArtifact', () => { + it('downloads a file and returns a Uint8Array', async () => { + const mockResponse = new Response(MOCK_DATA.buffer, { status: 200 }) + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockResponse) + + const result = await fetchArtifact(MOCK_URL) + expect(result).toBeInstanceOf(Uint8Array) + expect(result).toEqual(MOCK_DATA) + }) + + it('caches the result — second call does not fetch again', async () => { + const mockFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(MOCK_DATA.buffer, { status: 200 }) + ) + + await fetchArtifact(MOCK_URL) + await fetchArtifact(MOCK_URL) + + // fetch should only have been called once + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('passes integrity check with correct hash', async () => { + const hash = await sha256Hex(MOCK_DATA) + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(MOCK_DATA.buffer, { status: 200 }) + ) + + const result = await fetchArtifact(MOCK_URL, hash) + expect(result).toEqual(MOCK_DATA) + }) + + it('throws if integrity check fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(MOCK_DATA.buffer, { status: 200 }) + ) + + await expect( + fetchArtifact(MOCK_URL, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') + ).rejects.toThrow('Integrity check failed') + }) + + it('throws on non-200 HTTP response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(null, { status: 404, statusText: 'Not Found' }) + ) + + await expect(fetchArtifact(MOCK_URL)).rejects.toThrow('HTTP 404') + }) + + it('clearArtifactCache forces a re-fetch', async () => { + let callCount = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + callCount++ + return new Response(MOCK_DATA.buffer, { status: 200 }) + }) + + await fetchArtifact(MOCK_URL) + expect(callCount).toBe(1) + + clearArtifactCache() + await fetchArtifact(MOCK_URL) + expect(callCount).toBe(2) + }) +}) diff --git a/tests/zk/format.test.ts b/tests/zk/format.test.ts new file mode 100644 index 00000000..ae358e3f --- /dev/null +++ b/tests/zk/format.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { formatCalldata } from '../../src/zk/format.js' +import type { Groth16Proof } from '../../src/zk/types.js' + +// Reference proof from the snarkjs documentation / verifier template +const MOCK_PROOF: Groth16Proof = { + pi_a: [ + '7064994015026294289480764315691672484348282082441831098499455698703086695104', + '5768380490009591965218680048706929717064193891070000978893447045648766361592', + '1', + ], + pi_b: [ + [ + '20166804560038097178592891483823177914064613978044396696671063720944773413440', + '11394745025082948021977082200396049441448785697093406296741891019879126866752', + ], + [ + '9043524893956714461073424929820965406991649742888524254773936978700754578560', + '16406086580024474698887534600421987748428099455898213810668756023820456447232', + ], + ['1', '0'], + ], + pi_c: [ + '12847498867019985671640624588148866561428688897024175843547272278456131952640', + '21803027831866831490028025461447553454384685001462965649977085897748558680064', + '1', + ], + protocol: 'groth16', + curve: 'bn128', +} + +const MOCK_SIGNALS = ['1', '2'] + +describe('formatCalldata', () => { + it('produces bigint values for pA', () => { + const calldata = formatCalldata(MOCK_PROOF, MOCK_SIGNALS) + expect(typeof calldata.pA[0]).toBe('bigint') + expect(typeof calldata.pA[1]).toBe('bigint') + expect(calldata.pA[0]).toBe( + BigInt('7064994015026294289480764315691672484348282082441831098499455698703086695104') + ) + }) + + it('reverses pB G2 coordinates for BN254 / EVM convention', () => { + const calldata = formatCalldata(MOCK_PROOF, MOCK_SIGNALS) + // pi_b[0] = [x1, x0] in SnarkJS → pB[0] = [x0, x1] in calldata + expect(calldata.pB[0][0]).toBe(BigInt(MOCK_PROOF.pi_b[0][1])) + expect(calldata.pB[0][1]).toBe(BigInt(MOCK_PROOF.pi_b[0][0])) + expect(calldata.pB[1][0]).toBe(BigInt(MOCK_PROOF.pi_b[1][1])) + expect(calldata.pB[1][1]).toBe(BigInt(MOCK_PROOF.pi_b[1][0])) + }) + + it('produces bigint values for pC', () => { + const calldata = formatCalldata(MOCK_PROOF, MOCK_SIGNALS) + expect(typeof calldata.pC[0]).toBe('bigint') + expect(typeof calldata.pC[1]).toBe('bigint') + }) + + it('converts pubSignals to bigints', () => { + const calldata = formatCalldata(MOCK_PROOF, MOCK_SIGNALS) + expect(calldata.pubSignals).toEqual([1n, 2n]) + }) + + it('handles a large pubSignals array', () => { + const signals = Array.from({ length: 10 }, (_, i) => String(i * 1000)) + const calldata = formatCalldata(MOCK_PROOF, signals) + expect(calldata.pubSignals).toHaveLength(10) + expect(calldata.pubSignals[9]).toBe(9000n) + }) + + it('throws on non-numeric proof values', () => { + const badProof = { ...MOCK_PROOF, pi_a: ['not-a-number', '1', '1'] as [string, string, string] } + expect(() => formatCalldata(badProof, MOCK_SIGNALS)).toThrow( + 'expected a non-negative decimal integer' + ) + }) + + it('throws on non-numeric public signal', () => { + expect(() => formatCalldata(MOCK_PROOF, ['1', 'bad'])).toThrow( + 'expected a non-negative decimal integer' + ) + }) +}) diff --git a/tests/zk/integrity.test.ts b/tests/zk/integrity.test.ts new file mode 100644 index 00000000..0fc634d1 --- /dev/null +++ b/tests/zk/integrity.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { verifyIntegrity } from '../../src/zk/integrity.js' + +// Helper: compute SHA-256 of a buffer using the native crypto API +async function sha256Hex(data: Uint8Array): Promise { + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +describe('verifyIntegrity', () => { + it('returns true for a correct hash', async () => { + const buffer = new Uint8Array([1, 2, 3, 4, 5]) + const hash = await sha256Hex(buffer) + const result = await verifyIntegrity(buffer, hash) + expect(result).toBe(true) + }) + + it('accepts a hash with 0x prefix', async () => { + const buffer = new Uint8Array([10, 20, 30]) + const hash = await sha256Hex(buffer) + const result = await verifyIntegrity(buffer, `0x${hash}`) + expect(result).toBe(true) + }) + + it('accepts a hash with uppercase letters', async () => { + const buffer = new Uint8Array([99, 88, 77]) + const hash = await sha256Hex(buffer) + const result = await verifyIntegrity(buffer, hash.toUpperCase()) + expect(result).toBe(true) + }) + + it('returns false for a tampered buffer', async () => { + const buffer = new Uint8Array([1, 2, 3]) + const hash = await sha256Hex(buffer) + const tampered = new Uint8Array([1, 2, 4]) // last byte changed + const result = await verifyIntegrity(tampered, hash) + expect(result).toBe(false) + }) + + it('returns false for a completely wrong hash', async () => { + const buffer = new Uint8Array([1, 2, 3]) + const result = await verifyIntegrity( + buffer, + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + ) + expect(result).toBe(false) + }) + + it('verifies an empty buffer', async () => { + const buffer = new Uint8Array([]) + const hash = await sha256Hex(buffer) + const result = await verifyIntegrity(buffer, hash) + expect(result).toBe(true) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 2e2e0441..1bdc62fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "target": "ES2021", "module": "NodeNext", "moduleResolution": "NodeNext",