Skip to content

Commit f4bbdd7

Browse files
authored
Merge pull request #10 from o1-labs/feat/trustless-block-verification
feat: trustless block verification (verifyPrecomputedBlock / checkBlockClaims)
2 parents edad785 + b70d39e commit f4bbdd7

5 files changed

Lines changed: 303 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Trustless block verification. `verifyPrecomputedBlock(precomputed, { network })`
13+
verifies a block's Pickles/kimchi SNARK proof and returns proof-backed facts
14+
(`height`, `stateHash`, `previousStateHash`, `stagedLedgerHash`);
15+
`checkBlockClaims(precomputed, claimed)` and the pure `compareToClaims(facts, claimed)`
16+
check an untrusted endpoint's claims against the proof. The proof verifier is the
17+
optional, unbundled `mina-verify-wasm` package, loaded on first use; without it the
18+
calls throw `VerificationBackendError`. Verification is synchronous and CPU-bound
19+
(tens of seconds per block today).
20+
1021
## [0.2.3] - 2026-05-21
1122

1223
### Fixed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ TypeScript/JavaScript SDK for interacting with [Mina Protocol](https://minaproto
99
## Features
1010

1111
- **Daemon GraphQL client** — query node status, accounts, blocks; send payments and delegations
12+
- **Trustless verification** — verify a block's SNARK proof in-process (optional `mina-verify-wasm` backend)
1213
- Typed response objects with a `Currency` type backed by `bigint`
1314
- Automatic retry with configurable backoff
1415
- Public `executeQuery()` for custom GraphQL queries
@@ -97,13 +98,54 @@ console.log(b.mul(3).toString()); // "4.500000000"
9798

9899
`Currency` is immutable and stored as a `bigint` of nanomina, so all arithmetic is exact.
99100

