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>
128 lines
9.6 KiB
JavaScript
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 };
|