igneum/tools/observer/proof-capture.mjs
igneum-labs 1cd9c1dd91 Explorer: /proof/<hash> verifies a block's shard proof in the browser against the chain's record; the native SP1 verdict beside it; P17 state words on the block and explorer pages; /api/stats names the live program class (C46)
What a stranger sees: paste a chain block hash on /proof, the page downloads the captured proof bytes
(1,272,897 bytes), hashes them in the tab against the proof_hash the signed record carries, parses the
328-byte public values out of the SP1 container and checks keccak against the record's statement and the
decoded fields against the block (site/lib/proof.mjs, no library). The STARK is verified by this site's
node (the observer runs igneum-prove-host --mode verify with the pinned key on each capture: 29 ms verify,
197 ms key setup on the fixture proof); the page says so and labels the in-browser STARK verifier as coming.
docs/plans/explorer.md section 8 carries the size and time numbers and the two routes (Groth16 wrap plus
sp1-verifier in wasm, or the compressed verifier ported to wasm32).

Observer: a sample of pool proofs captured through igneum_getProofBytes while the node holds them
(PROOF_CAPTURE_EVERY_MS, PROOF_BYTES_KEEP), checked and verified, written to live_proof_bytes; every
live_proofs row carries the record (key_hash, payout, statement, proof_hash); getBlockTemplate.powEpoch
read every 10 s into live_state.pow_epoch. RPC load: wrpc 230 to 248 per minute against 222 to 224 before,
evm unchanged.

P17: the node release 0.3.13 (bb43e9a8) does not carry the state field (it is on ledger-fixes-0311
fbb0082a), so the explorer cuts the one word from the observer's tables by the design 2.4 rule and takes the
node's word per transaction when the fork answers one. A block that left the selected chain reads included
with a note, never reorged out.

C46: /api/stats algorithm reads "class v3 / generator 3 (epoch 55; ...)" from the node's epoch line, v4
when the node reports 4, "unknown" before the observer has read it; new lottery field.

Tests: site/lib/proof.test.mjs (the real tail of block 59199's proof reproduces the host's statement),
site/api/verify.test.mjs, tools/observer/proof-capture.test.mjs (the native verifier refusing a pre-pin
proof), site/api/public-stats.test.mjs. Dry run on the fixture proof of block 56 through the local preview:
VERIFIED, 5.8 ms of checks and 139 ms of download in the browser, STARK 29 ms on the node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:15:44 +00:00

128 lines
9.6 KiB
JavaScript