101+
## Trustless verification
102+
103+
Verify a Mina block's Pickles/kimchi SNARK proof in JS. A block that verifies attests its
104+
**entire chain history** up to that block by Pickles recursion — so you can check chain
105+
validity against an *untrusted* source (a node, an indexer, a GCS archive) without trusting
106+
whoever supplied the bytes.
107+
108+
The proof verifier is a WebAssembly module, [`mina-verify-wasm`](https://github.com/MinaProtocol/mina-verify),
109+
that is **not bundled** with this SDK (it is several MB) and is loaded lazily on first use.
110+
Install it to enable verification:
111+
112+
```bash
113+
npm install mina-verify-wasm
114+
```
115+
116+
```ts
117+
import { verifyPrecomputedBlock, checkBlockClaims } from '@o1-labs/mina-sdk';
118+
119+
// A "precomputed block" is the JSON a daemon publishes to GCS / the archive. (A daemon's
120+
// GraphQL `protocolState` is a lossy projection and is NOT sufficient to verify a proof.)
121+
const facts = verifyPrecomputedBlock(precomputedJson, { network: 'devnet' });
122+
// -> { height, stateHash, previousStateHash, stagedLedgerHash } (all proof-backed)
123+
124+
// Endpoint-honesty check: does an untrusted source's claim match what the proof attests?
125+
const { honest, mismatches } = checkBlockClaims(precomputedJson, {
126+
stateHash: claimedFromSomeEndpoint,
127+
});
128+
if (!honest) console.error('endpoint lied:', mismatches);
129+
```
130+
131+
`verifyPrecomputedBlock` throws `VerificationError` if the proof does not verify (do not
132+
ingest the block), or `VerificationBackendError` if `mina-verify-wasm` is not installed.
133+
`compareToClaims(facts, claimed)` is the pure comparison if you already have facts.
134+
135+
> **Synchronous & blocking.** Verification is single-threaded and CPU-bound (~tens of
136+
> seconds per block), and these calls hold the event loop while running. Fine for scripts
137+
> and periodic/background checks; run it in a worker thread if the host must stay
138+
> responsive. A threaded backend is planned.
139+
100140
## Errors
101141

102142
- `GraphQLError` — daemon returned an `errors` array (not retried)
103143
- `DaemonConnectionError` — transport-level failure after `retries` attempts
104144
- `AccountNotFoundError``getAccount` returned a `null` account
105145
- `CurrencyParseError` — invalid input to a `Currency.from*` factory
106146
- `CurrencyUnderflowError``Currency.sub` would go negative
147+
- `VerificationError` — a block's SNARK proof did not verify (do not ingest)
148+
- `VerificationBackendError` — the optional `mina-verify-wasm` backend is not installed
107149

108150
## Custom Queries
109151

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ export { AccountNotFoundError, DaemonConnectionError, GraphQLError } from './err
88
export type { GraphQLErrorEntry } from './errors.js';
99
export { MinaClient, DEFAULT_GRAPHQL_URI } from './client.js';
1010
export type { ClientConfig } from './client.js';
11+
export {
12+
checkBlockClaims,
13+
compareToClaims,
14+
verifyPrecomputedBlock,
15+
VerificationBackendError,
16+
VerificationError,
17+
} from './verify.js';
18+
export type { HonestyResult, VerifiedBlock, VerifyNetwork, VerifyOptions } from './verify.js';
1119
export type {
1220
AccountBalance,
1321
AccountData,

src/verify.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
}

tests/verify.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import {
3+
compareToClaims,
4+
verifyPrecomputedBlock,
5+
VerificationBackendError,
6+
type VerifiedBlock,
7+
} from '../src/index.js';
8+
9+
// Force the optional wasm backend to be unavailable, so the "backend missing" path is
10+
// tested deterministically whether or not `mina-verify-wasm` happens to be installed.
11+
vi.mock('mina-verify-wasm', () => {
12+
throw new Error("Cannot find module 'mina-verify-wasm'");
13+
});
14+
15+
const FACTS: VerifiedBlock = {
16+
height: 526824,
17+
stateHash: '3NLJdkAD23h8a8y87bdGwqfkuFdnqAg1GVahEWdhtSBpfXrgwNzw',
18+
previousStateHash: '3NKpcYv7raSh3wAkNCevTPzFQMdja3LUWwfFLaaDadwNwzUj5DGV',
19+
stagedLedgerHash: 'jxBSBGmRE3TZvQUGUwURzWVqnAZmkYfBMbayLZ692gwb3H6p6Aj',
20+
};
21+
22+
describe('compareToClaims', () => {
23+
it('is honest when every claimed field matches', () => {
24+
const r = compareToClaims(FACTS, {
25+
height: 526824,
26+
stateHash: FACTS.stateHash,
27+
});
28+
expect(r.honest).toBe(true);
29+
expect(r.mismatches).toEqual([]);
30+
expect(r.facts).toBe(FACTS);
31+
});
32+
33+
it('only checks fields that are present in the claim', () => {
34+
expect(compareToClaims(FACTS, {}).honest).toBe(true);
35+
expect(compareToClaims(FACTS, { stagedLedgerHash: FACTS.stagedLedgerHash }).honest).toBe(true);
36+
});
37+
38+
it('flags a lying source and reports the offending field', () => {
39+
const r = compareToClaims(FACTS, { stateHash: '3NLieToTheClient', height: 526824 });
40+
expect(r.honest).toBe(false);
41+
expect(r.mismatches).toHaveLength(1);
42+
expect(r.mismatches[0]).toMatchObject({
43+
field: 'stateHash',
44+
claimed: '3NLieToTheClient',
45+
actual: FACTS.stateHash,
46+
});
47+
});
48+
49+
it('reports every mismatched field', () => {
50+
const r = compareToClaims(FACTS, { height: 1, stagedLedgerHash: 'jxWRONG' });
51+
expect(r.honest).toBe(false);
52+
expect(r.mismatches.map((m) => m.field).sort()).toEqual(['height', 'stagedLedgerHash']);
53+
});
54+
});
55+
56+
describe('verifyPrecomputedBlock', () => {
57+
it('throws VerificationBackendError when mina-verify-wasm is not installed', () => {
58+
// The wasm backend is an optional, unbundled dependency; in CI it is absent.
59+
expect(() => verifyPrecomputedBlock('{}')).toThrow(VerificationBackendError);
60+
});
61+
});

0 commit comments

Comments
 (0)