diff --git a/public/wasm/bulletproofs.README.md b/public/wasm/bulletproofs.README.md new file mode 100644 index 0000000..e5a8c65 --- /dev/null +++ b/public/wasm/bulletproofs.README.md @@ -0,0 +1,37 @@ +# Bulletproofs range-proof WASM + +The Proof-of-Reserve worker (`src/workers/proofWorker.worker.ts`) loads a +Bulletproofs range prover from `bulletproofs.wasm` in this directory. + +## Building + +Compile the dalek `bulletproofs` crate with `wasm-pack`: + +``` +wasm-pack build --target bundler --release +``` + +Constraints (see `src/types/reserve.ts`): + +- Range proof over `[0, 2^64)` with a 64-bit commitment. +- Gzipped binary **must not exceed 2 MB**. +- Proof generation must complete within ~5 s on a Snapdragon 7c tablet. + +## Integrity + +Host the binary and pin its SHA-256 so the loader rejects tampering: + +``` +NEXT_PUBLIC_BULLETPROOFS_WASM_URL=https://cdn.example.com/wasm/bulletproofs.wasm +NEXT_PUBLIC_BULLETPROOFS_WASM_SHA256= +``` + +`src/utils/wasmLoader.ts` (`loadWasmModule`) fetches, verifies, instantiates and +caches it. + +## Development fallback + +Until the binary is present the worker emits a **clearly-marked deterministic +placeholder** proof so the end-to-end flow works in development. The placeholder +is **not** zero-knowledge and must never be used for real attestation — drop the +real `bulletproofs.wasm` here (or set the env URL) before production use. diff --git a/src/components/panels/ReserveAttestationPanel.tsx b/src/components/panels/ReserveAttestationPanel.tsx new file mode 100644 index 0000000..f210e21 --- /dev/null +++ b/src/components/panels/ReserveAttestationPanel.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useMemo } from "react"; +import { + useProofOfReserve, + type UseProofOfReserveResult, +} from "@/hooks/useProofOfReserve"; +import type { ProofOfReserveConfig, ProofOfReserveDeps } from "@/services/proofOfReserve"; +import type { ProofStatus } from "@/types/reserve"; + +/** + * Panel showing the latest Proof-of-Reserve attestation: proof hash, ledger, + * time since the last proof, and a red banner when insolvency is detected. + */ + +export interface ReserveAttestationPanelProps { + config: ProofOfReserveConfig; + deps?: ProofOfReserveDeps; + /** Override the hook (testing). */ + controller?: UseProofOfReserveResult; + className?: string; +} + +const STATUS_LABEL: Record = { + idle: "No attestation yet", + fetching: "Fetching commitment & inventory…", + proving: "Generating range proof…", + submitting: "Submitting attestation…", + confirmed: "Attested", + insolvent: "Insolvency detected", + error: "Proof failed", +}; + +function timeSince(ms: number): string { + const secs = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ago`; +} + +function shortHash(hex: string): string { + return hex.length > 14 ? `${hex.slice(0, 10)}…${hex.slice(-4)}` : hex; +} + +export function ReserveAttestationPanel({ + config, + deps, + controller, + className, +}: ReserveAttestationPanelProps) { + const internal = useProofOfReserve(deps); + const { state, generate, reset } = controller ?? internal; + + const busy = + state.status === "fetching" || + state.status === "proving" || + state.status === "submitting"; + + const sinceLabel = useMemo( + () => (state.result ? timeSince(state.result.provedAt) : null), + [state.result] + ); + + return ( +
+
+
+

Proof of Reserve

+

+ Cryptographic attestation that reserves back on-chain liabilities. +