// Proof capture for the explorer's /proof/<hash> page (6 October 2026, docs/plans/explorer.md "Verify a proof").
// The node keeps a shard proof's bytes in its pool for RECORD_WINDOW_CHAIN_BLOCKS (600 chain blocks, about ten
// minutes) after the proven block; `igneum_getProofBytes [number, shard, keyHash]` hands them out while they are
// there. The observer fetches a sample of them, checks what a browser can check (site/lib/proof.mjs: the SHA-256
// against the record's proof_hash, the keccak of the public values against its statement, the decoded statement
// against the block), runs the native SP1 verifier when one is configured (`igneum-prove-host --mode verify`, the
// pinned key, exit 0 = verified) and writes the lot to live_proof_bytes. Pure functions here, the wiring in
// observer.mjs; tools/observer/proof-capture.test.mjs checks the parsers against real host output.
import { spawn } from 'node:child_process';
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseProofTail, decodeStatement, statementOf, proofHashOf, fromHex, toHex } from '../../site/lib/proof.mjs';
/** The app bundle's host on the Mac, the default verifier when IGNEUM_PROOF_VERIFIER is unset and the file exists. */
export const MAC_APP_HOST = '/Applications/Igneum Miner.app/Contents/Resources/bin/igneum-prove-host';
/**
* Parses `igneum-prove-host --mode verify` output. The lines (host/src/main.rs run_verify):
* RESULT setup: 0.213 s (light verifier, pinned key), shard program id 0x2b1a... at <time>
* RESULT verify: VERIFIED in 0.031 s; block N shard S prover 0x.. statement 0x<got> (want 0x<want>) program id 0x.. (ours) proof B bytes at <time>
* RESULT verify: NOT VERIFIED in 0.001 s; ... program id 0x<claimed> IS NOT OURS 0x<pinned> (the prover runs another guest build) proof B bytes at <time>
* RESULT verify: NOT VERIFIED in 0.002 s; public values are not a shard statement; program id ... at <time>
*/
export function parseVerifyOutput(text) {
const out = { verified: null, verify_ms: null, setup_ms: null, pinned_id: null, claimed_id: null, ours: null, statement_got: null, statement_want: null, block: null, shard: null, prover: null, proof_bytes: null, note: '' };
const setup = /RESULT setup: ([\d.]+) s \(light verifier, pinned key\), shard program id (0x[0-9a-f]{64})/i.exec(text);
if (setup) { out.setup_ms = Math.round(Number(setup[1]) * 1000 * 10) / 10; out.pinned_id = setup[2].toLowerCase(); }
const v = /RESULT verify: (VERIFIED|NOT VERIFIED) in ([\d.]+) s;(.*)$/im.exec(text);
if (!v) { out.note = text.trim().split('\n').filter(l => /error|panic|No such|not found|refused/i.test(l)).slice(-2).join(' | ').slice(0, 300) || 'no RESULT verify line'; return out; }
out.verified = v[1] === 'VERIFIED'; out.verify_ms = Math.round(Number(v[2]) * 1000 * 10) / 10;
const rest = v[3];
const b = /block (\d+) shard (\d+) prover (0x[0-9a-f]{40}) statement (0x[0-9a-f]{64}) \(want (0x[0-9a-f]{64})\)/i.exec(rest);
if (b) { out.block = Number(b[1]); out.shard = Number(b[2]); out.prover = b[3].toLowerCase(); out.statement_got = b[4].toLowerCase(); out.statement_want = b[5].toLowerCase(); }
const notOurs = /program id (0x[0-9a-f]{64}) IS NOT OURS (0x[0-9a-f]{64})/i.exec(rest);
const ours = /program id (0x[0-9a-f]{64}) \(ours\)/i.exec(rest);
if (notOurs) { out.claimed_id = notOurs[1].toLowerCase(); out.pinned_id = out.pinned_id || notOurs[2].toLowerCase(); out.ours = false; out.note = 'the proof was made with another guest build (program id is not the pinned one)'; }
else if (ours) { out.claimed_id = ours[1].toLowerCase(); out.ours = true; }
if (/public values are not a shard statement/i.test(rest)) out.note = 'public values are not a shard statement';
else if (out.verified === false && !out.note && out.statement_got && out.statement_want && out.statement_got !== out.statement_want) out.note = 'the statement inside the proof is not the record\'s statement';
else if (out.verified === false && !out.note) out.note = 'the SP1 verifier rejected the proof';
const pb = /proof (\d+) bytes/i.exec(rest); if (pb) out.proof_bytes = Number(pb[1]);
return out;
}
/** Parses `--mode id`: the pinned ids the verifier carries. */
export function parseIdOutput(text) {
const m = /shard program id (0x[0-9a-f]{64}) \((\d+) bytes, sha256 (0x[0-9a-f]+)\) aggregator id (0x[0-9a-f]{64}) \((\d+) bytes\), pinned ([0-9T:Z-]+) on (.*?), SP1 ([\d.]+) circuit (v[\d.]+)/i.exec(text);
if (!m) return null;
return { shard_program_id: m[1].toLowerCase(), shard_elf_bytes: Number(m[2]), aggregator_id: m[4].toLowerCase(), aggregator_elf_bytes: Number(m[5]), pinned_at: m[6], sp1_crate_version: m[8], sp1_circuit_version: m[9] };
}
function run(cmd, args, timeoutMs = 60_000) {
return new Promise((resolve) => {
let out = '', err = '';
let p;
try { p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return resolve({ code: null, out: '', err: String(e.message || e) }); }
const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch { } }, timeoutMs);
p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { err += d; });
p.on('error', e => { clearTimeout(t); resolve({ code: null, out, err: err + String(e.message || e) }); });
p.on('close', code => { clearTimeout(t); resolve({ code, out, err }); });
});
}
/** The verifier's pinned ids, or null with the reason when it does not run. */
export async function verifierIds(host) {
if (!host) return { ids: null, reason: 'no verifier configured (IGNEUM_PROOF_VERIFIER)' };
const r = await run(host, ['--mode', 'id'], 20_000);
const ids = parseIdOutput(r.out);
return ids ? { ids, reason: null } : { ids: null, reason: `${host} --mode id: ${(r.err || r.out || 'no output').trim().slice(0, 200)}` };
}
/**
* Runs the native verifier on proof bytes against a statement. Writes the bytes to a temp file (the host reads a
* file), removes it after. Returns parseVerifyOutput's object plus total_ms (the process, setup included).
*/
export async function nativeVerify({ host, bytes, statement, dir = join(tmpdir(), 'igneum-observer') }) {
if (!host) return { verified: null, note: 'no verifier configured (IGNEUM_PROOF_VERIFIER)', total_ms: null };
await mkdir(dir, { recursive: true });
const file = join(dir, `verify-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.bin`);
await writeFile(file, bytes);
const t0 = performance.now();
const r = await run(host, ['--mode', 'verify', '--proof', file, '--statement', statement]);
const total_ms = Math.round((performance.now() - t0) * 10) / 10;
unlink(file).catch(() => { });
const parsed = parseVerifyOutput(r.out + '\n' + r.err);
if (parsed.verified === null && r.code !== 0) parsed.note = parsed.note || `verifier exited ${r.code}: ${(r.err || r.out).trim().slice(0, 200)}`;
return { ...parsed, total_ms, exit_code: r.code };
}
/**
* Fetches a pool proof from the node and runs every check. `evm(method, params)` is the observer's JSON-RPC call;
* `entry` is one igneum_getProofRecords pool entry (shard, keyHash, payout, statement, proofHash, verified).
* Returns the row for live_proof_bytes, or {error} when the node no longer holds the proof.
*/
export async function captureProof({ evm, blockHash, number, entry, host, verify = true }) {
const keyHash = String(entry.keyHash || '').replace(/^0x/, '').toLowerCase();
let r;
try { r = await evm('igneum_getProofBytes', ['0x' + Number(number).toString(16), Number(entry.shard), '0x' + keyHash]); }
catch (e) { return { error: `igneum_getProofBytes: ${e.message}` }; }
const bytes = fromHex(r.proof);
const statement = String(r.statement || entry.statement || '').toLowerCase();
const proofHash = String(r.proofHash || entry.proofHash || '').toLowerCase();
const row = {
block_hash: String(blockHash).replace(/^0x/, '').toLowerCase(), shard: Number(entry.shard), key_hash: keyHash, number: Number(number),
prover: keyHash.slice(0, 8), payout: entry.payout ? String(entry.payout).toLowerCase() : null, statement, proof_hash: proofHash,
proof: bytes, proof_bytes: bytes.length, public_values: null, sp1_version: null, decoded: null,
node_verified: r.verified === true ? true : r.verified === false ? false : null,
proof_hash_check: null, statement_check: null, fields_check: null,
native_verified: null, native_verify_ms: null, native_setup_ms: null, native_total_ms: null, native_program_id: null, native_pinned_id: null, native_ours: null, native_note: null,
};
row.proof_hash_check = (await proofHashOf(bytes)) === proofHash;
try {
const tail = parseProofTail(bytes);
row.public_values = tail.publicValues; row.sp1_version = tail.version;
row.statement_check = statementOf(tail.publicValues) === statement;
const d = decodeStatement(tail.publicValues); row.decoded = d;
row.fields_check = d.block_hash === row.block_hash && d.number === row.number && d.shard === row.shard && (!row.payout || d.prover === row.payout);
} catch (e) { row.statement_check = false; row.fields_check = false; row.native_note = `public values: ${e.message}`; }
if (verify && host) {
const v = await nativeVerify({ host, bytes, statement });
row.native_verified = v.verified; row.native_verify_ms = v.verify_ms; row.native_setup_ms = v.setup_ms; row.native_total_ms = v.total_ms;
row.native_program_id = v.claimed_id; row.native_pinned_id = v.pinned_id; row.native_ours = v.ours; row.native_note = [row.native_note, v.note].filter(Boolean).join('; ') || null;
} else if (verify) row.native_note = [row.native_note, 'no verifier configured (IGNEUM_PROOF_VERIFIER)'].filter(Boolean).join('; ');
return row;
}
export { toHex };