|
| 1 | +/** |
| 2 | + * Trustless block verification. |
| 3 | + * |
| 4 | + * Verifies a Mina block's Pickles/kimchi SNARK proof. A block that verifies attests its |
| 5 | + * entire chain history up to that block by Pickles recursion, so this lets a JS client |
| 6 | + * check chain validity against an **untrusted** data source — a node, an indexer, a GCS |
| 7 | + * archive — with no need to trust whoever supplied the bytes. |
| 8 | + * |
| 9 | + * The proof verifier is a WebAssembly module (`mina-verify-wasm`) that is *not* bundled |
| 10 | + * with this SDK (it is several MB). It is loaded lazily on first use; install it |
| 11 | + * alongside this package to enable verification: |
| 12 | + * |
| 13 | + * ```sh |
| 14 | + * npm install mina-verify-wasm |
| 15 | + * ``` |
| 16 | + * |
| 17 | + * GraphQL note: a daemon's GraphQL `protocolState` is a *lossy* projection and cannot be |
| 18 | + * re-hashed to verify a proof. The verifiable input is a **precomputed block** (the JSON |
| 19 | + * a daemon publishes to GCS / the archive), which carries the full protocol state. |
| 20 | + */ |
| 21 | + |
| 22 | +import { createRequire } from 'node:module'; |
| 23 | +import { join } from 'node:path'; |
| 24 | +import { pathToFileURL } from 'node:url'; |
| 25 | + |
| 26 | +/** Networks with an embedded verification key. */ |
| 27 | +export type VerifyNetwork = 'devnet' | 'mainnet'; |
| 28 | + |
| 29 | +/** Proof-backed facts extracted from a verified block — every field is attested by the |
| 30 | + * proof, so it is safe to trust even though the block came from an untrusted source. */ |
| 31 | +export interface VerifiedBlock { |
| 32 | + /** Block height (blockchain length). */ |
| 33 | + height: number; |
| 34 | + /** This block's state hash ("3N…"). */ |
| 35 | + stateHash: string; |
| 36 | + /** Parent block's state hash. */ |
| 37 | + previousStateHash: string; |
| 38 | + /** Staged-ledger Merkle root ("jx…"): an indexer's replayed ledger root must equal this. */ |
| 39 | + stagedLedgerHash: string; |
| 40 | +} |
| 41 | + |
| 42 | +export interface VerifyOptions { |
| 43 | + /** Verification-key network. Default `'devnet'`. */ |
| 44 | + network?: VerifyNetwork; |
| 45 | +} |
| 46 | + |
| 47 | +/** The block's proof did not verify, or the block JSON could not be decoded. The block |
| 48 | + * must NOT be ingested. */ |
| 49 | +export class VerificationError extends Error { |
| 50 | + constructor(message: string) { |
| 51 | + super(message); |
| 52 | + this.name = 'VerificationError'; |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +/** The optional `mina-verify-wasm` backend is not installed (or failed to load). */ |
| 57 | +export class VerificationBackendError extends Error { |
| 58 | + constructor(override readonly cause: unknown) { |
| 59 | + super( |
| 60 | + 'mina-verify-wasm is required for block verification but could not be loaded. ' + |
| 61 | + 'Install it with `npm install mina-verify-wasm`. ' + |
| 62 | + `Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`, |
| 63 | + ); |
| 64 | + this.name = 'VerificationBackendError'; |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +interface VerifyBackend { |
| 69 | + verifyPrecomputed(network: string, precomputedJson: string): string; |
| 70 | +} |
| 71 | + |
| 72 | +// A non-literal specifier so TypeScript/bundlers don't try to resolve the (optional, |
| 73 | +// unbundled) backend at build time — it's resolved at runtime from the host's modules. |
| 74 | +const BACKEND_PACKAGE = 'mina-verify-wasm'; |
| 75 | +let backend: VerifyBackend | undefined; |
| 76 | + |
| 77 | +// Synchronous loader. The `mina-verify-wasm` (nodejs target) package is CommonJS and |
| 78 | +// instantiates its wasm synchronously on require, so the whole verify path is sync — |
| 79 | +// it just blocks while the (CPU-bound) proof check runs. |
| 80 | +// |
| 81 | +// Resolve the optional backend from both this module's location (covers a hoisted |
| 82 | +// install next to the SDK) and the host process's cwd (covers a backend installed at |
| 83 | +// the app root, or an SDK that is symlinked / pnpm-isolated). `import.meta.url` works |
| 84 | +// in both the ESM and CJS builds (esbuild shims it for CJS). |
| 85 | +function loadBackend(): VerifyBackend { |
| 86 | + if (backend) return backend; |
| 87 | + const bases = [import.meta.url, pathToFileURL(join(process.cwd(), 'noop.js')).href]; |
| 88 | + let lastError: unknown; |
| 89 | + for (const base of bases) { |
| 90 | + try { |
| 91 | + const require = createRequire(base); |
| 92 | + const mod = require(BACKEND_PACKAGE) as Record<string, unknown>; |
| 93 | + const inner = (mod.default ?? mod) as Record<string, unknown>; |
| 94 | + if (typeof inner.verifyPrecomputed !== 'function') { |
| 95 | + throw new Error('module does not export verifyPrecomputed'); |
| 96 | + } |
| 97 | + backend = inner as unknown as VerifyBackend; |
| 98 | + return backend; |
| 99 | + } catch (cause) { |
| 100 | + lastError = cause; |
| 101 | + } |
| 102 | + } |
| 103 | + throw new VerificationBackendError(lastError); |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * Verify a **precomputed block** (the JSON a daemon publishes; the `{ "version", "data" }` |
| 108 | + * form, or a bare block object) and return its proof-backed facts. |
| 109 | + * |
| 110 | + * Synchronous and **blocking**: the proof check is CPU-bound and currently takes tens of |
| 111 | + * seconds, during which it holds the event loop. Run it off the main thread (a worker) if |
| 112 | + * the host must stay responsive. |
| 113 | + * |
| 114 | + * @throws {VerificationError} if the proof does not verify or the JSON is malformed. |
| 115 | + * @throws {VerificationBackendError} if `mina-verify-wasm` is not installed. |
| 116 | + */ |
| 117 | +export function verifyPrecomputedBlock( |
| 118 | + precomputed: string | object, |
| 119 | + options: VerifyOptions = {}, |
| 120 | +): VerifiedBlock { |
| 121 | + const network = options.network ?? 'devnet'; |
| 122 | + const json = typeof precomputed === 'string' ? precomputed : JSON.stringify(precomputed); |
| 123 | + const backend = loadBackend(); |
| 124 | + let raw: string; |
| 125 | + try { |
| 126 | + raw = backend.verifyPrecomputed(network, json); |
| 127 | + } catch (cause) { |
| 128 | + // The wasm rejects an invalid proof / undecodable block by throwing a string. |
| 129 | + throw new VerificationError(cause instanceof Error ? cause.message : String(cause)); |
| 130 | + } |
| 131 | + return JSON.parse(raw) as VerifiedBlock; |
| 132 | +} |
| 133 | + |
| 134 | +/** The result of checking an endpoint's claims against proof-backed facts. */ |
| 135 | +export interface HonestyResult { |
| 136 | + /** True iff every claimed field matched the proof-backed facts. */ |
| 137 | + honest: boolean; |
| 138 | + /** The proof-backed facts (authoritative). */ |
| 139 | + facts: VerifiedBlock; |
| 140 | + /** Fields where the claim disagreed with the proof — empty iff `honest`. */ |
| 141 | + mismatches: Array<{ field: keyof VerifiedBlock; claimed: unknown; actual: unknown }>; |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * Compare an endpoint's claimed block facts to proof-backed facts (pure; no I/O). |
| 146 | + * Only fields present in `claimed` are checked. A non-empty `mismatches` proves the |
| 147 | + * source lied about that field relative to what the proof attests. |
| 148 | + */ |
| 149 | +export function compareToClaims( |
| 150 | + facts: VerifiedBlock, |
| 151 | + claimed: Partial<VerifiedBlock>, |
| 152 | +): HonestyResult { |
| 153 | + const mismatches: HonestyResult['mismatches'] = []; |
| 154 | + for (const key of Object.keys(claimed) as Array<keyof VerifiedBlock>) { |
| 155 | + const claim = claimed[key]; |
| 156 | + if (claim === undefined) continue; |
| 157 | + if (claim !== facts[key]) { |
| 158 | + mismatches.push({ field: key, claimed: claim, actual: facts[key] }); |
| 159 | + } |
| 160 | + } |
| 161 | + return { honest: mismatches.length === 0, facts, mismatches }; |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Verify a precomputed block and check an untrusted source's claims about it against the |
| 166 | + * proof-backed facts — the endpoint-honesty primitive. `honest: false` means the source |
| 167 | + * served data inconsistent with what the SNARK proof attests. |
| 168 | + * |
| 169 | + * Synchronous and blocking — see {@link verifyPrecomputedBlock}. |
| 170 | + * |
| 171 | + * @throws {VerificationError} if the proof does not verify. |
| 172 | + * @throws {VerificationBackendError} if `mina-verify-wasm` is not installed. |
| 173 | + */ |
| 174 | +export function checkBlockClaims( |
| 175 | + precomputed: string | object, |
| 176 | + claimed: Partial<VerifiedBlock>, |
| 177 | + options: VerifyOptions = {}, |
| 178 | +): HonestyResult { |
| 179 | + const facts = verifyPrecomputedBlock(precomputed, options); |
| 180 | + return compareToClaims(facts, claimed); |
| 181 | +} |
0 commit comments