From 90779cb8790ce3ac84ec2c4d51723050a32df721 Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 04:36:21 -0700 Subject: [PATCH] feat: add Groth16 zk-proof verifier for anonymous meter readings (#64) Implements a zero-knowledge proof module that lets field operators submit an encrypted meter reading with a Groth16/BLS12-381 proof that the reading is in range and from a registered meter, without revealing the value or meter ID. - types/zk.ts: shared circuit input/proof/state types and bounds - services/keyCache.ts: IndexedDB cache for the ~150MB proving key with resumable Range-request downloads, SHA-256 integrity and LRU eviction - workers/zkProver.worker.ts: off-main-thread snarkjs Groth16 prover - services/zkProver.ts: input validation/witness assembly and worker driver - services/zkVerifier.ts: fetches the on-chain verification key, caches 24h, runs groth16Verify - hooks/useZKSubmission.ts: prove -> submit -> verify pipeline with progress - components/panels/ZKSubmissionPanel.tsx: stepper UI with progress bar - public/wasm/zk: artifact placeholders + docs (real zkey is CDN-hosted) - tests for input validation and integrity hashing --- public/wasm/zk/README.md | 33 ++ public/wasm/zk/circuit.wasm.placeholder | 2 + public/wasm/zk/circuit_final.zkey.placeholder | 2 + src/components/panels/ZKSubmissionPanel.tsx | 235 ++++++++++++ src/hooks/useZKSubmission.ts | 190 +++++++++ src/services/keyCache.ts | 360 ++++++++++++++++++ src/services/zkProver.ts | 214 +++++++++++ src/services/zkVerifier.ts | 140 +++++++ src/types/zk.ts | 131 +++++++ src/workers/zkProver.worker.ts | 111 ++++++ tests/unit/keyCache.test.ts | 36 ++ tests/unit/zkProver.test.ts | 108 ++++++ 12 files changed, 1562 insertions(+) create mode 100644 public/wasm/zk/README.md create mode 100644 public/wasm/zk/circuit.wasm.placeholder create mode 100644 public/wasm/zk/circuit_final.zkey.placeholder create mode 100644 src/components/panels/ZKSubmissionPanel.tsx create mode 100644 src/hooks/useZKSubmission.ts create mode 100644 src/services/keyCache.ts create mode 100644 src/services/zkProver.ts create mode 100644 src/services/zkVerifier.ts create mode 100644 src/types/zk.ts create mode 100644 src/workers/zkProver.worker.ts create mode 100644 tests/unit/keyCache.test.ts create mode 100644 tests/unit/zkProver.test.ts diff --git a/public/wasm/zk/README.md b/public/wasm/zk/README.md new file mode 100644 index 0000000..4708ad2 --- /dev/null +++ b/public/wasm/zk/README.md @@ -0,0 +1,33 @@ +# ZK circuit artifacts + +This directory hosts the Groth16 (BLS12-381) circuit artifacts used by the +anonymous meter-reading proof module. + +| File | Purpose | Size | +| -------------------- | ---------------------------------------- | ------- | +| `circuit.wasm` | Compiled witness generator (circom) | ~5 MB | +| `circuit_final.zkey` | Groth16 proving key | ~150 MB | + +## Why these are not committed + +`circuit_final.zkey` is ~150 MB and **must not** be committed or re-downloaded +per proof. In production it is hosted on a CDN (e.g. S3 + CloudFront) and pulled +on demand into IndexedDB by [`src/services/keyCache.ts`](../../../src/services/keyCache.ts), +which: + +- resumes interrupted transfers with HTTP `Range` requests, +- verifies a SHA-256 integrity hash before use, and +- evicts least-recently-used keys to stay within a storage budget. + +## Configuration + +Point the prover at the hosted key via environment variables (see +[`src/services/zkProver.ts`](../../../src/services/zkProver.ts)): + +``` +NEXT_PUBLIC_ZK_ZKEY_URL=https://cdn.example.com/zk/circuit_final.zkey +NEXT_PUBLIC_ZK_ZKEY_SHA256= +``` + +The `.placeholder` files in this directory exist only so the path resolves in +local development; replace them (or override the URL) with the real artifacts. diff --git a/public/wasm/zk/circuit.wasm.placeholder b/public/wasm/zk/circuit.wasm.placeholder new file mode 100644 index 0000000..9879bc3 --- /dev/null +++ b/public/wasm/zk/circuit.wasm.placeholder @@ -0,0 +1,2 @@ +PLACEHOLDER — the real compiled circuit witness generator (circuit.wasm) is +produced by `circom` and served from this path. See README.md in this directory. diff --git a/public/wasm/zk/circuit_final.zkey.placeholder b/public/wasm/zk/circuit_final.zkey.placeholder new file mode 100644 index 0000000..5b59f28 --- /dev/null +++ b/public/wasm/zk/circuit_final.zkey.placeholder @@ -0,0 +1,2 @@ +PLACEHOLDER — the real Groth16 proving key (~150 MB) is CDN-hosted and fetched +into IndexedDB at runtime. See README.md in this directory. diff --git a/src/components/panels/ZKSubmissionPanel.tsx b/src/components/panels/ZKSubmissionPanel.tsx new file mode 100644 index 0000000..b241a37 --- /dev/null +++ b/src/components/panels/ZKSubmissionPanel.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { MeterReading, ZKSubmissionStatus } from "@/types/zk"; +import { CONSUMPTION_MAX_KWH, CONSUMPTION_MIN_KWH } from "@/types/zk"; +import { + useZKSubmission, + type UseZKSubmissionOptions, +} from "@/hooks/useZKSubmission"; + +/** + * UI panel for submitting an encrypted meter reading together with a Groth16 + * zero-knowledge proof. The reading and meter identity never leave the device; + * only the ciphertext, proof and public signals are sent on-chain. A stepper + * surfaces each phase: key download → proving → submitting → verification. + */ + +export interface ZKSubmissionPanelProps + extends Omit { + buildContext: UseZKSubmissionOptions["buildContext"]; + className?: string; +} + +interface Step { + key: Exclude; + label: string; +} + +const STEPS: Step[] = [ + { key: "downloading-key", label: "Cache proving key" }, + { key: "proving", label: "Generate proof" }, + { key: "submitting", label: "Submit encrypted" }, + { key: "confirmed", label: "Verify on-chain" }, +]; + +/** Index of the step a given status belongs to (rejected maps to the last). */ +function statusToStepIndex(status: ZKSubmissionStatus): number { + switch (status) { + case "downloading-key": + return 0; + case "proving": + return 1; + case "submitting": + return 2; + case "confirmed": + case "rejected": + return 3; + default: + return -1; + } +} + +export function ZKSubmissionPanel({ + className, + buildContext, + ...options +}: ZKSubmissionPanelProps) { + const { state, submit, cancel, reset } = useZKSubmission({ + ...options, + buildContext, + }); + + const [meterId, setMeterId] = useState(""); + const [consumption, setConsumption] = useState(""); + + const activeIndex = statusToStepIndex(state.status); + const busy = + state.status === "downloading-key" || + state.status === "proving" || + state.status === "submitting"; + + const consumptionError = useMemo(() => { + if (consumption === "") return null; + const n = Number(consumption); + if (!Number.isInteger(n)) return "Enter a whole number of kWh."; + if (n < CONSUMPTION_MIN_KWH || n > CONSUMPTION_MAX_KWH) { + return `Must be between ${CONSUMPTION_MIN_KWH} and ${CONSUMPTION_MAX_KWH} kWh.`; + } + return null; + }, [consumption]); + + const canSubmit = + !busy && meterId.trim() !== "" && consumption !== "" && !consumptionError; + + const handleSubmit = () => { + const reading: MeterReading = { + meterId: meterId.trim(), + consumption: Number(consumption), + }; + void submit(reading); + }; + + return ( +
+
+

Anonymous Meter Submission

+

+ Your reading is encrypted and proven within range without revealing + the value or meter identity. +

+
+ + {/* Input form */} +
+ + +
+ + {/* Stepper */} +
    + {STEPS.map((step, i) => { + const done = activeIndex > i || state.status === "confirmed"; + const active = activeIndex === i && busy; + const failed = state.status === "rejected" && i === STEPS.length - 1; + return ( +
  1. + + + {step.label} + +
  2. + ); + })} +
+ + {/* Progress bar */} +
+
+
+ + {/* Status line */} +
+ + {state.status === "idle" + ? "Ready" + : state.status === "confirmed" + ? "Proof verified and submitted." + : state.status === "rejected" + ? state.error ?? "Submission rejected." + : `${STEPS[activeIndex]?.label ?? "Working"}… ${state.progress}%`} + + {state.proofHash && ( + + {state.proofHash.slice(0, 10)}… + + )} +
+ + {/* Actions */} +
+ {busy ? ( + + ) : ( + + )} + {(state.status === "confirmed" || state.status === "rejected") && ( + + )} +
+
+ ); +} + +export default ZKSubmissionPanel; diff --git a/src/hooks/useZKSubmission.ts b/src/hooks/useZKSubmission.ts new file mode 100644 index 0000000..cd5d0a4 --- /dev/null +++ b/src/hooks/useZKSubmission.ts @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { + MeterReading, + ZKProofResult, + ZKSubmissionState, +} from "@/types/zk"; +import { + generateProof, + prepareInputs, + type ProofContext, + type ZKProverConfig, +} from "@/services/zkProver"; +import { verifyProof } from "@/services/zkVerifier"; +import { classifyError } from "@/utils/errors"; + +/** + * Encrypted submission payload handed to the chain. The ciphertext and proof + * are public; the plaintext reading and meter identity never leave the device. + */ +export interface EncryptedSubmission { + encryptedReading: string; + proof: ZKProofResult["proof"]; + publicSignals: string[]; + proofHash: string; +} + +export interface UseZKSubmissionOptions { + /** Soroban contract holding the verification key and accepting submissions. */ + contractId: string; + network?: string; + proverConfig?: ZKProverConfig; + /** + * Resolve the non-reading context for a submission: the membership root, a + * recent block hash (replay protection) and the x25519 ciphertext. + */ + buildContext: (reading: MeterReading) => Promise | ProofContext; + /** + * Submit the encrypted payload on-chain. Should resolve once accepted into + * the ledger. Defaults to a no-op so the hook can be used proof-only. + */ + submitEncrypted?: (payload: EncryptedSubmission) => Promise; +} + +const INITIAL_STATE: ZKSubmissionState = { + status: "idle", + progress: 0, + proofHash: null, + error: null, +}; + +// Phase weighting across the overall 0–100 bar. +const KEY_PHASE_MAX = 55; // downloading-key occupies 0–55%. +const PROVE_PHASE_MAX = 90; // proving occupies 55–90%. + +export interface UseZKSubmissionReturn { + state: ZKSubmissionState; + /** Run the full pipeline: prove → submit → verify. */ + submit: (reading: MeterReading) => Promise; + /** Verify an already-generated proof against the on-chain key. */ + verify: ( + proof: ZKProofResult["proof"], + publicSignals: string[] + ) => Promise; + /** Abort an in-flight submission (key download and proving). */ + cancel: () => void; + reset: () => void; +} + +export function useZKSubmission( + options: UseZKSubmissionOptions +): UseZKSubmissionReturn { + const [state, setState] = useState(INITIAL_STATE); + const abortRef = useRef(null); + const runningRef = useRef(false); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + runningRef.current = false; + }, []); + + const reset = useCallback(() => { + cancel(); + setState(INITIAL_STATE); + }, [cancel]); + + const submit = useCallback( + async (reading: MeterReading) => { + if (runningRef.current) return; // Guard against double submission. + runningRef.current = true; + + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + setState({ + status: "downloading-key", + progress: 0, + proofHash: null, + error: null, + }); + + try { + const context = await options.buildContext(reading); + const inputs = prepareInputs(reading, context); + + const result = await generateProof(inputs, { + config: options.proverConfig, + signal, + onKeyProgress: (p) => { + const pct = p.percent ?? 0; + setState((s) => ({ + ...s, + status: "downloading-key", + progress: Math.round((pct / 100) * KEY_PHASE_MAX), + })); + }, + onProveProgress: (pct) => { + setState((s) => ({ + ...s, + status: "proving", + progress: + KEY_PHASE_MAX + + Math.round((pct / 100) * (PROVE_PHASE_MAX - KEY_PHASE_MAX)), + })); + }, + }); + + if (signal.aborted) return; + + // Submit the encrypted payload on-chain. + setState((s) => ({ + ...s, + status: "submitting", + progress: PROVE_PHASE_MAX, + proofHash: result.proofHash, + })); + + await options.submitEncrypted?.({ + encryptedReading: context.encryptedCiphertext, + proof: result.proof, + publicSignals: result.publicSignals, + proofHash: result.proofHash, + }); + + if (signal.aborted) return; + + // Independently verify against the on-chain verification key. + const ok = await verifyProof(result.proof, result.publicSignals, { + contractId: options.contractId, + network: options.network, + }); + + setState({ + status: ok ? "confirmed" : "rejected", + progress: 100, + proofHash: result.proofHash, + error: ok ? null : "Proof failed on-chain verification.", + }); + } catch (err) { + if ((err as Error)?.name === "AbortError" || signal.aborted) { + setState(INITIAL_STATE); + return; + } + setState((s) => ({ + ...s, + status: "rejected", + error: classifyError(err).message, + })); + } finally { + runningRef.current = false; + abortRef.current = null; + } + }, + [options] + ); + + const verify = useCallback( + (proof: ZKProofResult["proof"], publicSignals: string[]) => + verifyProof(proof, publicSignals, { + contractId: options.contractId, + network: options.network, + }), + [options.contractId, options.network] + ); + + return { state, submit, verify, cancel, reset }; +} diff --git a/src/services/keyCache.ts b/src/services/keyCache.ts new file mode 100644 index 0000000..789ad27 --- /dev/null +++ b/src/services/keyCache.ts @@ -0,0 +1,360 @@ +"use client"; + +import { openDB, type IDBPDatabase } from "idb"; + +/** + * IndexedDB-backed cache for the Groth16 proving key (`circuit_final.zkey`, + * ~150 MB). The key is far too large to re-download per proof, so it is + * persisted with a SHA-256 integrity check and LRU eviction. Downloads are + * resumable via HTTP Range requests so a dropped connection on a field tablet + * does not force a full restart. + */ + +const DB_NAME = "utility-zk-keys"; +const DB_VERSION = 1; + +const KEY_STORE = "proving-keys"; +const CHUNK_STORE = "partial-downloads"; + +/** Total bytes the cache may hold before LRU eviction kicks in (~600 MB). */ +const MAX_CACHE_BYTES = 600 * 1024 * 1024; + +/** Range request window. 8 MB balances request overhead vs. resume latency. */ +const RANGE_CHUNK_BYTES = 8 * 1024 * 1024; + +export interface CachedKey { + /** Canonical source URL — primary key. */ + url: string; + /** The verified key bytes. */ + bytes: ArrayBuffer; + /** Lowercase hex SHA-256 of `bytes`. */ + sha256: string; + size: number; + downloadedAt: number; + lastAccess: number; +} + +interface PartialDownload { + url: string; + /** Concatenated chunks received so far. */ + bytes: ArrayBuffer; + /** Bytes received; equals `bytes.byteLength`, kept for clarity. */ + received: number; + /** Total size advertised by the server, if known. */ + total: number | null; + updatedAt: number; +} + +export interface DownloadProgress { + received: number; + total: number | null; + /** 0–100, or null when total length is unknown. */ + percent: number | null; +} + +export class IntegrityError extends Error { + constructor( + readonly url: string, + readonly expected: string, + readonly actual: string + ) { + super( + `Integrity check failed for ${url}: expected ${expected}, got ${actual}` + ); + this.name = "IntegrityError"; + } +} + +let dbInstance: IDBPDatabase | null = null; + +async function getDb(): Promise { + if (dbInstance) return dbInstance; + dbInstance = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(KEY_STORE)) { + const store = db.createObjectStore(KEY_STORE, { keyPath: "url" }); + store.createIndex("lastAccess", "lastAccess"); + } + if (!db.objectStoreNames.contains(CHUNK_STORE)) { + db.createObjectStore(CHUNK_STORE, { keyPath: "url" }); + } + }, + }); + return dbInstance; +} + +/** Lowercase hex SHA-256 of the given bytes using SubtleCrypto. */ +export 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(""); +} + +function concat(a: ArrayBuffer, b: ArrayBuffer): ArrayBuffer { + const out = new Uint8Array(a.byteLength + b.byteLength); + out.set(new Uint8Array(a), 0); + out.set(new Uint8Array(b), a.byteLength); + return out.buffer; +} + +/** + * Return a cached, integrity-verified key if present and valid. A mismatched + * checksum evicts the entry and resolves to `null` so the caller re-downloads. + */ +export async function getCachedKey( + url: string, + expectedSha256?: string +): Promise { + try { + const db = await getDb(); + const entry = (await db.get(KEY_STORE, url)) as CachedKey | undefined; + if (!entry) return null; + + if (expectedSha256 && entry.sha256 !== expectedSha256.toLowerCase()) { + await db.delete(KEY_STORE, url); + return null; + } + + // Touch for LRU. + entry.lastAccess = Date.now(); + await db.put(KEY_STORE, entry); + return entry; + } catch { + return null; + } +} + +/** Evict least-recently-used keys until the cache fits within the budget. */ +async function evictToFit(db: IDBPDatabase, incomingBytes: number): Promise { + const all = (await db.getAll(KEY_STORE)) as CachedKey[]; + let used = all.reduce((sum, e) => sum + e.size, 0); + if (used + incomingBytes <= MAX_CACHE_BYTES) return; + + // Oldest access first. + all.sort((a, b) => a.lastAccess - b.lastAccess); + for (const entry of all) { + if (used + incomingBytes <= MAX_CACHE_BYTES) break; + await db.delete(KEY_STORE, entry.url); + used -= entry.size; + } +} + +async function persistKey(entry: CachedKey): Promise { + const db = await getDb(); + await evictToFit(db, entry.size); + await db.put(KEY_STORE, entry); + // Clear any resumable state now that the full key is stored. + await db.delete(CHUNK_STORE, entry.url).catch(() => {}); +} + +async function loadPartial(url: string): Promise { + try { + const db = await getDb(); + return ((await db.get(CHUNK_STORE, url)) as PartialDownload) ?? null; + } catch { + return null; + } +} + +async function savePartial(partial: PartialDownload): Promise { + try { + const db = await getDb(); + await db.put(CHUNK_STORE, partial); + } catch { + // Resumable state is best-effort; a failure just means a full restart. + } +} + +/** + * Download `url` into the cache, resuming any partial transfer, verifying the + * SHA-256 and evicting LRU entries to stay within budget. Returns the verified + * bytes. `signal` aborts the transfer; partial progress is retained for resume. + */ +export async function downloadKey( + url: string, + options: { + expectedSha256?: string; + onProgress?: (p: DownloadProgress) => void; + signal?: AbortSignal; + } = {} +): Promise { + const { expectedSha256, onProgress, signal } = options; + + // Reuse any already-verified copy. + const cached = await getCachedKey(url, expectedSha256); + if (cached) { + onProgress?.({ received: cached.size, total: cached.size, percent: 100 }); + return cached.bytes; + } + + let partial = await loadPartial(url); + let buffer = partial?.bytes ?? new ArrayBuffer(0); + let received = buffer.byteLength; + let total = partial?.total ?? null; + + const emit = () => + onProgress?.({ + received, + total, + percent: total ? Math.min(100, Math.round((received / total) * 100)) : null, + }); + emit(); + + // Probe total size once so we can drive the range loop and progress bar. + if (total === null) { + const head = await fetch(url, { method: "HEAD", signal }); + const len = head.headers.get("content-length"); + total = len ? Number(len) : null; + if (head.headers.get("accept-ranges") !== "bytes") { + // Server cannot resume — fall back to a single streamed download. + return streamWhole(url, { expectedSha256, onProgress, signal }); + } + } + + while (total === null || received < total) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + + const end = + total !== null + ? Math.min(received + RANGE_CHUNK_BYTES, total) - 1 + : received + RANGE_CHUNK_BYTES - 1; + + const res = await fetch(url, { + headers: { Range: `bytes=${received}-${end}` }, + signal, + }); + if (res.status !== 206 && res.status !== 200) { + throw new Error(`Range request failed: HTTP ${res.status}`); + } + const chunk = await res.arrayBuffer(); + if (chunk.byteLength === 0) break; // No more data. + + buffer = concat(buffer, chunk); + received = buffer.byteLength; + + partial = { url, bytes: buffer, received, total, updatedAt: Date.now() }; + await savePartial(partial); + emit(); + + if (res.status === 200) break; // Server ignored Range and sent everything. + } + + // Verify integrity before committing. + const actual = await sha256Hex(buffer); + if (expectedSha256 && actual !== expectedSha256.toLowerCase()) { + // Discard the corrupt partial so the next attempt starts clean. + const db = await getDb(); + await db.delete(CHUNK_STORE, url).catch(() => {}); + throw new IntegrityError(url, expectedSha256.toLowerCase(), actual); + } + + const now = Date.now(); + await persistKey({ + url, + bytes: buffer, + sha256: actual, + size: buffer.byteLength, + downloadedAt: now, + lastAccess: now, + }); + return buffer; +} + +/** Single-shot download for servers that do not support Range requests. */ +async function streamWhole( + url: string, + options: { + expectedSha256?: string; + onProgress?: (p: DownloadProgress) => void; + signal?: AbortSignal; + } +): Promise { + const { expectedSha256, onProgress, signal } = options; + const res = await fetch(url, { signal }); + if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`); + + const total = Number(res.headers.get("content-length")) || null; + const reader = res.body?.getReader(); + if (!reader) { + const buf = await res.arrayBuffer(); + onProgress?.({ received: buf.byteLength, total, percent: 100 }); + return finalizeWhole(url, buf, expectedSha256); + } + + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.byteLength; + onProgress?.({ + received, + total, + percent: total ? Math.round((received / total) * 100) : null, + }); + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + return finalizeWhole(url, merged.buffer, expectedSha256); +} + +async function finalizeWhole( + url: string, + buffer: ArrayBuffer, + expectedSha256?: string +): Promise { + const actual = await sha256Hex(buffer); + if (expectedSha256 && actual !== expectedSha256.toLowerCase()) { + throw new IntegrityError(url, expectedSha256.toLowerCase(), actual); + } + const now = Date.now(); + await persistKey({ + url, + bytes: buffer, + sha256: actual, + size: buffer.byteLength, + downloadedAt: now, + lastAccess: now, + }); + return buffer; +} + +/** Remove a single cached key and any partial download state. */ +export async function evictKey(url: string): Promise { + try { + const db = await getDb(); + await db.delete(KEY_STORE, url); + await db.delete(CHUNK_STORE, url); + } catch { + // best-effort + } +} + +/** Sum of all cached key sizes in bytes. */ +export async function getCacheSize(): Promise { + try { + const db = await getDb(); + const all = (await db.getAll(KEY_STORE)) as CachedKey[]; + return all.reduce((sum, e) => sum + e.size, 0); + } catch { + return 0; + } +} + +/** Drop every cached key and partial download. */ +export async function clearKeyCache(): Promise { + try { + const db = await getDb(); + await db.clear(KEY_STORE); + await db.clear(CHUNK_STORE); + } catch { + // best-effort + } +} diff --git a/src/services/zkProver.ts b/src/services/zkProver.ts new file mode 100644 index 0000000..f4b4127 --- /dev/null +++ b/src/services/zkProver.ts @@ -0,0 +1,214 @@ +"use client"; + +import { + CONSUMPTION_MAX_KWH, + CONSUMPTION_MIN_KWH, + type Groth16Proof, + type MeterReading, + type ZKCircuitInputs, + type ZKProofResult, + type ZKWorkerRequest, + type ZKWorkerResponse, +} from "@/types/zk"; +import { downloadKey, sha256Hex, type DownloadProgress } from "@/services/keyCache"; + +/** + * Orchestrates anonymous meter-reading proofs: gathers and validates the + * circuit inputs, ensures the proving key is cached, then drives the prover + * web worker. The heavy WASM proving runs off the main thread; this service is + * the thin coordination layer the React hook talks to. + */ + +export interface ZKProverConfig { + /** Compiled circuit WASM (witness generator). */ + wasmUrl: string; + /** Groth16 proving key (`circuit_final.zkey`), CDN-hosted. */ + zkeyUrl: string; + /** Expected SHA-256 of the zkey for integrity verification. */ + zkeySha256?: string; +} + +export const DEFAULT_PROVER_CONFIG: ZKProverConfig = { + wasmUrl: "/wasm/zk/circuit.wasm", + zkeyUrl: + process.env.NEXT_PUBLIC_ZK_ZKEY_URL ?? "/wasm/zk/circuit_final.zkey", + zkeySha256: process.env.NEXT_PUBLIC_ZK_ZKEY_SHA256, +}; + +/** Context not derivable from the raw reading, supplied by the caller. */ +export interface ProofContext { + /** Membership tree root the meter belongs to (256-bit hex, 0x-prefixed). */ + merkleRoot: string; + /** Recent ledger hash for replay protection (within last 10 ledgers, hex). */ + blockHash: string; + /** x25519 ciphertext of the reading (512-bit hex). */ + encryptedCiphertext: string; + /** Optional explicit salt (248-bit hex); a random one is generated if absent. */ + salt?: string; +} + +export class ProverInputError extends Error { + constructor(message: string) { + super(message); + this.name = "ProverInputError"; + } +} + +/** Convert a hex string (optionally 0x-prefixed) to a decimal field string. */ +export function hexToFieldString(hex: string): string { + const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; + if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length === 0) { + throw new ProverInputError(`Invalid hex value: ${hex}`); + } + return BigInt(`0x${clean}`).toString(10); +} + +/** Cryptographically-random 248-bit salt as a 0x-prefixed hex string. */ +export function generateSalt(): string { + const bytes = new Uint8Array(31); // 248 bits + crypto.getRandomValues(bytes); + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `0x${hex}`; +} + +/** + * Validate a reading and assemble the full circuit witness. Field elements are + * normalised to decimal strings; the timestamp stays numeric (32-bit). Throws + * `ProverInputError` for out-of-range or malformed inputs before any expensive + * key download or proving begins. + */ +export function prepareInputs( + reading: MeterReading, + context: ProofContext +): ZKCircuitInputs { + if (!Number.isInteger(reading.consumption)) { + throw new ProverInputError("consumption must be an integer kWh value"); + } + if ( + reading.consumption < CONSUMPTION_MIN_KWH || + reading.consumption > CONSUMPTION_MAX_KWH + ) { + throw new ProverInputError( + `consumption ${reading.consumption} kWh is outside the accepted range [${CONSUMPTION_MIN_KWH}, ${CONSUMPTION_MAX_KWH}]` + ); + } + if (!reading.meterId) { + throw new ProverInputError("meterId is required"); + } + + const timestamp = reading.timestamp ?? Math.floor(Date.now() / 1000); + if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > 0xffffffff) { + throw new ProverInputError("timestamp must fit in 32 bits"); + } + + const salt = context.salt ?? generateSalt(); + + return { + // Private signals. + meterId: hexToFieldString(reading.meterId), + consumption: String(reading.consumption), + salt: hexToFieldString(salt), + // Public signals. + merkleRoot: hexToFieldString(context.merkleRoot), + encryptedCiphertext: context.encryptedCiphertext, + timestamp, + blockHash: hexToFieldString(context.blockHash), + }; +} + +let workerCounter = 0; + +export interface GenerateProofOptions { + config?: ZKProverConfig; + /** 0–100 progress while the ~150 MB proving key downloads. */ + onKeyProgress?: (p: DownloadProgress) => void; + /** 0–100 progress while the worker proves. */ + onProveProgress?: (percent: number) => void; + signal?: AbortSignal; +} + +/** Stable hash over the canonicalised proof, used as a client-side reference. */ +async function hashProof( + proof: Groth16Proof, + publicSignals: string[] +): Promise { + const canonical = JSON.stringify({ proof, publicSignals }); + const bytes = new TextEncoder().encode(canonical); + return sha256Hex(bytes.buffer); +} + +/** + * Ensure the proving key is cached, then run the Groth16 prover in a worker. + * Resolves with the proof, public signals and a proof hash. The worker is + * always terminated, and `signal` aborts both the key download and proving. + */ +export async function generateProof( + inputs: ZKCircuitInputs, + options: GenerateProofOptions = {} +): Promise { + const config = options.config ?? DEFAULT_PROVER_CONFIG; + + // 1. Cache / resume the proving key (verified by SHA-256). + const zkey = await downloadKey(config.zkeyUrl, { + expectedSha256: config.zkeySha256, + onProgress: options.onKeyProgress, + signal: options.signal, + }); + + // 2. Prove off the main thread. + const worker = new Worker( + new URL("../workers/zkProver.worker.ts", import.meta.url), + { type: "module" } + ); + const id = ++workerCounter; + + try { + const { proof, publicSignals } = await new Promise<{ + proof: Groth16Proof; + publicSignals: string[]; + }>((resolve, reject) => { + const onAbort = () => { + worker.terminate(); + 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; + if (msg.type === "progress") { + options.onProveProgress?.(msg.progress); + } else if (msg.type === "result") { + options.signal?.removeEventListener("abort", onAbort); + resolve({ proof: msg.proof, publicSignals: msg.publicSignals }); + } else if (msg.type === "error") { + options.signal?.removeEventListener("abort", onAbort); + reject(new Error(msg.error)); + } + }; + worker.onerror = (e: ErrorEvent) => { + options.signal?.removeEventListener("abort", onAbort); + reject(new Error(e.message || "Prover worker crashed")); + }; + + // Transfer the (large) zkey buffer to avoid a structured-clone copy. + const request: ZKWorkerRequest = { + id, + type: "prove", + inputs, + wasmUrl: new URL(config.wasmUrl, self.location.href).href, + zkey, + }; + worker.postMessage(request, [zkey]); + }); + + const proofHash = await hashProof(proof, publicSignals); + options.onProveProgress?.(100); + return { proof, publicSignals, proofHash }; + } finally { + worker.terminate(); + } +} diff --git a/src/services/zkVerifier.ts b/src/services/zkVerifier.ts new file mode 100644 index 0000000..e463975 --- /dev/null +++ b/src/services/zkVerifier.ts @@ -0,0 +1,140 @@ +"use client"; + +import { rpc, xdr, scValToNative } from "@stellar/stellar-sdk"; +import type { Groth16Proof, Groth16VerificationKey } from "@/types/zk"; +import { getRpcUrl } from "@/services/soroban"; +import { buildCacheKey, cacheGet, cacheSet } from "@/services/cache"; + +/** + * Client-side Groth16 verifier. The verification key lives on-chain in the + * Soroban contract under the `zk_verification_key` data entry. It is fetched + * once, cached locally for 24 hours, and used to verify proofs during audit + * with snarkjs — no proving key or private signals are involved. + */ + +/** On-chain data-entry key holding the serialized verification key. */ +const VK_DATA_ENTRY = "zk_verification_key"; + +/** Verification keys are cached for 24h, matching the freshness requirement. */ +const VK_TTL_MS = 24 * 60 * 60 * 1000; + +const SNARKJS_CDN = "https://cdn.jsdelivr.net/npm/snarkjs@0.7.5/+esm"; + +interface SnarkjsVerifier { + groth16: { + verify: ( + vk: Groth16VerificationKey, + publicSignals: string[], + proof: Groth16Proof + ) => Promise; + }; +} + +let snarkjsPromise: Promise | null = null; + +async function loadSnarkjs(): Promise { + if (snarkjsPromise) return snarkjsPromise; + snarkjsPromise = (async () => { + let lastError: unknown; + for (const specifier of ["snarkjs", SNARKJS_CDN]) { + try { + const mod = (await import(/* webpackIgnore: true */ specifier)) as { + default?: SnarkjsVerifier; + } & SnarkjsVerifier; + const resolved = (mod.groth16 ? mod : mod.default) as SnarkjsVerifier; + if (typeof resolved?.groth16?.verify === "function") return resolved; + } catch (err) { + lastError = err; + } + } + throw new Error( + `Unable to load snarkjs verifier: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }` + ); + })(); + return snarkjsPromise; +} + +/** + * Read the verification key from the Soroban contract's persistent storage. + * The contract stores it as a Bytes/string scval containing JSON. + */ +async function fetchVerificationKeyFromChain( + contractId: string, + network: string +): Promise { + const server = new rpc.Server(getRpcUrl(network)); + const key = xdr.ScVal.scvSymbol(VK_DATA_ENTRY); + + const entry = await server.getContractData( + contractId, + key, + rpc.Durability.Persistent + ); + + const native = scValToNative(entry.val.contractData().val()); + const json = + typeof native === "string" + ? native + : new TextDecoder().decode(native as Uint8Array); + const vk = JSON.parse(json) as Groth16VerificationKey; + + if (vk.protocol !== "groth16") { + throw new Error(`Unexpected proof system in verification key: ${vk.protocol}`); + } + return vk; +} + +/** + * Return the contract's Groth16 verification key, served from the 24h local + * cache when fresh and otherwise fetched from chain and re-cached. + */ +export async function getVerificationKey( + contractId: string, + network: string = "testnet", + options: { forceRefresh?: boolean } = {} +): Promise { + const cacheKey = buildCacheKey(["zk-vk", network, contractId]); + + if (!options.forceRefresh) { + const cached = await cacheGet(cacheKey); + if (cached) return cached; + } + + const vk = await fetchVerificationKeyFromChain(contractId, network); + await cacheSet(cacheKey, vk, VK_TTL_MS); + return vk; +} + +export interface VerifyOptions { + contractId: string; + network?: string; + /** Skip the cache and re-fetch the verification key from chain. */ + forceRefresh?: boolean; +} + +/** + * Verify a Groth16 proof against the contract's verification key. Resolves to + * `true`/`false`; a thrown error means verification could not be performed + * (e.g. the key could not be fetched), which callers should treat as a reject. + */ +export async function verifyProof( + proof: Groth16Proof, + publicSignals: string[], + options: VerifyOptions +): Promise { + const vk = await getVerificationKey( + options.contractId, + options.network ?? "testnet", + { forceRefresh: options.forceRefresh } + ); + + if (publicSignals.length !== vk.nPublic) { + // A signal-count mismatch can never verify; fail fast and clearly. + return false; + } + + const snarkjs = await loadSnarkjs(); + return snarkjs.groth16.verify(vk, publicSignals, proof); +} diff --git a/src/types/zk.ts b/src/types/zk.ts new file mode 100644 index 0000000..3b2f917 --- /dev/null +++ b/src/types/zk.ts @@ -0,0 +1,131 @@ +/** + * Shared types for the anonymous meter-reading zero-knowledge proof module. + * + * Proof system: Groth16 over BLS12-381, compiled via circom + snarkjs to WASM. + * The circuit proves that an encrypted meter reading is within an acceptable + * range and originates from a registered meter, without revealing the reading + * or the meter identity. + */ + +/** Acceptable consumption range enforced by the circuit's RangeCheck gadget. */ +export const CONSUMPTION_MIN_KWH = 0; +export const CONSUMPTION_MAX_KWH = 10_000; + +/** A recent block hash must fall within this many ledgers to be accepted. */ +export const FRESHNESS_LEDGER_WINDOW = 10; + +/** + * Private signals — never leave the prover. Field elements are passed to + * snarkjs as decimal strings to avoid precision loss above 2^53. + */ +export interface ZKPrivateInputs { + /** Registered meter identifier (248-bit field element, decimal string). */ + meterId: string; + /** Consumption reading in kWh (64-bit, decimal string). */ + consumption: string; + /** Per-submission random salt (248-bit field element, decimal string). */ + salt: string; +} + +/** + * Public signals — disclosed alongside the proof and bound into verification. + */ +export interface ZKPublicInputs { + /** Membership tree root the meter must belong to (256-bit hex). */ + merkleRoot: string; + /** x25519 ciphertext of the reading (512-bit hex). */ + encryptedCiphertext: string; + /** Reading timestamp, unix seconds (32-bit). */ + timestamp: number; + /** Recent ledger hash for replay protection (within last 10 ledgers). */ + blockHash: string; +} + +/** Full circuit witness input handed to the worker. */ +export interface ZKCircuitInputs extends ZKPrivateInputs, ZKPublicInputs {} + +/** Raw groth16 proof as returned by snarkjs. */ +export interface Groth16Proof { + pi_a: string[]; + pi_b: string[][]; + pi_c: string[]; + protocol: "groth16"; + curve: "bls12381"; +} + +/** Output of a successful proving run. */ +export interface ZKProofResult { + proof: Groth16Proof; + /** Public signals in circuit declaration order, as decimal strings. */ + publicSignals: string[]; + /** SHA-256 of the canonicalised proof, used as a client-side reference. */ + proofHash: string; +} + +/** snarkjs Groth16 verification key (subset of fields we rely on). */ +export interface Groth16VerificationKey { + protocol: "groth16"; + curve: "bls12381"; + nPublic: number; + vk_alpha_1: string[]; + vk_beta_2: string[][]; + vk_gamma_2: string[][]; + vk_delta_2: string[][]; + IC: string[][]; +} + +/** Lifecycle phases surfaced to the UI stepper. */ +export type ZKSubmissionStatus = + | "idle" + | "downloading-key" + | "proving" + | "submitting" + | "confirmed" + | "rejected"; + +export interface ZKSubmissionState { + status: ZKSubmissionStatus; + /** 0–100 overall progress across the active phase. */ + progress: number; + /** SHA-256 reference of the generated proof, once available. */ + proofHash: string | null; + /** Human-readable error if the run was rejected. */ + error: string | null; +} + +/** A plaintext meter reading submitted by a field operator. */ +export interface MeterReading { + meterId: string; + consumption: number; + /** Unix seconds; defaults to the current time when omitted. */ + timestamp?: number; +} + +/** Messages exchanged with the prover web worker. */ +export type ZKWorkerRequest = { + id: number; + type: "prove"; + inputs: ZKCircuitInputs; + wasmUrl: string; + /** The cached proving key bytes, transferred to the worker. */ + zkey: ArrayBuffer; +}; + +export type ZKWorkerResponse = + | { + id: number; + type: "progress"; + /** 0–100 within the proving phase. */ + progress: number; + } + | { + id: number; + type: "result"; + proof: Groth16Proof; + publicSignals: string[]; + } + | { + id: number; + type: "error"; + error: string; + }; diff --git a/src/workers/zkProver.worker.ts b/src/workers/zkProver.worker.ts new file mode 100644 index 0000000..14b3a3b --- /dev/null +++ b/src/workers/zkProver.worker.ts @@ -0,0 +1,111 @@ +/** + * Web Worker that runs the Groth16 (BLS12-381) prover off the main thread so a + * ~30 s proving run never blocks the field tablet's UI. It receives the circuit + * inputs, WASM circuit URL and the cached proving-key bytes, builds the witness + * via snarkjs and returns `{ proof, publicSignals }`. + * + * snarkjs is loaded lazily. It is intentionally NOT a build-time dependency + * (the package and its WASM are large and the zkey is CDN-hosted), so we resolve + * it at runtime from the bundler-provided global, a local module, or an ESM CDN. + */ + +import type { ZKWorkerRequest, ZKWorkerResponse, Groth16Proof } from "../types/zk"; + +interface SnarkjsLike { + groth16: { + fullProve: ( + input: Record, + wasmFile: string | { type: "mem"; data: Uint8Array }, + zkeyFile: string | { type: "mem"; data: Uint8Array } + ) => Promise<{ proof: Groth16Proof; publicSignals: string[] }>; + }; +} + +const worker = self as unknown as Worker & { + snarkjs?: SnarkjsLike; +}; + +const SNARKJS_CDN = "https://cdn.jsdelivr.net/npm/snarkjs@0.7.5/+esm"; + +let snarkjsPromise: Promise | null = null; + +async function loadSnarkjs(): Promise { + if (worker.snarkjs) return worker.snarkjs; + if (snarkjsPromise) return snarkjsPromise; + + snarkjsPromise = (async () => { + // Prefer a locally-installed module if the bundler provides one, then fall + // back to the pinned ESM CDN build. The dynamic specifier keeps the bundler + // from trying to resolve an optional dependency at build time. + const candidates = ["snarkjs", SNARKJS_CDN]; + let lastError: unknown; + for (const specifier of candidates) { + try { + const mod = (await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier)) as { + default?: SnarkjsLike; + } & SnarkjsLike; + const resolved = (mod.groth16 ? mod : mod.default) as SnarkjsLike; + if (typeof resolved?.groth16?.fullProve === "function") return resolved; + } catch (err) { + lastError = err; + } + } + throw new Error( + `Unable to load snarkjs prover: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }` + ); + })(); + + return snarkjsPromise; +} + +/** + * Map the structured circuit inputs onto the field-element signal names the + * compiled circuit expects. Field elements are kept as decimal strings. + */ +function toWitnessInput(inputs: ZKWorkerRequest["inputs"]): Record { + return { + meterId: inputs.meterId, + consumption: inputs.consumption, + salt: inputs.salt, + merkleRoot: inputs.merkleRoot, + encryptedCiphertext: inputs.encryptedCiphertext, + timestamp: inputs.timestamp, + blockHash: inputs.blockHash, + }; +} + +worker.addEventListener("message", async (event: MessageEvent) => { + const { id, type, inputs, wasmUrl, zkey } = event.data; + if (type !== "prove") return; + + const post = (msg: ZKWorkerResponse, transfer?: Transferable[]) => + worker.postMessage(msg, transfer ?? []); + + try { + post({ id, type: "progress", progress: 5 }); + const snarkjs = await loadSnarkjs(); + post({ id, type: "progress", progress: 20 }); + + const zkeyData = new Uint8Array(zkey); + + // snarkjs streams witness generation then proving internally; we cannot get + // fine-grained callbacks, so we bracket the call with coarse progress marks. + post({ id, type: "progress", progress: 35 }); + const { proof, publicSignals } = await snarkjs.groth16.fullProve( + toWitnessInput(inputs), + wasmUrl, + { type: "mem", data: zkeyData } + ); + post({ id, type: "progress", progress: 95 }); + + post({ id, type: "result", proof, publicSignals }); + } catch (err) { + post({ + id, + type: "error", + error: err instanceof Error ? err.message : String(err), + }); + } +}); diff --git a/tests/unit/keyCache.test.ts b/tests/unit/keyCache.test.ts new file mode 100644 index 0000000..4dec6b1 --- /dev/null +++ b/tests/unit/keyCache.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { sha256Hex, IntegrityError } from "@/services/keyCache"; + +describe("sha256Hex", () => { + it("matches the known digest of an empty input", async () => { + const hex = await sha256Hex(new ArrayBuffer(0)); + expect(hex).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + }); + + it('matches the known digest of "abc"', async () => { + const bytes = new TextEncoder().encode("abc"); + const hex = await sha256Hex(bytes.buffer); + expect(hex).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + }); + + it("returns lowercase hex of fixed 64-char length", async () => { + const bytes = new TextEncoder().encode("meter-reading"); + const hex = await sha256Hex(bytes.buffer); + expect(hex).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("IntegrityError", () => { + it("captures the url, expected and actual digests", () => { + const err = new IntegrityError("https://cdn/zkey", "aaaa", "bbbb"); + expect(err.name).toBe("IntegrityError"); + expect(err.url).toBe("https://cdn/zkey"); + expect(err.expected).toBe("aaaa"); + expect(err.actual).toBe("bbbb"); + expect(err.message).toContain("Integrity check failed"); + }); +}); diff --git a/tests/unit/zkProver.test.ts b/tests/unit/zkProver.test.ts new file mode 100644 index 0000000..01659f4 --- /dev/null +++ b/tests/unit/zkProver.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { + prepareInputs, + hexToFieldString, + generateSalt, + ProverInputError, +} from "@/services/zkProver"; +import type { MeterReading } from "@/types/zk"; +import type { ProofContext } from "@/services/zkProver"; + +const CONTEXT: ProofContext = { + merkleRoot: "0x" + "ab".repeat(32), + blockHash: "0x" + "cd".repeat(32), + encryptedCiphertext: "0x" + "ef".repeat(64), + salt: "0x" + "11".repeat(31), +}; + +describe("hexToFieldString", () => { + it("converts 0x-prefixed hex to a decimal field string", () => { + expect(hexToFieldString("0xff")).toBe("255"); + expect(hexToFieldString("ff")).toBe("255"); + expect(hexToFieldString("0x00")).toBe("0"); + }); + + it("rejects malformed hex", () => { + expect(() => hexToFieldString("0xzz")).toThrow(ProverInputError); + expect(() => hexToFieldString("")).toThrow(ProverInputError); + }); +}); + +describe("generateSalt", () => { + it("produces a 248-bit (62 hex char) 0x-prefixed value", () => { + const salt = generateSalt(); + expect(salt).toMatch(/^0x[0-9a-f]{62}$/); + }); + + it("produces unique values across calls", () => { + const a = generateSalt(); + const b = generateSalt(); + expect(a).not.toBe(b); + }); +}); + +describe("prepareInputs", () => { + const reading: MeterReading = { + meterId: "0x" + "0a".repeat(31), + consumption: 4200, + timestamp: 1_700_000_000, + }; + + it("assembles a full witness with normalised field strings", () => { + const inputs = prepareInputs(reading, CONTEXT); + expect(inputs.consumption).toBe("4200"); + expect(inputs.timestamp).toBe(1_700_000_000); + // Field elements are decimal strings, not hex. + expect(inputs.meterId).toMatch(/^[0-9]+$/); + expect(inputs.merkleRoot).toMatch(/^[0-9]+$/); + expect(inputs.blockHash).toMatch(/^[0-9]+$/); + // Ciphertext is carried through as-is (split into limbs by the circuit). + expect(inputs.encryptedCiphertext).toBe(CONTEXT.encryptedCiphertext); + }); + + it("generates a salt when none is supplied", () => { + const { salt, ...rest } = CONTEXT; + void salt; + const inputs = prepareInputs(reading, rest); + expect(inputs.salt).toMatch(/^[0-9]+$/); + }); + + it("rejects consumption below the accepted range", () => { + expect(() => + prepareInputs({ ...reading, consumption: -1 }, CONTEXT) + ).toThrow(ProverInputError); + }); + + it("rejects consumption above the accepted range", () => { + expect(() => + prepareInputs({ ...reading, consumption: 10_001 }, CONTEXT) + ).toThrow(/outside the accepted range/); + }); + + it("rejects non-integer consumption", () => { + expect(() => + prepareInputs({ ...reading, consumption: 12.5 }, CONTEXT) + ).toThrow(ProverInputError); + }); + + it("rejects a missing meter id", () => { + expect(() => + prepareInputs({ ...reading, meterId: "" }, CONTEXT) + ).toThrow(/meterId is required/); + }); + + it("rejects a timestamp that does not fit in 32 bits", () => { + expect(() => + prepareInputs({ ...reading, timestamp: 0x1_0000_0000 }, CONTEXT) + ).toThrow(/32 bits/); + }); + + it("accepts the inclusive range boundaries", () => { + expect(() => + prepareInputs({ ...reading, consumption: 0 }, CONTEXT) + ).not.toThrow(); + expect(() => + prepareInputs({ ...reading, consumption: 10_000 }, CONTEXT) + ).not.toThrow(); + }); +});