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>
135 lines
13 KiB
JavaScript
135 lines
13 KiB
JavaScript
// Igneum proof verification, server half. GET /api/verify with one of:
|
|
// ?block=<hash> | ?height=N the block's state word, every planned shard with the record the chain carries for it
|
|
// (vote key hash, payout, statement, proof hash, carrier) and, when the observer captured
|
|
// the shard's proof bytes, the capture: the browser checks it ran, the node's verdict,
|
|
// the native SP1 verifier's verdict and times, the decoded statement, the bytes url
|
|
// ?block=<hash>&shard=N&key=<keyHash>&bytes=1 the captured proof bytes themselves (application/octet-stream),
|
|
// for the browser to hash and parse (site/lib/proof.mjs on /proof/<hash>)
|
|
// ?latest=1 the newest captured proofs (block, shard, verdict), so the page can offer one
|
|
// Everything is read from what tools/observer wrote to Neon (live_blocks, live_proofs, live_proof_bytes, live_state).
|
|
// The pinned program ids come from site/lib/pinned-guests.mjs (generated from proving/igneum-prove/elf/manifest.json).
|
|
// The STARK is verified by the observer's native verifier (igneum-prove-host --mode verify, the pinned key) and by
|
|
// the node when its verifier is on; the browser checks the binding of the bytes to the signed record. The in-browser
|
|
// STARK verifier is not built (docs/plans/explorer.md, "Verify a proof"). Cached 5 s; the bytes are immutable.
|
|
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
|
import { blockState, decodeStatement, fromHex } from '../lib/proof.mjs';
|
|
import { PINNED } from '../lib/pinned-guests.mjs';
|
|
|
|
const STALE_AFTER_S = 30;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
|
|
export function shardRow(x, cap, hash) {
|
|
const base = {
|
|
i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover || null, key_hash: x.key_hash || null, payout: x.payout || null,
|
|
statement: x.statement || null, proof_hash: x.proof_hash || null, pgas: num(x.pgas), lag: num(x.lag_daa),
|
|
carried_by: x.carried_by || null, carrier_number: num(x.carrier_number), payout_wei: x.payout_wei || null, node_verified: x.verified === null || x.verified === undefined ? null : !!x.verified,
|
|
proof: null,
|
|
};
|
|
if (!cap) return base;
|
|
let decoded = null;
|
|
try { if (cap.public_values_hex) decoded = decodeStatement(fromHex(cap.public_values_hex)); } catch { decoded = null; }
|
|
base.key_hash = base.key_hash || cap.key_hash; base.payout = base.payout || cap.payout; base.statement = base.statement || cap.statement; base.proof_hash = base.proof_hash || cap.proof_hash;
|
|
base.proof = {
|
|
captured_at: cap.received_at, bytes: num(cap.proof_bytes), has_bytes: !!cap.has_bytes, sp1_version: cap.sp1_version || null,
|
|
node_verified: cap.node_verified === null || cap.node_verified === undefined ? null : !!cap.node_verified,
|
|
checks: { proof_hash: cap.proof_hash_check, statement: cap.statement_check, fields: cap.fields_check },
|
|
native: { verified: cap.native_verified === null || cap.native_verified === undefined ? null : !!cap.native_verified, verify_ms: num(cap.native_verify_ms), setup_ms: num(cap.native_setup_ms), total_ms: num(cap.native_total_ms),
|
|
program_id: cap.native_program_id || null, pinned_id: cap.native_pinned_id || null, ours: cap.native_ours === null || cap.native_ours === undefined ? null : !!cap.native_ours, note: cap.native_note || null, verifier: cap.verifier ? 'igneum-prove-host --mode verify (native SP1 light verifier, pinned key)' : null },
|
|
statement_decoded: decoded,
|
|
url: cap.has_bytes ? `/api/verify?block=${hash}&shard=${num(x.shard)}&key=${cap.key_hash}&bytes=1` : null,
|
|
};
|
|
return base;
|
|
}
|
|
|
|
export function createHandler({ env = process.env, sql } = {}) {
|
|
return async function handler(req, res) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
|
const q = new URL(req.url || '/', 'http://x').searchParams;
|
|
const T = tablePrefix();
|
|
try {
|
|
sql = sql || neon(env.DATABASE_URL);
|
|
|
|
// ---- the bytes ----
|
|
if (q.get('bytes') === '1') {
|
|
const hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase(), shard = Number(q.get('shard')), key = String(q.get('key') || '').replace(/^0x/i, '').toLowerCase();
|
|
if (!HEX64.test(hash) || !Number.isInteger(shard) || !HEX64.test(key)) { res.setHeader('Cache-Control', 'no-store'); return res.status(400).json({ ok: false, error: 'block (64 hex), shard (integer) and key (64 hex) are required' }); }
|
|
const rows = await sql(`SELECT encode(proof, 'hex') AS proof_hex, proof_bytes, proof_hash FROM ${T}live_proof_bytes WHERE block_hash = $1 AND shard = $2 AND key_hash = $3`, [hash, shard, key]);
|
|
if (!rows.length) { res.setHeader('Cache-Control', 'no-store'); return res.status(404).json({ ok: false, error: 'No captured proof for that block, shard and key.' }); }
|
|
if (!rows[0].proof_hex) { res.setHeader('Cache-Control', 'no-store'); return res.status(410).json({ ok: false, error: `The bytes of this proof were dropped (the observer keeps the newest captures only); the record, the checks and the verdict remain.`, proof_bytes: num(rows[0].proof_bytes), proof_hash: rows[0].proof_hash }); }
|
|
const bytes = Buffer.from(rows[0].proof_hex, 'hex');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
|
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
res.setHeader('Content-Length', String(bytes.length));
|
|
res.setHeader('X-Proof-Hash', rows[0].proof_hash || '');
|
|
return res.status(200).end(bytes);
|
|
}
|
|
|
|
res.setHeader('Cache-Control', 'public, max-age=5, s-maxage=5');
|
|
const head = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`);
|
|
const now = new Date(head[0].now).getTime(); const s = head[0].state || null;
|
|
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
|
const fin = s && s.finality ? { active: !!s.finality.finality_active, latest_locked_index: num(s.finality.latest_locked_index), latest_locked_blue_score: num(s.finality.latest_locked_blue_score) } : { active: false, latest_locked_index: null, latest_locked_blue_score: null };
|
|
const pv = s && s.proving ? s.proving : null;
|
|
const base = {
|
|
ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S,
|
|
finality: fin,
|
|
verifier: {
|
|
node: pv && pv.supported ? (pv.verifier || null) : null,
|
|
node_note: pv && pv.supported ? (pv.verifier === 'Off' ? 'the observer\'s node runs with its SP1 verifier off: it reads the chain\'s payouts; a paid shard means a carrying block paid it' : `the observer's node verifies pool proofs (${pv.verifier})`) : 'the observer has not reported the proving layer',
|
|
native: 'igneum-prove-host --mode verify: the native SP1 light verifier with the pinned key, run by the observer on each captured proof',
|
|
browser: 'SHA-256 of the bytes against the record\'s proof hash, keccak-256 of the public values against its statement, the statement\'s fields against the block (site/lib/proof.mjs); the in-browser STARK verifier is not built',
|
|
pinned: PINNED,
|
|
},
|
|
};
|
|
|
|
// ---- the newest captures ----
|
|
if (q.get('latest') === '1') {
|
|
const rows = await sql(`SELECT block_hash, shard, key_hash, number, prover, proof_bytes, proof IS NOT NULL AS has_bytes, native_verified, proof_hash_check, statement_check, fields_check, received_at FROM ${T}live_proof_bytes ORDER BY received_at DESC LIMIT 20`);
|
|
const count = await sql(`SELECT count(*)::int AS n, count(*) FILTER (WHERE proof IS NOT NULL)::int AS with_bytes, min(received_at) AS oldest FROM ${T}live_proof_bytes`);
|
|
return res.status(200).json({ ...base, captured: { total: count[0] ? count[0].n : 0, with_bytes: count[0] ? count[0].with_bytes : 0, oldest: count[0] ? count[0].oldest : null },
|
|
latest: rows.map(r => ({ block: r.block_hash, shard: num(r.shard), key_hash: r.key_hash, number: num(r.number), prover: r.prover, bytes: num(r.proof_bytes), has_bytes: !!r.has_bytes, native_verified: r.native_verified, checks_ok: r.proof_hash_check === true && r.statement_check === true && r.fields_check === true, captured_at: r.received_at, href: `/proof/${r.block_hash}` })) });
|
|
}
|
|
|
|
// ---- one block ----
|
|
let hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase();
|
|
if (q.has('height')) {
|
|
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 ORDER BY is_chain_block DESC, received_at DESC LIMIT 1`, [Number(q.get('height'))]);
|
|
if (!r.length) { res.setHeader('Cache-Control', 'no-store'); return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${q.get('height')} in the last 24 hours.` }); }
|
|
hash = r[0].hash;
|
|
}
|
|
if (!HEX64.test(hash)) { res.setHeader('Cache-Control', 'no-store'); return res.status(400).json({ ...base, ok: false, error: 'A block hash is 64 hex characters (block=<hash>), or give height=N.' }); }
|
|
const rows = await sql(`SELECT hash, number, blue_score, daa_score, timestamp_ms, is_chain_block, color, vote_key_hash, miner_address, evm_miner, received_at FROM ${T}live_blocks WHERE hash = $1`, [hash]);
|
|
if (!rows.length) { res.setHeader('Cache-Control', 'no-store'); return res.status(404).json({ ...base, ok: false, error: 'No block with that hash in the last 24 hours. The explorer keeps one day; older blocks live in the node.' }); }
|
|
const b = rows[0];
|
|
const [shards, caps, mergedBy, count] = await Promise.all([
|
|
// to_jsonb: the record columns (key_hash, payout, statement, proof_hash) exist once the observer has restarted on the 6 Oct 2026 code; until then they read as undefined
|
|
sql(`SELECT to_jsonb(p) AS j FROM ${T}live_proofs p WHERE block_hash = $1 ORDER BY shard`, [hash]).then(r => r.map(x => x.j)).catch(() => []),
|
|
sql(`SELECT block_hash, shard, key_hash, number, prover, payout, statement, proof_hash, proof_bytes, proof IS NOT NULL AS has_bytes, encode(public_values, 'hex') AS public_values_hex, sp1_version, node_verified,
|
|
proof_hash_check, statement_check, fields_check, native_verified, native_verify_ms, native_setup_ms, native_total_ms, native_program_id, native_pinned_id, native_ours, native_note, verifier, received_at
|
|
FROM ${T}live_proof_bytes WHERE block_hash = $1 ORDER BY shard, received_at DESC`, [hash]).catch(() => []),
|
|
b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash, blue_score FROM ${T}live_blocks WHERE is_chain_block AND received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND (detail->'mergeset'->'blues' ? $1 OR detail->'mergeset'->'reds' ? $1) LIMIT 1`, [hash, b.received_at]).catch(() => []),
|
|
sql(`SELECT count(*)::int AS n FROM ${T}live_proof_bytes WHERE received_at > now() - interval '24 hours'`).catch(() => [{ n: 0 }]),
|
|
]);
|
|
const capOf = new Map(); for (const c of caps) if (!capOf.has(num(c.shard))) capOf.set(num(c.shard), c);
|
|
const merged = mergedBy[0] || null;
|
|
const st = blockState({
|
|
chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending', number: num(b.number), blue_score: num(b.blue_score), shards: shards.map(x => x.state),
|
|
locked_blue_score: fin.latest_locked_blue_score, finality_active: fin.active,
|
|
merged_locked: merged && fin.latest_locked_blue_score !== null ? num(merged.blue_score) <= fin.latest_locked_blue_score : null,
|
|
});
|
|
return res.status(200).json({
|
|
...base,
|
|
block: { hash: b.hash, number: num(b.number), chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending', blue_score: num(b.blue_score), daa: num(b.daa_score), ts: num(b.timestamp_ms),
|
|
miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, miner: b.miner_address || null, evm_miner: b.evm_miner || null, merged_by: b.is_chain_block ? b.hash : (merged ? merged.hash : null), ...st },
|
|
shards: shards.map(x => shardRow(x, capOf.get(num(x.shard)) || null, hash)),
|
|
captured_24h: count[0] ? count[0].n : 0,
|
|
capture_note: 'the observer captures one pool proof every few minutes while the node still holds its bytes (about ten minutes after the block); a shard without a capture shows the record the chain carries, which any node can hand out again through igneum_getProofBytes while it holds the proof',
|
|
});
|
|
} catch (e) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
}
|
|
};
|
|
}
|
|
export default createHandler();
|