igneum/site/lib/proof.mjs
igneum-labs 87a329438c 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

201 lines
14 KiB
JavaScript

// Igneum proof checks that run anywhere: in the browser on /proof/<hash>, in the observer when it captures a proof,
// and in the tests. Zero dependencies (site/lib/eth.mjs imports node:crypto, so its keccak is repeated here for the
// browser; site/lib/proof.test.mjs checks the two agree). What a stranger can check without our software, from the
// bytes the chain and the node hand out:
// 1. the proof bytes hash (SHA-256) to the proof_hash the signed record carries in a coinbase;
// 2. the public values inside the proof hash (keccak-256) to the statement the record carries;
// 3. the public values decode to this block, this number, this shard and this payout address.
// The STARK itself (SP1 compressed proof, 1.27 MB) is verified by the node and by the observer's native verifier;
// the in-browser STARK verifier is not built (docs/plans/explorer.md, "Verify a proof").
// ---- keccak-256 (the original Keccak padding 0x01..0x80), the same code as site/lib/eth.mjs ----------------------
const RC = [
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
];
const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
const M64 = (1n << 64n) - 1n;
const rotl = (v, n) => n === 0 ? v : (((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64);
function keccakF(A) {
const C = new Array(5), D = new Array(5), B = new Array(25);
for (let round = 0; round < 24; round++) {
for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
for (let x = 0; x < 5; x++) D[x] = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1);
for (let i = 0; i < 25; i++) A[i] ^= D[i % 5];
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], ROT[x + 5 * y]);
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y] & M64) & B[(x + 2) % 5 + 5 * y]);
A[0] ^= RC[round];
}
}
export function keccak256(bytes) {
const rate = 136;
const padded = new Uint8Array(Math.ceil((bytes.length + 1) / rate) * rate);
padded.set(bytes); padded[bytes.length] ^= 0x01; padded[padded.length - 1] ^= 0x80;
const A = new Array(25).fill(0n);
for (let off = 0; off < padded.length; off += rate) {
for (let i = 0; i < rate / 8; i++) {
let lane = 0n;
for (let b = 7; b >= 0; b--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b]);
A[i] ^= lane;
}
keccakF(A);
}
const out = new Uint8Array(32);
for (let i = 0; i < 4; i++) { let lane = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(lane & 0xffn); lane >>= 8n; } }
return out;
}
/** 328 bytes, big-endian, `ShardOutput::to_bytes` (proving/igneum-prove/core/src/shard.rs; the node's
* `statement_bytes` in igneum/exec/src/proving.rs writes the same bytes). */
export const STATEMENT_LEN = 328;
const hexOf = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
export function toHex(bytes, prefix = '0x') { return prefix + hexOf(bytes); }
export function fromHex(s) {
const h = String(s || '').replace(/^0x/i, '').replace(/^\\x/i, '');
if (h.length % 2 || /[^0-9a-f]/i.test(h)) throw new Error('not hex');
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
const u64 = (b, o) => { let v = 0n; for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(b[o + i]); return v; };
const u32 = (b, o) => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
const u64le = (b, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(b[o + i]); return v; };
/**
* The public values of an SP1 proof file, read from its tail. The file is bincode of
* `SP1ProofWithPublicValues { proof, public_values: { buffer: { data: Vec<u8> } }, sp1_version: String, tee_proof: Option<Vec<u8>> }`
* (sp1-sdk 6.8.1 src/proof.rs; sp1-primitives 6.8.1 src/types.rs, `ptr` is serde-skipped), so the last bytes are
* `[u64 len][public values][u64 len][version][0x00]`. The proof enum comes first and is not parsed here.
* Returns { publicValues: Uint8Array, version: string, proofEnd: number } or throws with the reason.
*/
export function parseProofTail(bytes) {
const n = bytes.length;
if (n < 1 + 8 + 8 + STATEMENT_LEN) throw new Error(`file too short for a proof (${n} bytes)`);
if (bytes[n - 1] !== 0) throw new Error('the proof carries a TEE field; not an Igneum shard proof');
let p = n - 1;
// the version string, searched backwards: its u64 length sits 8 bytes before it
for (let len = 1; len <= 32; len++) {
const start = p - len, lenAt = start - 8;
if (lenAt < 8) break;
if (u64le(bytes, lenAt) !== BigInt(len)) continue;
const version = String.fromCharCode(...bytes.slice(start, p));
if (!/^v?\d+\.\d+\.\d+/.test(version)) continue;
// bincode writes a Vec as [u64 len][bytes], so the public values END at lenAt and their length sits before them:
// the shard statement first (328), then any other length up to 64 KB
const pvEnd = lenAt;
const lengths = [STATEMENT_LEN]; for (let L = 1; L <= 65536; L++) if (L !== STATEMENT_LEN) lengths.push(L);
for (const L of lengths) {
const at = pvEnd - L - 8;
if (at < 0) break;
if (u64le(bytes, at) === BigInt(L)) return { publicValues: bytes.slice(at + 8, pvEnd), version, proofEnd: at };
}
throw new Error('no public values length matches the tail');
}
throw new Error('no SP1 version string at the tail; not an SP1 proof file');
}
/** The shard statement decoded from 328 public-value bytes. Numbers as Number where they fit, hashes as 0x hex. */
export function decodeStatement(pv) {
if (!pv || pv.length !== STATEMENT_LEN) throw new Error(`a shard statement is ${STATEMENT_LEN} bytes, got ${pv ? pv.length : 0}`);
const h32 = o => toHex(pv.slice(o, o + 32));
return {
chain_id: Number(u64(pv, 0)), number: Number(u64(pv, 8)), block_hash: hexOf(pv.slice(16, 48)),
shard: u32(pv, 48), tx_start: u32(pv, 52), tx_count: u32(pv, 56),
link_in: h32(60), link_out: h32(92), tx_acc_in: h32(124), tx_acc_out: h32(156),
pre_root: h32(188), post_root: h32(220), receipts_root: h32(252),
gas_used: Number(u64(pv, 284)), pgas_used: Number(u64(pv, 292)), executed: u32(pv, 300), skipped: u32(pv, 304),
prover: toHex(pv.slice(308, 328)),
};
}
/** keccak-256 of the public values, the `statement` a proof record signs; 0x hex. */
export function statementOf(pv) { return toHex(keccak256(pv)); }
/** SHA-256 of the proof bytes, the `proof_hash` a proof record signs (igneum/exec/src/proving.rs `proof_hash`); 0x hex. */
export async function proofHashOf(bytes) {
const c = globalThis.crypto && globalThis.crypto.subtle;
if (c) return toHex(new Uint8Array(await c.digest('SHA-256', bytes)));
const { createHash } = await import('node:crypto');
return '0x' + createHash('sha256').update(bytes).digest('hex');
}
const eqHex = (a, b) => String(a || '').replace(/^0x/i, '').toLowerCase() === String(b || '').replace(/^0x/i, '').toLowerCase();
/**
* The three checks a browser runs on a captured proof against the record the chain carries. `record` has
* block_hash, number, shard, statement, proof_hash, payout (0x, 20 bytes); `bytes` is the proof file.
* Every check names what it compared; `ok` is true only when all three pass. Times in ms are the caller's.
*/
export async function checkProof(bytes, record) {
const checks = [];
const ph = await proofHashOf(bytes);
checks.push({ name: 'proof bytes hash to the record', ok: eqHex(ph, record.proof_hash), got: ph, want: record.proof_hash, how: 'SHA-256 of the file' });
let tail = null, st = null;
try { tail = parseProofTail(bytes); } catch (e) { checks.push({ name: 'public values found in the proof', ok: false, got: e.message, want: '328 bytes at the tail', how: 'bincode tail' }); }
if (tail) {
const stmt = statementOf(tail.publicValues);
checks.push({ name: 'public values hash to the statement', ok: eqHex(stmt, record.statement), got: stmt, want: record.statement, how: 'keccak-256 of the public values' });
try { st = decodeStatement(tail.publicValues); } catch (e) { checks.push({ name: 'statement decodes', ok: false, got: e.message, want: `${STATEMENT_LEN} bytes`, how: 'ShardOutput layout' }); }
if (st) {
const same = eqHex(st.block_hash, record.block_hash) && st.number === Number(record.number) && st.shard === Number(record.shard) && (!record.payout || eqHex(st.prover, record.payout));
checks.push({ name: 'statement names this block, shard and payout', ok: same, got: `block ${st.block_hash.slice(0, 12)}, number ${st.number}, shard ${st.shard}, payout ${st.prover}`, want: `block ${String(record.block_hash).replace(/^0x/, '').slice(0, 12)}, number ${record.number}, shard ${record.shard}${record.payout ? ', payout ' + record.payout : ''}`, how: 'fields of the public values' });
}
}
return { ok: checks.length >= 3 && checks.every(c => c.ok), checks, statement: st, version: tail ? tail.version : null, bytes: bytes.length };
}
/**
* The one state word for a block (design docs/design/execution-layer.md 2.4, ledger P17, applied to blocks: a block
* is as far along as its chain block). Inputs come from the observer's tables:
* chain (is a chain block), color (pending, blue, red), number (chain block number, null until the executor ran it),
* shards (state words of its planned shards: planned, proving, verified, paid), blue_score,
* locked_blue_score (blue score of the latest locked checkpoint, null when none), finality_active,
* merged_locked (a merged block: whether the chain block that merged it is at or below the lock; null = unknown).
* Returns { state, failure, note }; state is one of pending, included, executed, proven, finalised, "finality not active";
* failure is null or "finality paused" (a block is never "reorged out": one that left the selected chain is still held and merged; the
* transaction word carries that failure, from the node).
*/
export function blockState(b) {
const shards = b.shards || [];
const proven = shards.length > 0 && shards.every(s => s === 'verified' || s === 'paid');
const executed = !!b.chain && b.number !== null && b.number !== undefined;
const locked = b.locked_blue_score !== null && b.locked_blue_score !== undefined && b.blue_score !== null && b.blue_score !== undefined && Number(b.blue_score) <= Number(b.locked_blue_score);
let state, failure = null, note = '';
if (b.chain) {
if (executed && locked) { state = b.finality_active ? 'finalised' : 'finality not active'; if (!b.finality_active) note = 'a locked checkpoint covers it, finality is not active, so no surface says finalised'; }
else if (executed && proven) state = 'proven';
else if (executed) state = 'executed';
else { state = 'included'; note = 'a chain block the executor has not run yet'; }
if (executed && !locked && !b.finality_active) failure = 'finality paused';
} else if (b.color === 'blue' || b.color === 'red') {
state = b.merged_locked === true ? (b.finality_active ? 'finalised' : 'finality not active') : 'included';
note = b.color === 'red' ? 'merged red: excluded from the order, its reward goes to the merging miner' : 'merged blue by a chain block';
// a block that was on the selected chain and left it (a tip reorg) is still held and merged; only a transaction can be reorged out (ledger P17)
if (b.number !== null && b.number !== undefined) note = `left the selected chain at number ${b.number} (a reorg), then ${note}`;
} else {
state = 'pending'; note = 'not merged by a chain block yet';
if (b.number !== null && b.number !== undefined) note = `left the selected chain at number ${b.number} (a reorg), not merged again yet`;
}
if (!proven && shards.length) note = (note ? note + '; ' : '') + `${shards.filter(s => s === 'verified' || s === 'paid').length} of ${shards.length} shards proven`;
return { state, failure, note };
}
/** The transaction word from the node's igneum_getTransactionStatus (the P17 `state` field), or derived from its block when the node predates P17. */
export function txState(node, block, executedHere) {
if (node && typeof node.state === 'string') return { state: node.state, failure: node.failure || null, source: 'node' };
if (!block) return { state: 'included', failure: null, source: 'block' };
if (block.state === 'pending') return { state: 'included', failure: null, source: 'block', note: 'in a block not merged yet' };
if (!executedHere) return { state: 'included', failure: block.failure || null, source: 'block', note: 'this copy did not execute in this block' };
return { state: block.state, failure: block.failure || null, source: 'block' };
}
/** The lottery line for /api/stats (reviewer C46): never a fixed generator; read from the node's epoch. */
export function lotteryLine(pe) {
if (!pe || pe.program_class === null || pe.program_class === undefined) return 'Igneum lottery hash: random-program GPU hash, new program every hour; class and generator unknown until the observer reads the node\'s epoch (docs/spec/01-lottery-hash.md)';
const g = Number(pe.program_class);
const next = pe.next_program_class !== null && pe.next_program_class !== undefined && Number(pe.next_program_class) !== g ? `, next epoch class v${pe.next_program_class}` : '';
return `Igneum lottery hash: random-program GPU hash, new program every hour, class v${g} / generator ${g} (epoch ${pe.epoch_index}${next}; docs/spec/01-lottery-hash.md)`;
}