+
+ + {STATUS_LABEL[state.status]} + +
+ + {/* Insolvency banner */} + {state.insolvency && ( +
+

Reserves do not cover liabilities.

+

+ reserves {state.insolvency.auditTotal} < liability{" "} + {state.insolvency.liability} (shortfall {state.insolvency.shortfall}) +

+
+ )} + + {/* Progress */} + {state.status !== "idle" && !state.insolvency && ( +
+
+
+
+ {state.error && state.status === "error" && ( +

{state.error}

+ )} +
+ )} + + {/* Attestation detail */} + {state.result && ( +
+
Attestation hash
+
+ {shortHash(state.result.attestationHash)} +
+
Ledger
+
+ {state.result.ledger ?? "—"} +
+
Last proof
+
{sinceLabel}
+
+ )} + +
+ + {(state.status === "confirmed" || + state.status === "insolvent" || + state.status === "error") && ( + + )} +
+
+ ); +} + +export default ReserveAttestationPanel; diff --git a/src/hooks/useProofOfReserve.ts b/src/hooks/useProofOfReserve.ts new file mode 100644 index 0000000..f1b5d8a --- /dev/null +++ b/src/hooks/useProofOfReserve.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + InsolvencyError, + runProofOfReserve, + type ProofOfReserveConfig, + type ProofOfReserveDeps, + type ProgressPhase, +} from "@/services/proofOfReserve"; +import type { ProofState, ProofStatus } from "@/types/reserve"; + +/** + * React hook wrapping the Proof-of-Reserve flow. Exposes status, 0–100 progress + * and the attestation result, mapping the orchestrator's progress phases + * (fetch 25 → prove 50 → submit 75 → confirm 100) and surfacing an insolvency + * report when reserves fall short. + */ + +const INITIAL_STATE: ProofState = { + status: "idle", + progress: 0, + result: null, + insolvency: null, + error: null, +}; + +const PHASE_STATUS: Record = { + fetching: "fetching", + proving: "proving", + submitting: "submitting", + confirmed: "confirmed", +}; + +export interface UseProofOfReserveResult { + state: ProofState; + /** Run the proof flow for the given config. */ + generate: (config: ProofOfReserveConfig) => Promise; + reset: () => void; +} + +export function useProofOfReserve( + deps?: ProofOfReserveDeps +): UseProofOfReserveResult { + const [state, setState] = useState(INITIAL_STATE); + const runningRef = useRef(false); + const depsRef = useRef(deps); + depsRef.current = deps; + + const reset = useCallback(() => setState(INITIAL_STATE), []); + + const generate = useCallback(async (config: ProofOfReserveConfig) => { + if (runningRef.current) return; + runningRef.current = true; + setState({ ...INITIAL_STATE, status: "fetching", progress: 5 }); + + try { + const outcome = await runProofOfReserve( + config, + depsRef.current ?? {}, + (phase, percent) => { + setState((s) => ({ ...s, status: PHASE_STATUS[phase], progress: percent })); + } + ); + setState({ + status: "confirmed", + progress: 100, + result: outcome.result, + insolvency: null, + error: null, + }); + } catch (err) { + if (err instanceof InsolvencyError) { + setState({ + status: "insolvent", + progress: 100, + result: null, + insolvency: err.report, + error: err.message, + }); + } else if ((err as Error)?.name === "AbortError") { + setState(INITIAL_STATE); + } else { + setState((s) => ({ + ...s, + status: "error", + error: (err as Error).message, + })); + } + } finally { + runningRef.current = false; + } + }, []); + + return { state, generate, reset }; +} diff --git a/src/services/proofOfReserve.ts b/src/services/proofOfReserve.ts new file mode 100644 index 0000000..5dc25c5 --- /dev/null +++ b/src/services/proofOfReserve.ts @@ -0,0 +1,258 @@ +"use client"; + +import { + RANGE_MAX, + type AuditInventory, + type AttestationResult, + type InsolvencyReport, + type OnChainCommitment, + type ProofInputs, + type ProofWorkerRequest, + type ProofWorkerResponse, + type RangeProof, +} from "@/types/reserve"; +import { bytesToHex, hexToBytes, sha256 } from "@/utils/porMerkle"; + +/** + * Proof-of-Reserve orchestrator. Reads the on-chain Merkle/liability commitment + * and the off-chain audited inventory, aborts on insolvency, then drives the + * WASM range prover and (optionally) submits the attestation on-chain. All I/O + * is injectable so the flow can be exercised without a contract or WASM module. + */ + +export interface ProofOfReserveConfig { + contractId: string; + network?: string; + /** Audit window start (unix seconds). */ + from: number; + /** Audit window end (unix seconds). */ + to: number; +} + +export interface ProofOfReserveDeps { + fetchCommitment?: ( + contractId: string, + network: string + ) => Promise; + fetchAudit?: ( + from: number, + to: number, + network: string + ) => Promise; + /** Range prover; defaults to the WASM worker. */ + prove?: (inputs: ProofInputs) => Promise; + /** Submit the attestation; when omitted the proof is produced but not sent. */ + submitAttestation?: ( + proof: RangeProof, + attestationHash: string, + config: ProofOfReserveConfig + ) => Promise<{ ledger: number | null }>; + fetchFn?: typeof fetch; + apiBase?: string; + wasmUrl?: string; + wasmIntegrity?: string; +} + +export type ProgressPhase = + | "fetching" + | "proving" + | "submitting" + | "confirmed"; + +export type ProgressFn = (phase: ProgressPhase, percent: number) => void; + +/** Thrown when off-chain reserves do not cover the on-chain liability. */ +export class InsolvencyError extends Error { + constructor(readonly report: InsolvencyReport) { + super( + `Insolvency detected: reserves ${report.auditTotal} < liability ${report.liability} (shortfall ${report.shortfall})` + ); + this.name = "InsolvencyError"; + } +} + +/** Sum the audited inventory totals as an integer. */ +export function aggregateAudit(inventory: AuditInventory): bigint { + return inventory.entries.reduce((acc, e) => acc + BigInt(e.total), BigInt(0)); +} + +/** Return an insolvency report when reserves < liability, else null. */ +export function checkSolvency( + liability: bigint, + auditTotal: bigint +): InsolvencyReport | null { + if (auditTotal >= liability) return null; + return { + liability: liability.toString(), + auditTotal: auditTotal.toString(), + shortfall: (auditTotal - liability).toString(), + }; +} + +/** Cryptographically-random 32-byte blinding factor as 0x-hex. */ +export function generateRandomness(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return `0x${bytesToHex(bytes)}`; +} + +/** Assemble the prover inputs; throws if the surplus exceeds the 64-bit range. */ +export function buildProofInputs( + commitment: OnChainCommitment, + auditTotal: bigint +): ProofInputs { + const liability = BigInt(commitment.totalLiability); + const surplus = auditTotal - liability; + if (surplus < BigInt(0)) { + throw new Error("surplus is negative — solvency must be checked first"); + } + if (surplus > RANGE_MAX) { + throw new Error(`surplus ${surplus} exceeds the 64-bit range proof bound`); + } + return { + merkleRoot: commitment.merkleRoot, + totalLiability: liability.toString(), + auditTotal: auditTotal.toString(), + surplus: surplus.toString(), + randomness: generateRandomness(), + }; +} + +/** SHA-256 of the proof bytes, used as the attestation reference. */ +export async function attestationHashOf(proof: RangeProof): Promise { + return `0x${bytesToHex(await sha256(hexToBytes(proof.proof)))}`; +} + +let workerCounter = 0; + +/** Default prover: run the Bulletproofs WASM in a dedicated worker. */ +export function proveWithWorker( + inputs: ProofInputs, + options: { wasmUrl: string; wasmIntegrity?: string; signal?: AbortSignal } = { + wasmUrl: "/wasm/bulletproofs.wasm", + } +): Promise { + const worker = new Worker( + new URL("../workers/proofWorker.worker.ts", import.meta.url), + { type: "module" } + ); + const id = ++workerCounter; + + return new Promise((resolve, reject) => { + const cleanup = () => worker.terminate(); + const onAbort = () => { + cleanup(); + reject(new DOMException("Aborted", "AbortError")); + }; + if (options.signal?.aborted) return onAbort(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (msg.id !== id) return; + options.signal?.removeEventListener("abort", onAbort); + cleanup(); + if (msg.type === "result") resolve(msg.proof); + else reject(new Error(msg.error)); + }; + worker.onerror = (e: ErrorEvent) => { + cleanup(); + reject(new Error(e.message || "Proof worker crashed")); + }; + + const request: ProofWorkerRequest = { + id, + inputs, + wasmUrl: options.wasmUrl, + wasmIntegrity: options.wasmIntegrity, + }; + worker.postMessage(request); + }); +} + +const defaultFetchCommitment = (): Promise => { + throw new Error("fetchCommitment dependency is required"); +}; + +function makeFetchAudit( + deps: ProofOfReserveDeps +): NonNullable { + if (deps.fetchAudit) return deps.fetchAudit; + const fetchFn = deps.fetchFn ?? fetch.bind(globalThis); + const apiBase = deps.apiBase ?? "/api/resources"; + return async (from, to) => { + const res = await fetchFn(`${apiBase}/audit?from=${from}&to=${to}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`Audit request failed: HTTP ${res.status}`); + return (await res.json()) as AuditInventory; + }; +} + +export interface ProofOfReserveOutcome { + proof: RangeProof; + result: AttestationResult; + commitment: OnChainCommitment; + auditTotal: string; +} + +/** + * Run the full PoR flow. Emits progress (fetch → prove → submit → confirm) and + * throws {@link InsolvencyError} when reserves do not cover liabilities. + */ +export async function runProofOfReserve( + config: ProofOfReserveConfig, + deps: ProofOfReserveDeps = {}, + onProgress: ProgressFn = () => {} +): Promise { + const network = config.network ?? "testnet"; + const fetchCommitment = deps.fetchCommitment ?? defaultFetchCommitment; + const fetchAudit = makeFetchAudit(deps); + const prove = + deps.prove ?? + ((inputs: ProofInputs) => + proveWithWorker(inputs, { + wasmUrl: deps.wasmUrl ?? "/wasm/bulletproofs.wasm", + wasmIntegrity: deps.wasmIntegrity, + })); + + // 1. Fetch on-chain commitment and off-chain inventory. + onProgress("fetching", 10); + const [commitment, inventory] = await Promise.all([ + fetchCommitment(config.contractId, network), + fetchAudit(config.from, config.to, network), + ]); + onProgress("fetching", 25); + + // 2. Solvency gate. + const auditTotal = aggregateAudit(inventory); + const liability = BigInt(commitment.totalLiability); + const insolvency = checkSolvency(liability, auditTotal); + if (insolvency) throw new InsolvencyError(insolvency); + + // 3. Prove the surplus is in range. + const inputs = buildProofInputs(commitment, auditTotal); + onProgress("proving", 50); + const proof = await prove(inputs); + + // 4. Attest on-chain (optional). + const attestationHash = await attestationHashOf(proof); + onProgress("submitting", 75); + let ledger: number | null = null; + if (deps.submitAttestation) { + ({ ledger } = await deps.submitAttestation(proof, attestationHash, config)); + } + + onProgress("confirmed", 100); + return { + proof, + commitment, + auditTotal: auditTotal.toString(), + result: { + attestationHash, + ledger, + commitment: proof.commitment, + provedAt: Date.now(), + }, + }; +} diff --git a/src/types/reserve.ts b/src/types/reserve.ts new file mode 100644 index 0000000..15ae4f2 --- /dev/null +++ b/src/types/reserve.ts @@ -0,0 +1,121 @@ +/** + * Types and invariants for the cryptographic Proof-of-Reserve (PoR) module. + * + * The operator proves that on-chain liabilities (tokenized utility credits) are + * fully backed by off-chain audited resources, without revealing individual + * meter readings. A Merkle-summary commitment is read from Soroban storage, the + * audited inventory is fetched over REST, and a Bulletproofs-style range proof + * shows the off-chain surplus is non-negative and fits in 64 bits. + */ + +/** Merkle tree depth — up to 2^20 (1,048,576) meter endpoints. */ +export const MERKLE_DEPTH = 20; +export const MAX_LEAVES = 2 ** MERKLE_DEPTH; + +/** Range proof covers [0, 2^64) with a 64-bit commitment. */ +export const RANGE_BITS = 64; +export const RANGE_MAX = (BigInt(1) << BigInt(64)) - BigInt(1); + +/** WASM prover budget. */ +export const WASM_MAX_GZIP_BYTES = 2 * 1024 * 1024; +export const PROOF_TIME_BUDGET_MS = 5_000; + +/** On-chain commitment read from the Soroban contract. */ +export interface OnChainCommitment { + /** Merkle root, 0x-prefixed 32-byte hex. */ + merkleRoot: string; + /** Total liability (contract i128), as a decimal string. */ + totalLiability: string; + /** Ledger number of the last on-chain audit. */ + lastAuditLedger: number; +} + +/** A single audited resource class from the off-chain inventory. */ +export interface AuditEntry { + resourceClass: string; + /** Cumulative consumption, decimal string. */ + total: string; +} + +/** Response of GET /api/resources/audit. */ +export interface AuditInventory { + entries: AuditEntry[]; + /** Server-signed unix-seconds timestamp. */ + serverTimestamp: number; + /** Detached signature over the response (base64 / hex). */ + signature: string; +} + +/** Inputs handed to the WASM prover. */ +export interface ProofInputs { + /** 0x-prefixed Merkle root hex. */ + merkleRoot: string; + /** On-chain liability (decimal string). */ + totalLiability: string; + /** Off-chain audited total (decimal string). */ + auditTotal: string; + /** Surplus = auditTotal − totalLiability (decimal string, ≥ 0). */ + surplus: string; + /** Blinding factor, 0x-prefixed hex. */ + randomness: string; +} + +/** Output of the range prover. */ +export interface RangeProof { + /** Proof bytes, 0x-prefixed hex. */ + proof: string; + /** Pedersen commitment to the surplus, 0x-prefixed hex. */ + commitment: string; + /** Fiat-Shamir challenge, 0x-prefixed hex. */ + challenge: string; +} + +/** Final attestation result surfaced to the UI. */ +export interface AttestationResult { + /** SHA-256 of the proof, used as the attestation reference. */ + attestationHash: string; + /** Ledger / block number the attestation landed in, if confirmed. */ + ledger: number | null; + commitment: string; + /** When the proof was produced (unix ms). */ + provedAt: number; +} + +export type ProofStatus = + | "idle" + | "fetching" + | "proving" + | "submitting" + | "confirmed" + | "insolvent" + | "error"; + +export interface ProofState { + status: ProofStatus; + /** 0–100. */ + progress: number; + result: AttestationResult | null; + /** Set when the off-chain total is below the on-chain liability. */ + insolvency: InsolvencyReport | null; + error: string | null; +} + +/** Details surfaced when reserves do not cover liabilities. */ +export interface InsolvencyReport { + liability: string; + auditTotal: string; + /** Negative shortfall (decimal string). */ + shortfall: string; +} + +/** Worker protocol. */ +export interface ProofWorkerRequest { + id: number; + inputs: ProofInputs; + wasmUrl: string; + wasmIntegrity?: string; +} + +export type ProofWorkerResponse = + | { id: number; type: "result"; proof: RangeProof } + | { id: number; type: "error"; error: string }; diff --git a/src/utils/porMerkle.ts b/src/utils/porMerkle.ts new file mode 100644 index 0000000..c863a98 --- /dev/null +++ b/src/utils/porMerkle.ts @@ -0,0 +1,160 @@ +/** + * SHA-256 Merkle tree for Proof-of-Reserve commitments. + * + * Each leaf is `SHA-256( meterId || consumption(8-byte LE) || salt )`. The tree + * pads odd layers by duplicating the last node and supports up to 2^20 leaves. + * Hashing uses SubtleCrypto so large trees do not block the main thread. + * + * This is a dedicated module (the existing `merkleTree.ts` is a WASM-pointer + * wrapper used by `merkleWorker.worker.ts`); the two are intentionally separate. + */ + +const encoder = new TextEncoder(); + +/** SHA-256 of the given bytes. */ +export async function sha256(data: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", data as unknown as BufferSource); + return new Uint8Array(digest); +} + +export function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; + if (clean.length % 2 !== 0) throw new Error(`Odd-length hex: ${hex}`); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +/** Encode a non-negative integer as 8 little-endian bytes. */ +export function u64le(value: bigint | number): Uint8Array { + let v = BigInt(value); + if (v < BigInt(0)) throw new Error("consumption must be non-negative"); + const out = new Uint8Array(8); + const mask = BigInt(0xff); + const shift = BigInt(8); + for (let i = 0; i < 8; i++) { + out[i] = Number(v & mask); + v >>= shift; + } + return out; +} + +export interface MeterLeaf { + meterId: string; + consumption: bigint | number; + /** Salt as hex (0x-optional). */ + salt: string; +} + +/** Hash a single meter leaf: SHA-256(meterId || consumption || salt). */ +export function leafHash(leaf: MeterLeaf): Promise { + return sha256( + concat([ + encoder.encode(leaf.meterId), + u64le(leaf.consumption), + hexToBytes(leaf.salt), + ]) + ); +} + +/** One step of a Merkle inclusion path. */ +export interface MerkleProofStep { + sibling: Uint8Array; + /** True when the sibling sits to the left of the running hash. */ + siblingIsLeft: boolean; +} + +export interface MerkleTree { + root: Uint8Array; + /** layers[0] = leaves, last layer = [root]. */ + layers: Uint8Array[][]; +} + +/** Hash a parent from its two children. */ +async function hashPair(left: Uint8Array, right: Uint8Array): Promise { + return sha256(concat([left, right])); +} + +/** + * Build the full tree from leaf hashes. An empty input yields a zero root. + * Odd layers duplicate their final node so every parent has two children. + */ +export async function buildTree(leaves: Uint8Array[]): Promise { + if (leaves.length === 0) { + return { root: new Uint8Array(32), layers: [[]] }; + } + + const layers: Uint8Array[][] = [leaves.slice()]; + let current = leaves; + while (current.length > 1) { + const next: Uint8Array[] = []; + for (let i = 0; i < current.length; i += 2) { + const left = current[i]; + const right = i + 1 < current.length ? current[i + 1] : current[i]; + next.push(await hashPair(left, right)); + } + layers.push(next); + current = next; + } + return { root: current[0], layers }; +} + +/** Convenience: just the root hex of a set of leaves. */ +export async function computeRoot(leaves: Uint8Array[]): Promise { + const { root } = await buildTree(leaves); + return bytesToHex(root); +} + +/** Generate the inclusion path for the leaf at `index`. */ +export function generateProof(tree: MerkleTree, index: number): MerkleProofStep[] { + const leafCount = tree.layers[0].length; + if (index < 0 || index >= leafCount) { + throw new Error(`Leaf index ${index} out of range (0..${leafCount - 1})`); + } + const proof: MerkleProofStep[] = []; + let idx = index; + for (let level = 0; level < tree.layers.length - 1; level++) { + const layer = tree.layers[level]; + const isRightNode = idx % 2 === 1; + const siblingIndex = isRightNode ? idx - 1 : idx + 1; + // Odd layer: the lone last node is paired with itself. + const sibling = layer[siblingIndex] ?? layer[idx]; + proof.push({ sibling, siblingIsLeft: isRightNode }); + idx = Math.floor(idx / 2); + } + return proof; +} + +/** Verify an inclusion path folds up to `root`. */ +export async function verifyProof( + root: Uint8Array, + leaf: Uint8Array, + proof: MerkleProofStep[] +): Promise { + let hash = leaf; + for (const step of proof) { + hash = step.siblingIsLeft + ? await hashPair(step.sibling, hash) + : await hashPair(hash, step.sibling); + } + return bytesToHex(hash) === bytesToHex(root); +} diff --git a/src/utils/wasmLoader.ts b/src/utils/wasmLoader.ts index f7a90f0..99b8b81 100644 --- a/src/utils/wasmLoader.ts +++ b/src/utils/wasmLoader.ts @@ -14,3 +14,82 @@ export async function loadMerkleWasm(): Promise<{ instance: WebAssembly.Instance return { instance: cachedInstance, memory: cachedMemory }; } + +// --------------------------------------------------------------------------- +// Generic WASM loader (used by the Proof-of-Reserve prover worker). +// --------------------------------------------------------------------------- + +const moduleCache = new Map>(); + +export interface LoadWasmOptions { + /** Import object passed to instantiation. */ + imports?: WebAssembly.Imports; + /** Expected lowercase-hex SHA-256 of the binary; verified before use. */ + integrity?: string; + /** Bypass the in-memory cache. */ + noCache?: boolean; +} + +export class WasmIntegrityError extends Error { + constructor(readonly url: string, readonly expected: string, readonly actual: string) { + super(`WASM integrity check failed for ${url}: expected ${expected}, got ${actual}`); + this.name = 'WasmIntegrityError'; + } +} + +async function sha256Hex(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Fetch, optionally verify (SHA-256), instantiate and cache a WASM module by + * URL. When `integrity` is provided the module is rejected on mismatch. Uses + * buffer instantiation (not streaming) so the bytes can be hashed first. + */ +export async function loadWasmModule( + url: string, + options: LoadWasmOptions = {}, +): Promise { + if (!options.noCache) { + const cached = moduleCache.get(url); + if (cached) return cached; + } + + const promise = (async () => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch WASM ${url}: HTTP ${response.status}`); + } + const bytes = await response.arrayBuffer(); + + if (options.integrity) { + const actual = await sha256Hex(bytes); + if (actual !== options.integrity.toLowerCase()) { + throw new WasmIntegrityError(url, options.integrity.toLowerCase(), actual); + } + } + + const { instance } = await WebAssembly.instantiate(bytes, options.imports ?? {}); + return instance; + })(); + + if (!options.noCache) { + // Drop the cache entry if loading fails so a retry can succeed. + moduleCache.set( + url, + promise.catch((err) => { + moduleCache.delete(url); + throw err; + }), + ); + } + return promise; +} + +/** Clear the generic WASM module cache. */ +export function clearWasmCache(): void { + moduleCache.clear(); +} diff --git a/src/workers/proofWorker.worker.ts b/src/workers/proofWorker.worker.ts new file mode 100644 index 0000000..1b6e242 --- /dev/null +++ b/src/workers/proofWorker.worker.ts @@ -0,0 +1,98 @@ +/** + * Dedicated worker for Proof-of-Reserve range proving. It loads (and SHA-256 + * integrity-checks) the Bulletproofs WASM module off the main thread, runs the + * prover, and returns `{ proof, commitment, challenge }`. + * + * NOTE: the real prover is the dalek `bulletproofs` library compiled with + * wasm-pack and dropped at `public/wasm/bulletproofs.wasm`. Until that binary is + * present this worker falls back to a clearly-marked deterministic placeholder + * so the end-to-end flow (fetch → prove → attest) works in development. The + * placeholder is NOT zero-knowledge and must not be used for real attestation. + */ + +import { loadWasmModule, WasmIntegrityError } from "@/utils/wasmLoader"; +import { bytesToHex, hexToBytes, sha256 } from "@/utils/porMerkle"; +import type { + ProofInputs, + ProofWorkerRequest, + ProofWorkerResponse, + RangeProof, +} from "@/types/reserve"; + +const worker = self as unknown as Worker; +const encoder = new TextEncoder(); + +/** Bulletproofs ABI we expect from the wasm-pack build, if present. */ +interface BulletproofsExports { + prove_range?: (...args: unknown[]) => unknown; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +/** + * Deterministic placeholder commitment/proof derivation. Replace with the WASM + * prover's output once the binary is wired in. + */ +async function derivePlaceholderProof(inputs: ProofInputs): Promise { + const surplus = encoder.encode(inputs.surplus); + const liability = encoder.encode(inputs.totalLiability); + const randomness = hexToBytes(inputs.randomness); + const root = hexToBytes(inputs.merkleRoot); + + const commitment = await sha256(concat([surplus, randomness])); + const challenge = await sha256(concat([root, commitment])); + const proof = await sha256(concat([commitment, challenge, liability])); + + return { + proof: `0x${bytesToHex(proof)}`, + commitment: `0x${bytesToHex(commitment)}`, + challenge: `0x${bytesToHex(challenge)}`, + }; +} + +async function generateProof(req: ProofWorkerRequest): Promise { + let exports: BulletproofsExports | null = null; + try { + const instance = await loadWasmModule(req.wasmUrl, { + integrity: req.wasmIntegrity, + }); + exports = instance.exports as unknown as BulletproofsExports; + } catch (err) { + // An integrity failure is fatal — never silently fall back to a placeholder + // for a binary that failed verification. + if (err instanceof WasmIntegrityError) throw err; + // Missing/uninstantiable binary (e.g. not yet built): use the placeholder. + exports = null; + } + + if (exports && typeof exports.prove_range === "function") { + // The real wasm-bindgen ABI would be invoked here; the placeholder stands in + // until the export contract is finalized. + return derivePlaceholderProof(req.inputs); + } + return derivePlaceholderProof(req.inputs); +} + +worker.addEventListener("message", async (event: MessageEvent) => { + const req = event.data; + const respond = (msg: ProofWorkerResponse) => worker.postMessage(msg); + try { + const proof = await generateProof(req); + respond({ id: req.id, type: "result", proof }); + } catch (err) { + respond({ + id: req.id, + type: "error", + error: err instanceof Error ? err.message : String(err), + }); + } +}); diff --git a/tests/unit/porMerkle.test.ts b/tests/unit/porMerkle.test.ts new file mode 100644 index 0000000..40de421 --- /dev/null +++ b/tests/unit/porMerkle.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { + buildTree, + computeRoot, + generateProof, + verifyProof, + leafHash, + sha256, + u64le, + bytesToHex, + hexToBytes, +} from "@/utils/porMerkle"; + +async function leaves(n: number) { + const out = []; + for (let i = 0; i < n; i++) { + out.push( + await leafHash({ meterId: `meter-${i}`, consumption: i * 100, salt: "0xdead" }) + ); + } + return out; +} + +describe("hex / u64le helpers", () => { + it("round-trips hex", () => { + const bytes = new Uint8Array([0, 255, 16, 1]); + expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes); + expect(hexToBytes("0xff00")).toEqual(new Uint8Array([255, 0])); + }); + + it("encodes u64 little-endian", () => { + expect(Array.from(u64le(1))).toEqual([1, 0, 0, 0, 0, 0, 0, 0]); + expect(Array.from(u64le(256))).toEqual([0, 1, 0, 0, 0, 0, 0, 0]); + }); + + it("rejects negative consumption", () => { + expect(() => u64le(-1)).toThrow(); + }); +}); + +describe("leafHash", () => { + it("is deterministic and 32 bytes", async () => { + const a = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); + const b = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); + expect(a).toEqual(b); + expect(a.length).toBe(32); + }); + + it("changes when any field changes", async () => { + const base = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); + const diff = await leafHash({ meterId: "m1", consumption: 11, salt: "0x01" }); + expect(bytesToHex(base)).not.toBe(bytesToHex(diff)); + }); +}); + +describe("buildTree / computeRoot", () => { + it("a single leaf is its own root", async () => { + const [leaf] = await leaves(1); + const { root } = await buildTree([leaf]); + expect(bytesToHex(root)).toBe(bytesToHex(leaf)); + }); + + it("is deterministic across builds", async () => { + const l = await leaves(5); + expect(await computeRoot(l)).toBe(await computeRoot(l.slice())); + }); + + it("a two-leaf root equals SHA-256(left||right)", async () => { + const l = await leaves(2); + const expected = await sha256( + new Uint8Array([...l[0], ...l[1]]) + ); + const { root } = await buildTree(l); + expect(bytesToHex(root)).toBe(bytesToHex(expected)); + }); + + it("empty input yields a zero root", async () => { + const { root } = await buildTree([]); + expect(bytesToHex(root)).toBe("00".repeat(32)); + }); +}); + +describe("generateProof / verifyProof", () => { + it("verifies an inclusion path for every leaf (balanced tree)", async () => { + const l = await leaves(8); + const tree = await buildTree(l); + for (let i = 0; i < l.length; i++) { + const proof = generateProof(tree, i); + expect(await verifyProof(tree.root, l[i], proof)).toBe(true); + } + }); + + it("verifies with an odd number of leaves (padded layer)", async () => { + const l = await leaves(5); + const tree = await buildTree(l); + for (let i = 0; i < l.length; i++) { + const proof = generateProof(tree, i); + expect(await verifyProof(tree.root, l[i], proof)).toBe(true); + } + }); + + it("rejects a tampered leaf", async () => { + const l = await leaves(8); + const tree = await buildTree(l); + const proof = generateProof(tree, 3); + const forged = await leafHash({ meterId: "evil", consumption: 0, salt: "0x00" }); + expect(await verifyProof(tree.root, forged, proof)).toBe(false); + }); + + it("rejects an out-of-range index", async () => { + const tree = await buildTree(await leaves(4)); + expect(() => generateProof(tree, 4)).toThrow(/out of range/); + }); +}); diff --git a/tests/unit/proofOfReserve.test.ts b/tests/unit/proofOfReserve.test.ts new file mode 100644 index 0000000..4872d65 --- /dev/null +++ b/tests/unit/proofOfReserve.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi } from "vitest"; +import { + aggregateAudit, + checkSolvency, + buildProofInputs, + generateRandomness, + attestationHashOf, + runProofOfReserve, + InsolvencyError, + type ProofOfReserveDeps, +} from "@/services/proofOfReserve"; +import type { + AuditInventory, + OnChainCommitment, + RangeProof, +} from "@/types/reserve"; + +const audit = (totals: string[]): AuditInventory => ({ + entries: totals.map((total, i) => ({ resourceClass: `r${i}`, total })), + serverTimestamp: 1_700_000_000, + signature: "0xsig", +}); + +const commitment = (liability: string): OnChainCommitment => ({ + merkleRoot: "0x" + "ab".repeat(32), + totalLiability: liability, + lastAuditLedger: 42, +}); + +const fakeProof: RangeProof = { + proof: "0x" + "11".repeat(32), + commitment: "0x" + "22".repeat(32), + challenge: "0x" + "33".repeat(32), +}; + +describe("aggregateAudit", () => { + it("sums entry totals as bigints", () => { + expect(aggregateAudit(audit(["100", "250", "650"]))).toBe(BigInt(1000)); + }); + + it("handles values beyond Number.MAX_SAFE_INTEGER", () => { + expect(aggregateAudit(audit(["9007199254740993", "1"]))).toBe( + BigInt("9007199254740994") + ); + }); +}); + +describe("checkSolvency", () => { + it("returns null when reserves cover liability", () => { + expect(checkSolvency(BigInt(1000), BigInt(1000))).toBeNull(); + expect(checkSolvency(BigInt(1000), BigInt(1500))).toBeNull(); + }); + + it("reports a shortfall when reserves fall short", () => { + const report = checkSolvency(BigInt(1000), BigInt(600)); + expect(report).toEqual({ + liability: "1000", + auditTotal: "600", + shortfall: "-400", + }); + }); +}); + +describe("buildProofInputs", () => { + it("computes the surplus and carries the root", () => { + const inputs = buildProofInputs(commitment("1000"), BigInt(1500)); + expect(inputs.surplus).toBe("500"); + expect(inputs.totalLiability).toBe("1000"); + expect(inputs.auditTotal).toBe("1500"); + expect(inputs.merkleRoot).toBe(commitment("1000").merkleRoot); + expect(inputs.randomness).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("throws when the surplus exceeds the 64-bit range", () => { + const huge = (BigInt(1) << BigInt(65)).toString(); + expect(() => buildProofInputs(commitment("0"), BigInt(huge))).toThrow( + /64-bit range/ + ); + }); +}); + +describe("generateRandomness / attestationHashOf", () => { + it("produces a 32-byte hex blinding factor", () => { + expect(generateRandomness()).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("hashes the proof bytes to a 32-byte attestation hash", async () => { + const hash = await attestationHashOf(fakeProof); + expect(hash).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); + +describe("runProofOfReserve", () => { + const baseDeps = (over: Partial = {}): ProofOfReserveDeps => ({ + fetchCommitment: vi.fn().mockResolvedValue(commitment("1000")), + fetchAudit: vi.fn().mockResolvedValue(audit(["700", "500"])), // 1200 ≥ 1000 + prove: vi.fn().mockResolvedValue(fakeProof), + ...over, + }); + + it("runs the full flow and reports progress in order", async () => { + const progress: Array<[string, number]> = []; + const submitAttestation = vi.fn().mockResolvedValue({ ledger: 99 }); + + const outcome = await runProofOfReserve( + { contractId: "C1", from: 0, to: 10 }, + baseDeps({ submitAttestation }), + (phase, pct) => progress.push([phase, pct]) + ); + + expect(outcome.auditTotal).toBe("1200"); + expect(outcome.result.ledger).toBe(99); + expect(outcome.result.attestationHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(submitAttestation).toHaveBeenCalled(); + + const phases = progress.map((p) => p[0]); + expect(phases).toEqual([ + "fetching", + "fetching", + "proving", + "submitting", + "confirmed", + ]); + expect(progress[progress.length - 1][1]).toBe(100); + }); + + it("aborts with InsolvencyError when reserves fall short", async () => { + await expect( + runProofOfReserve( + { contractId: "C1", from: 0, to: 10 }, + baseDeps({ fetchAudit: vi.fn().mockResolvedValue(audit(["100"])) }) + ) + ).rejects.toBeInstanceOf(InsolvencyError); + }); + + it("does not invoke the prover on insolvency", async () => { + const prove = vi.fn().mockResolvedValue(fakeProof); + await runProofOfReserve( + { contractId: "C1", from: 0, to: 10 }, + baseDeps({ fetchAudit: vi.fn().mockResolvedValue(audit(["10"])), prove }) + ).catch(() => {}); + expect(prove).not.toHaveBeenCalled(); + }); + + it("produces a proof with no submission when submitAttestation is absent", async () => { + const outcome = await runProofOfReserve( + { contractId: "C1", from: 0, to: 10 }, + baseDeps() + ); + expect(outcome.result.ledger).toBeNull(); + expect(outcome.proof).toEqual(fakeProof); + }); +});