From 1cd9c1dd9143385a1460e8bcb6c9516400f2e2be Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:15:44 +0000 Subject: [PATCH] Explorer: /proof/ 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 --- docs/api/public-stats.md | 18 +- docs/plans/explorer.md | 92 ++++++ site/api/explorer.mjs | 45 ++- site/api/public-stats.test.mjs | 21 +- site/api/stats.mjs | 19 +- site/api/verify.mjs | 135 +++++++++ site/api/verify.test.mjs | 108 ++++++++ site/block.html | 10 +- site/build.mjs | 20 +- site/explorer.html | 20 +- site/lib/pinned-guests.mjs | 20 ++ site/lib/proof.mjs | 201 ++++++++++++++ site/lib/proof.test.mjs | 119 ++++++++ site/proof.html | 385 ++++++++++++++++++++++++++ site/vercel.json | 5 +- tools/observer/README.md | 8 +- tools/observer/observer.mjs | 171 +++++++++++- tools/observer/proof-capture.mjs | 128 +++++++++ tools/observer/proof-capture.test.mjs | 74 +++++ 19 files changed, 1558 insertions(+), 41 deletions(-) create mode 100644 site/api/verify.mjs create mode 100644 site/api/verify.test.mjs create mode 100644 site/lib/pinned-guests.mjs create mode 100644 site/lib/proof.mjs create mode 100644 site/lib/proof.test.mjs create mode 100644 site/proof.html create mode 100644 tools/observer/proof-capture.mjs create mode 100644 tools/observer/proof-capture.test.mjs diff --git a/docs/api/public-stats.md b/docs/api/public-stats.md index d41fb08ae..d3f7e0765 100644 --- a/docs/api/public-stats.md +++ b/docs/api/public-stats.md @@ -19,7 +19,8 @@ https://igneum.network on master). | Field | Meaning | Source | |---|---|---| | `network`, `chain_id`, `node_version` | Network name, EVM chain id (4461 mainnet, 4462 testnet, 4463 devnet, design 8.1), the node's version | observer `live_state` | -| `algorithm` | The lottery hash, named | fixed text | +| `algorithm` | The lottery hash, named with the LIVE program class and generator of the running epoch ("class v3 / generator 3 (epoch 55; ...)"; class v4 reads as v4 when the node reports it; "class and generator unknown" until the observer has read the epoch line). Never a fixed generator (reviewer C46, 6 October 2026) | observer `live_state.pow_epoch`, from the node's `getBlockTemplate.powEpoch` | +| `lottery` | The epoch line itself: `program_class`, `generator` (the same number: v2 = 2, v3 = 3, spec 01 section 1.4.6), `next_program_class`, `epoch_index`, `epoch_seed`, `epoch_blocks`, `boundary_daa`, `class_v3_activation_daa`, `era_index`, `dataset_log2`, `read_at`, `source`; every value null with a `source` note until the observer has read it | observer `live_state.pow_epoch` | | `stale`, `age_s`, `observer_updated_at` | `stale` when the observer has not written for 30 s; treat every number as last known then | observer | | `height` | The chain block number (the EVM block number): the number of the newest chain block the observer has a shard plan for | `live_blocks.number` | | `block_count`, `header_count` | Every DAG block the node holds | `getBlockDagInfo` | @@ -48,7 +49,7 @@ reflects a one-minute window): "network": "igneum-devnet", "chain_id": 4463, "node_version": "2.1.0", - "algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)", + "algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, class v3 / generator 3 (epoch 55; docs/spec/01-lottery-hash.md)", "stale": false, "age_s": 0.9, "height": 82145, @@ -225,3 +226,16 @@ The explorer's own feed, cached 5 s: `?blocks=N[&before=ms]` (latest blocks), `? `?address=0x..|igneumdev:..`, `?search=q`. Shapes are in `site/api/explorer.mjs`; the pages are the reference client. Balances need `EXPLORER_EVM_RPC` on the deployment (a public EVM JSON-RPC); without it `balance.available` is false with the reason. + +## /api/verify (6 October 2026) + +Feeds `/proof/`, the page where a stranger checks a block's shard proof without Igneum software +(`site/api/verify.mjs`, `site/proof.html`, `site/lib/proof.mjs`; the plan and the numbers in `docs/plans/explorer.md`, +"Verify a proof"). Cached 5 s; the bytes are immutable. + +| Query | Answer | +|---|---| +| `?block=` or `?height=N` | `block` with its one state word (`state`: pending, included, executed, proven, finalised, "finality not active"; `failure`: null, "reorged out" or "finality paused"; `note`), `finality` (the latest locked checkpoint the word is cut against), `verifier` (the node's verifier mode, the native verifier, what the browser checks, `pinned`: the pinned program ids and verifying-key hashes from `proving/igneum-prove/elf/manifest.json`), and `shards[]`: per planned shard the record the chain carries (`key_hash`, `payout`, `statement`, `proof_hash`, `carried_by`, `carrier_number`, `lag`, `payout_wei`) and, when the observer captured its bytes, `proof` (`bytes`, `has_bytes`, `sp1_version`, `node_verified`, `checks` {proof_hash, statement, fields}, `native` {verified, verify_ms, setup_ms, total_ms, program_id, pinned_id, ours, note}, `statement_decoded`, `url`) | +| `?block=&shard=N&key=&bytes=1` | The captured proof bytes, `application/octet-stream`, `X-Proof-Hash`; 410 with the hash when the observer dropped the bytes (it keeps the newest `PROOF_BYTES_KEEP`), 404 when nothing was captured | +| `?latest=1` | The newest captures: block, shard, prover, bytes, whether the three checks passed, the native verdict, `href` | + diff --git a/docs/plans/explorer.md b/docs/plans/explorer.md index 29b0ac4c5..785f63c02 100644 --- a/docs/plans/explorer.md +++ b/docs/plans/explorer.md @@ -127,3 +127,95 @@ site. None of this is in the repo; the stats API of this branch is the network s price when ordered. - `EXPLORER_EVM_RPC` on Vercel: no public devnet EVM RPC exists, so balances show "no EVM RPC configured" on the devnet deployment; the testnet's `https://rpc.testnet.igneum.network` is the value for the testnet. + +## 8. Verify a proof (6 October 2026, branch `explorer-verify`) + +Goal: a stranger checks a block's proof without our software. What shipped, what is verified where, and the number +that decides the in-browser verifier. + +### What a stranger sees + +`/proof/` (linked from every proven or finalised block on `/explorer`, from the shard +table on `/block/`, and from the "verify this block's proof" line under it). Paste a hash: the page fetches +`/api/verify?block=`, which returns the block's one state word, every planned shard with the record the chain +carries for it (vote key hash, payout, statement, proof hash, the carrying chain block) and, where the observer +captured the shard's proof, the bytes url and the verdicts. The browser then downloads the bytes +(`/api/verify?...&bytes=1`, 1,272,897 bytes for a compressed SP1 shard proof) and runs three checks in the tab +(`site/lib/proof.mjs`, no library, no server trust): SHA-256 of the bytes against the record's `proof_hash` +(`igneum/exec/src/proving.rs` `proof_hash`), keccak-256 of the public values parsed from the proof's bincode tail +against the record's `statement`, and the decoded 328-byte statement (`ShardOutput::to_bytes`) against this block's +hash, number, shard and payout. The strip shows the verdict (VERIFIED, NOT VERIFIED, binding only, not captured), +the time (checks plus download in this browser; the STARK's time on the node), the block's state word and the pinned +program id. Three cards say who verified what: in this browser (the binding), by this site's node (the native SP1 +light verifier with the pinned key, `igneum-prove-host --mode verify`, its verify and setup times), by the network +(carried and paid; the observer's node runs with its own verifier off, and says so). The page carries the label +"the in-browser verifier is coming" on the STARK line, and never says the STARK was verified in the browser. + +### The data path + +| Step | Where | Rate | +|---|---|---| +| The pool entry appears in `igneum_getProofRecords` (`proofBytes` > 0) | observer `pollRecords` | every 2 s, as before | +| `igneum_getProofBytes [number, shard, keyHash]` while the node holds it (600 chain blocks, `RECORD_WINDOW_CHAIN_BLOCKS`) | observer `captureTick`, `tools/observer/proof-capture.mjs` | one proof every `PROOF_CAPTURE_EVERY_MS` (5 min) | +| The three browser checks, then the native verifier | the observer, `igneum-prove-host --mode verify` from the Mac app bundle | 0.213 s key setup plus the verify per proof (below) | +| `live_proof_bytes` row (bytes kept for the newest `PROOF_BYTES_KEEP` = 50, about 64 MB; record, checks and verdict kept 24 h) | Neon | prune each minute | +| `/api/verify` and `/proof/` | Vercel function `site/api/verify.mjs`, `site/proof.html` | cached 5 s; the bytes immutable | + +Why a sample: 1,272,897 bytes per shard proof at one chain block per second is 110 GB a day. Every proof's RECORD +(statement, proof hash, key hash, payout) is now stored per shard in `live_proofs`, so a shard without captured +bytes still shows what the chain carries; any node hands the bytes out again while it holds them. + +### The in-browser verifier: the size and time problem, with numbers + +| Item | Number | Source | +|---|---|---| +| Compressed SP1 shard proof | 1,272,897 bytes (1,272,769 to 1,272,909 across runs) | `docs/bench-log.md` 5 October, the saved proof of block 59199 | +| Native verify of it, pinned key | 0.029 to 0.038 s; key setup 0.213 s with the light verifier (measured 6 October 15:46 UTC under other load, a functional run, not a benchmark) | bench-log; this branch's run of `--mode verify` on block 59199's proof | +| Browser checks on the same bytes | SHA-256 and keccak of 1.27 MB and 328 bytes: a few ms (measured on the page, shown in the strip) | `site/proof.html` | +| What verifies a COMPRESSED proof | `sp1-prover` `verify_compressed`: the recursion STARK verifier of `sp1-hypercube` 6.8.1 (KoalaBear, jagged PCS) plus the recursion verifying key and the allowed-vk Merkle root; std crates with rayon, tokio and the circuit artifacts | `proving/igneum-prove/host/src/proof_system.rs`, `~/.cargo/registry` sp1-* 6.8.1 | +| What `sp1-verifier` (the no_std, wasm-ready crate) verifies | Groth16 and Plonk WRAPPED proofs only | the crate's README (vendor registry) | +| The wrap step | NOT RUN here: `wrap` in `proof_system.rs` bails ("needs SP1's circuit artifacts", ledger P3); the wrapped proof would be about 260 bytes and the wasm verifier a few hundred KB, verify in tens of ms (approximate, from SP1's published figures, unmeasured on this project) | `host/src/proof_system.rs` line 327; ledger P3 | + +Decision for the first cut: ship the server-side verify (the observer's native verifier, labelled on the page) and +the browser binding checks. The in-browser STARK verifier is owed on one of two routes, both unmeasured here: +(a) wrap each shard proof to Groth16 (the P3 measurement: the circuit artifacts, the minutes per wrap on a consumer +GPU, who pays for it) and compile `sp1-verifier` to wasm; (b) port the compressed-proof verifier to wasm32 (drop +rayon, tokio and the file reads from the sp1-hypercube verify path; the recursion vk and allowed-vk root shipped as +static bytes, about 1 MB). Route (a) is the one that gives a phone a millisecond verify; route (b) keeps the 1.27 MB +download per proof. Hours of work either way, after P3's numbers exist. + +### The state word (ledger P17) and the fork + +The P17 `state` and `failure` fields live on the fork `ledger-fixes-0311` (`fbb0082a`, `tx_status_json`, +`igneum_getFinalityView`). They are NOT in `release-0.3.13-node` (`bb43e9a8` does not contain `b1e98b79`; +`igneum_getTransactionStatus` there still answers the three flags). So the explorer cuts the word itself from the +observer's tables with the same rule (`site/lib/proof.mjs` `blockState`, `txState`; design 2.4): a block is pending +(not merged), included (merged, or a chain block the executor has not run), executed (the node planned it), proven +(every shard's record carried and paid), finalised (at or below the latest locked checkpoint, finality active) or +"finality not active"; the failure "finality paused" (executed, not locked, finality not active). A block that had a +number and left the selected chain is NOT "reorged out": it is still held and merged (the tip flips between parallel +blocks several times a minute on the devnet); the note says so, and the word stays included. "Reorged out" and +"skipped" are transaction failures, read from the node. +Per transaction the block page asks `igneum_getTransactionStatus` when `EXPLORER_EVM_RPC` is set and takes the node's +`state` when the fork carries it; otherwise the word is cut from the block's. Owed: the fork merge (P17 into the +node release), after which the page reads the node's word and the derived one is the fallback. + +### Reviewer C46 + +`/api/stats` `algorithm` read "generator v2" as fixed text. The observer now asks `getBlockTemplate` every 10 s and +writes `powEpoch` (`program_class` 3, `next_program_class` 3, `program_class_v3_activation_daa` 154,800, epoch 55 on +the devnet at 15:52 UTC) to `live_state.pow_epoch`; the line reads "class v3 / generator 3 (epoch 55; ...)" and +will read v4 when the node reports 4. A new `lottery` field carries the epoch line. Until the restarted observer has +written it, the line says the class is unknown, never v2. + +### Tests and the dry run + +`site/lib/proof.test.mjs` (the tail parse and the statement decode against the real tail of block 59199's proof, +reproducing the statement the host printed; the three checks on genuine and tampered bytes; the state words), +`site/api/verify.test.mjs` (the API from a fixture: block, shards, capture, the bytes route, 404/410/400, the state +cut), `tools/observer/proof-capture.test.mjs` (the host-output parsers against real lines; the capture against a +fake node; on a Mac with the app, the native verifier refusing block 59199's pre-pin proof: "IS NOT OURS"), +`site/api/public-stats.test.mjs` (C46). CI runs the first, second and fourth with the explorer tests. + +Dry run: see the status below this section once it ran (the devnet's execution was reset for 0.3.13 at about +15:30 UTC and the pool was empty until the provers returned). diff --git a/site/api/explorer.mjs b/site/api/explorer.mjs index 02ad7f454..c5b4f2b2a 100644 --- a/site/api/explorer.mjs +++ b/site/api/explorer.mjs @@ -9,6 +9,7 @@ // Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s. import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs'; import { classify } from '../lib/explorer.mjs'; +import { blockState, txState } from '../lib/proof.mjs'; const STALE_AFTER_S = 30; const ROW = `hash, number, blue_score, daa_score, timestamp_ms, parents, parent_hashes, is_chain_block, vote_key_hash, miner_address, evm_miner, engine, @@ -44,7 +45,10 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { 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 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, evm_rpc_configured: !!EVM }; + // the finality view every state word is cut against (design 2.4, ledger P17): the latest locked checkpoint and whether finality is active + 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 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, evm_rpc_configured: !!EVM, finality: fin }; + const stateOf = (b, shardStates, mergedLocked = null) => 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: shardStates, locked_blue_score: fin.latest_locked_blue_score, finality_active: fin.active, merged_locked: mergedLocked }); // ---- search ---- if (q.has('search')) { @@ -52,7 +56,7 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { if (c.type === 'none') return res.status(404).json({ ...base, ok: false, error: 'Not a block hash, a transaction hash, a chain block number or an address.' }); if (c.type === 'address') return res.status(200).json({ ...base, type: 'address', href: `/address/${c.value}` }); if (c.type === 'height') { - const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [c.value]); + const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 ORDER BY is_chain_block DESC, received_at DESC LIMIT 1`, [c.value]); if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${c.value} in the last 24 hours.` }); return res.status(200).json({ ...base, type: 'block', href: `/block/${r[0].hash}` }); } @@ -70,15 +74,21 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { const rows = before ? await sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE received_at < to_timestamp($2 / 1000.0) ORDER BY received_at DESC LIMIT $1`, [n, before]) : await sql(`SELECT ${ROW} FROM ${T}live_blocks ORDER BY received_at DESC LIMIT $1`, [n]); - const locked = new Set((await sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => [])).map(c => c.hash)); - return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash) })) }); + const hashes = rows.map(b => b.hash); + const [lockedRows, shardRows] = await Promise.all([ + sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => []), + hashes.length ? sql(`SELECT block_hash, array_agg(state ORDER BY shard) AS states FROM ${T}live_proofs WHERE block_hash = ANY($1::text[]) GROUP BY 1`, [`{${hashes.map(h => `"${h}"`).join(',')}}`]).catch(() => []) : Promise.resolve([]), + ]); + const locked = new Set(lockedRows.map(c => c.hash)); + const shardsOf = new Map(shardRows.map(r => [r.block_hash, r.states || []])); + return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash), ...stateOf(b, shardsOf.get(b.hash) || []) })) }); } // ---- one block ---- if (q.has('block') || q.has('height')) { 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 LIMIT 1`, [Number(q.get('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) 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; } @@ -87,12 +97,15 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { if (!rows.length) 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, cps, certs, children, mergedBy] = await Promise.all([ - sql(`SELECT shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number FROM ${T}live_proofs WHERE block_hash = $1 ORDER BY shard`, [hash]).catch(() => []), + // to_jsonb: the record columns (key_hash, 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 index, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at FROM ${T}live_checkpoints WHERE hash = $1`, [hash]).catch(() => []), sql(`SELECT index, hash, carrier, voter_count, aggregator, total_weight, active_weight, prev_index, prev_hash, headers_complete, created_at FROM ${T}live_certificates WHERE hash = $1 OR carrier = $1`, [hash]).catch(() => []), sql(`SELECT hash FROM ${T}live_blocks WHERE received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND $1 = ANY(parent_hashes) ORDER BY received_at LIMIT 20`, [hash, b.received_at]), - b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash 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]), + 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]), ]); + const captured = await sql(`SELECT shard, key_hash, native_verified, proof_hash_check, statement_check, fields_check, proof IS NOT NULL AS has_bytes FROM ${T}live_proof_bytes WHERE block_hash = $1 ORDER BY shard, received_at DESC`, [hash]).catch(() => []); + const capOf = new Map(); for (const c of captured) if (!capOf.has(num(c.shard))) capOf.set(num(c.shard), c); let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC if (EVM && b.is_chain_block) { try { @@ -102,8 +115,24 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { igneum: e.igneum ? { rewards: e.igneum.rewards, proving_pool_credit: e.igneum.provingPoolCredit, pgas_used: e.igneum.pgasUsed, skipped: e.igneum.skipped } : null }; } catch (err) { evmView = { error: String(err.message || err).slice(0, 120) }; } } + // the one state word (design 2.4, ledger P17) for the block, and per transaction: the node's `state` when the + // fork carries it (ledger-fixes-0311), else cut from the block's word; at most 20 transactions are asked + const merged = mergedBy[0] || null; + const st = stateOf(b, shards.map(x => x.state), merged && fin.latest_locked_blue_score !== null ? num(merged.blue_score) <= fin.latest_locked_blue_score : null); + const txHashes = ((b.detail && b.detail.txs) || []).map(t => t.hash).filter(Boolean).slice(0, 20); + const executedHere = new Set(evmView && evmView.transactions ? evmView.transactions.map(t => String(t.hash).toLowerCase()) : []); + const txStates = {}; + let txSource = EVM ? 'block' : 'block (no EVM RPC on this deployment)'; + if (EVM && txHashes.length) { + const answers = await Promise.all(txHashes.map(h => evm(EVM, 'igneum_getTransactionStatus', [h]).catch(() => null))); + answers.forEach((a, i) => { const w = txState(a, st, executedHere.has(String(txHashes[i]).toLowerCase())); txStates[txHashes[i]] = w; if (w.source === 'node') txSource = 'node'; }); + if (txSource !== 'node' && answers.some(a => a)) txSource = 'block (the node predates the P17 state field; its flags were read)'; + } else for (const h of txHashes) txStates[h] = txState(null, st, executedHere.has(String(h).toLowerCase())); return res.status(200).json({ - ...base, block: { ...row(b), detail: b.detail || null }, shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number) })), + ...base, block: { ...row(b), detail: b.detail || null, ...st }, tx_states: txStates, tx_state_source: txSource, + shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number), + key_hash: x.key_hash || null, statement: x.statement || null, proof_hash: x.proof_hash || null, + captured: capOf.has(num(x.shard)) ? { native_verified: capOf.get(num(x.shard)).native_verified, checks_ok: capOf.get(num(x.shard)).proof_hash_check === true && capOf.get(num(x.shard)).statement_check === true && capOf.get(num(x.shard)).fields_check === true, has_bytes: !!capOf.get(num(x.shard)).has_bytes } : null })), checkpoint: cps[0] ? { index: num(cps[0].index), state: cps[0].state, signed: num(cps[0].signed_weight), active: num(cps[0].active_weight), total: num(cps[0].total_weight), fraction_active: num(cps[0].fraction_active), fraction_total: num(cps[0].fraction_total), votes: num(cps[0].votes_seen), voters: num(cps[0].voters), locked_at: cps[0].locked_at } : null, certificates: certs.map(c => ({ index: num(c.index), checkpoint: c.hash, carrier: c.carrier, role: c.hash === hash ? 'checkpoint' : 'carrier', voter_count: num(c.voter_count), aggregator: c.aggregator, total_weight: num(c.total_weight), active_weight: num(c.active_weight), previous: c.prev_index === null ? null : { index: num(c.prev_index), hash: c.prev_hash }, headers_complete: !!c.headers_complete })), children: children.map(c => c.hash), merged_by: b.is_chain_block ? hash : (mergedBy[0] ? mergedBy[0].hash : null), evm: evmView, diff --git a/site/api/public-stats.test.mjs b/site/api/public-stats.test.mjs index c7853f41c..a4fe79cdf 100644 --- a/site/api/public-stats.test.mjs +++ b/site/api/public-stats.test.mjs @@ -2,7 +2,7 @@ // (FIELDS in each handler), computed from a fixture of what the observer writes. Runs without a database. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createHandler as stats, FIELDS as STATS_FIELDS, REWARD_FIELDS, LAST_BLOCK_FIELDS, rewardAt } from './stats.mjs'; +import { createHandler as stats, FIELDS as STATS_FIELDS, REWARD_FIELDS, LAST_BLOCK_FIELDS, LOTTERY_FIELDS, rewardAt } from './stats.mjs'; import { createHandler as supply, FIELDS as SUPPLY_FIELDS, SCHEDULE_FIELDS } from './supply.mjs'; const NOW = '2026-10-05T19:21:56.000Z'; @@ -12,6 +12,8 @@ const state = { observer_started_at: '2026-10-05T19:01:00.000Z', updated_at: '2026-10-05T19:21:55.000Z', finality: { chain_id: 'igneum-devnet', finality_active: true, latest_locked_index: 4073, latest_locked_blue_score: 122190 }, supply_check: { checked_at: NOW, sampled: 500, rule_match: 500, rule_mismatch: 0, sum_match: 480, sum_mismatch: 0, sum_skipped: 20, examples: [], bps: 1 }, + // the epoch line the observer read from getBlockTemplate.powEpoch on 6 October 2026 (class v3 since DAA 154,800) + pow_epoch: { epoch_index: 55, epoch_seed: 'a900f8702a7da451fe19a147dc727f83149b551c2bc1e86bdc1c174934bd0fe4', epoch_blocks: 3600, boundary_daa: 201600, virtual_daa: 198548, program_class: 3, next_program_class: 3, class_v3_activation_daa: 154800, era_index: 0, dataset_log2: 28, day_index: 20732, read_at: NOW, source: 'getBlockTemplate.powEpoch' }, }; const last = { hash: '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee', blue_score: 122211, daa_score: 125064, timestamp_ms: 1791227608731, miner_address: 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e', vote_key_hash: '1c3f1190b777365e419f02fd46ce365f464377d0fdbdf1332da6600d09a75a0e', tx_count: 0, is_chain_block: true, number: 81263 }; @@ -35,6 +37,23 @@ test('/api/stats carries every documented field and the reward of spec 2.5', asy assert.equal(res.body.blocks_per_day_measured, 86400); assert.equal(res.body.stale, false); assert.equal(res.headers['Cache-Control'], 'public, max-age=10, s-maxage=10'); + // reviewer C46: the algorithm line names the live class and generator from the node, never a fixed one + for (const f of LOTTERY_FIELDS) assert.ok(f in res.body.lottery, `missing lottery.${f}`); + assert.match(res.body.algorithm, /class v3 \/ generator 3 \(epoch 55;/); + assert.doesNotMatch(res.body.algorithm, /generator v2/); + assert.equal(res.body.lottery.program_class, 3); assert.equal(res.body.lottery.generator, 3); assert.equal(res.body.lottery.class_v3_activation_daa, 154800); +}); +test('/api/stats without an epoch line says the class is unknown, and a v4 node reads as v4', async () => { + const saved = state.pow_epoch; + delete state.pow_epoch; + let res = fakeRes(); + await stats({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/stats' }, res); + assert.match(res.body.algorithm, /unknown until the observer reads/); assert.doesNotMatch(res.body.algorithm, /v2/); assert.equal(res.body.lottery.program_class, null); + state.pow_epoch = { ...saved, program_class: 4, next_program_class: 4, epoch_index: 900 }; + res = fakeRes(); + await stats({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/stats' }, res); + assert.match(res.body.algorithm, /class v4 \/ generator 4 \(epoch 900;/); + state.pow_epoch = saved; }); test('/api/stats marks a stale observer', async () => { const res = fakeRes(); diff --git a/site/api/stats.mjs b/site/api/stats.mjs index bc07b0d8d..97696f7c3 100644 --- a/site/api/stats.mjs +++ b/site/api/stats.mjs @@ -5,11 +5,13 @@ // example responses in docs/api/public-stats.md; FIELDS below is the contract the CI check asserts. import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs'; import { blockSubsidy, rampFactor, HALVING_INTERVAL_SECONDS, LAUNCH_RAMP_SECONDS, PROVING_POOL_SHARE_PERCENT, sompiToIgn } from '../lib/emission.mjs'; +import { lotteryLine } from '../lib/proof.mjs'; export const STALE_AFTER_S = 30; export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'node_version', 'algorithm', 'stale', 'age_s', 'height', 'block_count', 'header_count', 'daa', 'blue_score', 'difficulty', 'hashrate', 'hashrate_unit', 'hashrate_source', 'block_time_target_s', 'block_time_measured_s', 'blocks_per_day_target', 'blocks_per_day_measured', - 'block_reward', 'last_block', 'finality', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source']; + 'block_reward', 'last_block', 'finality', 'lottery', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source']; +export const LOTTERY_FIELDS = ['program_class', 'generator', 'next_program_class', 'epoch_index', 'epoch_seed', 'epoch_blocks', 'boundary_daa', 'class_v3_activation_daa', 'era_index', 'dataset_log2', 'read_at', 'source']; export const REWARD_FIELDS = ['sompi', 'ign', 'miner_ign', 'proving_pool_ign', 'split', 'daa_used', 'ramp_factor', 'halving_period', 'next_halving_daa', 'next_halving_in_s']; export const LAST_BLOCK_FIELDS = ['hash', 'time', 'ts_ms', 'age_s', 'blue_score', 'daa', 'miner', 'miner_id', 'tx_count', 'chain']; @@ -25,6 +27,17 @@ export function rewardAt(daa) { }; } +// The epoch line as the observer read it from getBlockTemplate.powEpoch (live_state.pow_epoch): the program class of +// the running epoch as a generator version (2, 3, later 4), the next epoch's class, the epoch index and seed. +export function lotteryOf(pe) { + if (!pe) return { program_class: null, generator: null, next_program_class: null, epoch_index: null, epoch_seed: null, epoch_blocks: null, boundary_daa: null, class_v3_activation_daa: null, era_index: null, dataset_log2: null, read_at: null, source: 'the observer has not read the node\'s epoch line yet (getBlockTemplate.powEpoch)' }; + return { + program_class: num(pe.program_class), generator: num(pe.program_class), next_program_class: num(pe.next_program_class), epoch_index: num(pe.epoch_index), epoch_seed: pe.epoch_seed || null, + epoch_blocks: num(pe.epoch_blocks), boundary_daa: num(pe.boundary_daa), class_v3_activation_daa: num(pe.class_v3_activation_daa), era_index: num(pe.era_index), dataset_log2: num(pe.dataset_log2), + read_at: pe.read_at || null, source: 'getBlockTemplate.powEpoch read by the observer every 10 s; class v2 = generator 2, v3 = generator 3 (docs/spec/01-lottery-hash.md 1.4.6)', + }; +} + export function shape({ now, state: s, last, miners10m }) { const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null; const age = updated === null ? null : Math.max(0, (now - updated) / 1000); @@ -36,7 +49,8 @@ export function shape({ now, state: s, last, miners10m }) { return { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, node_version: s ? s.node_version : null, - algorithm: 'Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)', + // the live class and generator from the node's epoch line (reviewer C46: never a fixed "generator v2"); class v4 reads as v4 when the node reports it + algorithm: lotteryLine(s && s.pow_epoch), stale, age_s: age === null ? null : Math.round(age * 10) / 10, // height is the chain block number (the EVM block number); block_count counts every DAG block the node holds height: last && last.number !== null && last.number !== undefined ? num(last.number) : null, @@ -54,6 +68,7 @@ export function shape({ now, state: s, last, miners10m }) { blue_score: num(last.blue_score), daa: num(last.daa_score), miner: last.miner_address || null, miner_id: last.vote_key_hash ? String(last.vote_key_hash).slice(0, 8) : null, tx_count: num(last.tx_count), chain: !!last.is_chain_block, } : null, + lottery: lotteryOf(s && s.pow_epoch), finality: 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), chain_id: s.finality.chain_id || null } : { active: false, latest_locked_index: null, latest_locked_blue_score: null, chain_id: null }, peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null, miners_10m: miners10m, observer_updated_at: s ? s.updated_at : null, diff --git a/site/api/verify.mjs b/site/api/verify.mjs new file mode 100644 index 000000000..84e043f87 --- /dev/null +++ b/site/api/verify.mjs @@ -0,0 +1,135 @@ +// Igneum proof verification, server half. GET /api/verify with one of: +// ?block= | ?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=&shard=N&key=&bytes=1 the captured proof bytes themselves (application/octet-stream), +// for the browser to hash and parse (site/lib/proof.mjs on /proof/) +// ?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=), 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(); diff --git a/site/api/verify.test.mjs b/site/api/verify.test.mjs new file mode 100644 index 000000000..9e8ccef50 --- /dev/null +++ b/site/api/verify.test.mjs @@ -0,0 +1,108 @@ +// /api/verify from a fixture of what the observer writes (no database): a proven chain block with one captured proof, +// the bytes route, the state word, the 404s. The capture row is a stand-in proof (filler plus the real tail of block +// 59199's proof, site/lib/proof.test.mjs) so the returned bytes hash and parse as a browser would. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createHandler } from './verify.mjs'; +import { checkProof, fromHex } from '../lib/proof.mjs'; +import { PINNED } from '../lib/pinned-guests.mjs'; + +const NOW = '2026-10-06T17:30:00.000Z'; +const H = 'e10881755e4dd2e316bad57c210e124546de87c3c66b54f285f54133ba82aead'; +const KEY = 'ab'.repeat(32); +const TAIL = '06d52e065d50a6d1474801000000000000000000000000116f000000000000e73fe10881755e4dd2e316bad57c210e124546de87c3c66b54f285f54133ba82aead00000000000000000000000038046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d538046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f4501340178cce1f22de96fd23db5c21b3cd245b3cc061d0811859d047e23ee993b4d7c5484ab50c80c12ec38af13679fcafcc04de483849ee9e5afe1b7844f256e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421000000000000000000000000000000000000000000000000dd442fcbb964a3afdc90d49b408e8dd296fa86e8060000000000000076362e312e3000'; +const STATEMENT = '0xdad25faf2aa6b9cfafa200562ff2e511677eae521af3d232bb3a256bfbd32a35'; +function fakeProof() { const head = new Uint8Array(2000); for (let i = 0; i < head.length; i++) head[i] = (i * 7 + 3) & 0xff; const tail = fromHex(TAIL); const out = new Uint8Array(head.length + tail.length); out.set(head); out.set(tail, head.length); return out; } +const PROOF = fakeProof(); +const PROOF_HASH = '0x' + createHash('sha256').update(PROOF).digest('hex'); +const PV_HEX = TAIL.slice(9 * 2 + 16, 9 * 2 + 16 + 328 * 2); + +const state = { id: 1, network: 'igneum-devnet', node_version: '2.1.0', updated_at: '2026-10-06T17:29:59.000Z', + finality: { finality_active: true, latest_locked_index: 5000, latest_locked_blue_score: 140000 }, + proving: { supported: true, active: true, verifier: 'Off', pool: { entries: 1, pending: 0, verified: 0, failed: 0 } } }; +const block = { hash: H, number: 59199, blue_score: 139000, daa_score: 141000, timestamp_ms: 1791306000000, is_chain_block: true, color: 'blue', vote_key_hash: '1c3f1190' + '0'.repeat(56), miner_address: 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e', evm_miner: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', received_at: '2026-10-06T17:20:00.000Z' }; +const shard = { shard: 0, shards: 1, state: 'paid', prover: 'abababab', verified: null, carried_by: 'f'.repeat(64), carrier_number: 59240, lag_daa: 41, payout_wei: '930084984000000000', pgas: 0, key_hash: KEY, payout: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', statement: STATEMENT, proof_hash: PROOF_HASH }; +const cap = { block_hash: H, shard: 0, key_hash: KEY, number: 59199, prover: 'abababab', payout: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', statement: STATEMENT, proof_hash: PROOF_HASH, proof_bytes: PROOF.length, has_bytes: true, public_values_hex: PV_HEX, sp1_version: 'v6.1.0', node_verified: null, + proof_hash_check: true, statement_check: true, fields_check: true, native_verified: true, native_verify_ms: 31, native_setup_ms: 205, native_total_ms: 260, native_program_id: PINNED.shard.program_id, native_pinned_id: PINNED.shard.program_id, native_ours: true, native_note: null, verifier: '/x/igneum-prove-host', received_at: '2026-10-06T17:21:00.000Z' }; + +function fakeSql({ noBytes = false, noBlock = false } = {}) { + return async (query, params) => { + if (/row_to_json/.test(query)) return [{ now: NOW, state }]; + if (/encode\(proof, 'hex'\)/.test(query)) return noBytes ? [{ proof_hex: null, proof_bytes: PROOF.length, proof_hash: PROOF_HASH }] : [{ proof_hex: Buffer.from(PROOF).toString('hex'), proof_bytes: PROOF.length, proof_hash: PROOF_HASH }]; + if (/FROM \w*live_blocks WHERE number = /.test(query)) return params[0] === 59199 ? [{ hash: H }] : []; + if (/FROM \w*live_blocks WHERE hash = /.test(query)) return noBlock || params[0] !== H ? [] : [block]; + if (/FROM \w*live_proofs p WHERE block_hash/.test(query)) return [{ j: shard }]; + if (/FROM \w*live_proof_bytes WHERE block_hash/.test(query)) return [cap]; + if (/count\(\*\)::int AS n, count\(\*\) FILTER/.test(query)) return [{ n: 3, with_bytes: 3, oldest: '2026-10-06T12:00:00.000Z' }]; + if (/count\(\*\)::int AS n FROM \w*live_proof_bytes/.test(query)) return [{ n: 3 }]; + if (/ORDER BY received_at DESC LIMIT 20/.test(query)) return [{ ...cap, native_verified: true }]; + return []; + }; +} +function fakeRes() { const r = { code: 0, headers: {}, body: null, raw: null, status(c) { r.code = c; return r; }, setHeader(k, v) { r.headers[k] = v; }, json(o) { r.body = o; return r; }, end(b) { r.raw = b; return r; } }; return r; } +const call = (url, opts) => { const res = fakeRes(); return createHandler({ sql: fakeSql(opts), env: {} })({ method: 'GET', url }, res).then(() => res); }; + +test('one block: the state word, the record of each shard, the capture with the three checks and the native verdict', async () => { + const res = await call('/api/verify?block=' + H); + assert.equal(res.code, 200); + const j = res.body; + assert.equal(j.ok, true); assert.equal(j.chain_id, 4463); assert.equal(j.stale, false); + assert.equal(j.block.state, 'finalised'); assert.equal(j.block.failure, null); assert.equal(j.block.number, 59199); + assert.equal(j.verifier.node, 'Off'); assert.equal(j.verifier.pinned.shard.program_id, PINNED.shard.program_id); + assert.equal(j.shards.length, 1); + const s = j.shards[0]; + assert.equal(s.state, 'paid'); assert.equal(s.key_hash, KEY); assert.equal(s.statement, STATEMENT); assert.equal(s.proof_hash, PROOF_HASH); + assert.equal(s.proof.bytes, PROOF.length); assert.equal(s.proof.has_bytes, true); + assert.deepEqual(s.proof.checks, { proof_hash: true, statement: true, fields: true }); + assert.equal(s.proof.native.verified, true); assert.equal(s.proof.native.verify_ms, 31); assert.equal(s.proof.native.ours, true); + assert.equal(s.proof.statement_decoded.number, 59199); assert.equal(s.proof.statement_decoded.block_hash, H); + assert.equal(s.proof.url, `/api/verify?block=${H}&shard=0&key=${KEY}&bytes=1`); + assert.equal(res.headers['Cache-Control'], 'public, max-age=5, s-maxage=5'); +}); + +test('the bytes route hands out the captured proof, and a browser\'s checks pass on them', async () => { + const res = await call(`/api/verify?block=${H}&shard=0&key=${KEY}&bytes=1`); + assert.equal(res.code, 200); + assert.equal(res.headers['Content-Type'], 'application/octet-stream'); + assert.equal(res.headers['Content-Length'], String(PROOF.length)); + assert.equal(res.headers['X-Proof-Hash'], PROOF_HASH); + assert.match(res.headers['Cache-Control'], /immutable/); + const r = await checkProof(new Uint8Array(res.raw), { block_hash: H, number: 59199, shard: 0, statement: STATEMENT, proof_hash: PROOF_HASH, payout: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8' }); + assert.equal(r.ok, true); +}); + +test('dropped bytes answer 410 with the hash; a missing capture 404; bad parameters 400', async () => { + const gone = await call(`/api/verify?block=${H}&shard=0&key=${KEY}&bytes=1`, { noBytes: true }); + assert.equal(gone.code, 410); assert.equal(gone.body.proof_hash, PROOF_HASH); + const bad = await call(`/api/verify?block=${H}&shard=x&key=${KEY}&bytes=1`); + assert.equal(bad.code, 400); + const none = await call(`/api/verify?block=${'0'.repeat(64)}&shard=0&key=${KEY}&bytes=1`); + assert.equal(none.code, 200); // the fake answers every (hash, shard, key); the shape is what is checked here +}); + +test('height resolves, an unknown block is 404, a bad hash is 400, latest lists captures', async () => { + const byHeight = await call('/api/verify?height=59199'); + assert.equal(byHeight.code, 200); assert.equal(byHeight.body.block.hash, H); + const missingHeight = await call('/api/verify?height=1'); + assert.equal(missingHeight.code, 404); + const unknown = await call('/api/verify?block=' + '1'.repeat(64)); + assert.equal(unknown.code, 404); assert.match(unknown.body.error, /last 24 hours/); + const bad = await call('/api/verify?block=zz'); + assert.equal(bad.code, 400); + const latest = await call('/api/verify?latest=1'); + assert.equal(latest.code, 200); assert.equal(latest.body.captured.total, 3); assert.equal(latest.body.latest[0].href, '/proof/' + H); assert.equal(latest.body.latest[0].checks_ok, true); +}); + +test('the state word never says more than the chain: finality off, shards not all proven, a merged block', async () => { + state.finality.finality_active = false; + let res = await call('/api/verify?block=' + H); + assert.equal(res.body.block.state, 'finality not active'); + state.finality.finality_active = true; state.finality.latest_locked_blue_score = 100; + res = await call('/api/verify?block=' + H); + assert.equal(res.body.block.state, 'proven'); + shard.state = 'proving'; + res = await call('/api/verify?block=' + H); + assert.equal(res.body.block.state, 'executed'); assert.match(res.body.block.note, /0 of 1 shards proven/); + shard.state = 'paid'; state.finality.latest_locked_blue_score = 140000; +}); diff --git a/site/block.html b/site/block.html index 62b0e4a44..8fe304083 100644 --- a/site/block.html +++ b/site/block.html @@ -278,12 +278,15 @@ async function load() { const b = j.block, d = b.detail || {}, now = new Date(j.now).getTime(); document.title = `Igneum block ${shortHash(b.hash, 8, 6)}`; $('title').textContent = b.hash; - const chips = [b.chain ? 'chain block' : `${esc(b.color)}`]; + // the one state word (design 2.4, ledger P17): pending, included, executed, proven, finalised or "finality not active"; and the failure path when there is one + const stateCls = s => s === 'finalised' || s === 'proven' ? 'ok' : s === 'executed' ? 'hot' : ''; + const chips = [b.state ? `${esc(b.state)}` : '', b.failure ? `${esc(b.failure)}` : '', b.chain ? 'chain block' : `${esc(b.color)}`]; if (j.checkpoint) chips.push(`checkpoint ${j.checkpoint.index}, ${esc(j.checkpoint.state)}`); if (b.proof_records) chips.push(`${b.proof_records} proof record${b.proof_records === 1 ? '' : 's'}`); if (b.tx_count) chips.push(`${b.tx_count} transaction${b.tx_count === 1 ? '' : 's'}`); $('chips').innerHTML = chips.join(''); $('summary').innerHTML = dl([ + ['State', `${esc(b.state || 'n/a')}${b.failure ? ` ${esc(b.failure)}` : ''} ${esc(b.note || '')}${j.finality && j.finality.latest_locked_index !== null ? ` · latest lock: checkpoint ${int(j.finality.latest_locked_index)} at blue score ${int(j.finality.latest_locked_blue_score)}${j.finality.active ? '' : ', finality not active'}` : ' · no locked checkpoint yet'}`], ['Number', b.number === null ? 'none, not a chain block' : int(b.number)], ['Time', `${utc(b.ts)} (${rel(now - b.ts)})`], ['DAA score', int(b.daa)], ['Blue score', int(b.blue_score)], @@ -311,12 +314,13 @@ async function load() { $('tx-n').textContent = `${b.tx_count ?? 0} EVM, 1 coinbase`; $('coinbase').innerHTML = cb ? `
${dl([['Coinbase', `${esc(cb.id)}`], ['Outputs', cb.outputs.map(o => `${o.pool ? 'proving pool (burns on the UTXO side, credited on the EVM side)' : addr(o.address)} ${ign(o.value, 8)} IGN`).join('
') || 'none'], ['Carries', `${cb.records} proof record${cb.records === 1 ? '' : 's'}${cb.reveal ? ', a key reveal' : ''}${(d.certificates || []).length ? `, certificate ${d.certificates.join(', ')}` : ''}`]])}
` : '
No coinbase decoded.
'; const txs = d.txs || []; const ev = j.evm && j.evm.transactions ? new Map(j.evm.transactions.map(t => [t.hash, t])) : null; - $('txs').innerHTML = txs.length ? `${txs.map(t => { const e = ev && ev.get(t.hash); return ``; }).join('')}
TransactionFromToValueBytes
${esc(t.hash || '?')}${e ? addr(e.from) : 'needs an EVM RPC'}${e ? addr(e.to) : ''}${e ? ignFromWei(e.value) + ' IGN' : ''}${t.size ?? ''}
` : '
No EVM transactions in this block.
'; + const ts = j.tx_states || {}; + $('txs').innerHTML = txs.length ? `${txs.map(t => { const e = ev && ev.get(t.hash); const w = ts[t.hash] || null; return ``; }).join('')}
TransactionStateFromToValueBytes
${esc(t.hash || '?')}${w ? `${esc(w.state)}${w.failure ? `${esc(w.failure)}` : ''}` : ''}${e ? addr(e.from) : 'needs an EVM RPC'}${e ? addr(e.to) : ''}${e ? ignFromWei(e.value) + ' IGN' : ''}${t.size ?? ''}

State per transaction: ${esc(j.tx_state_source === 'node' ? 'the node\'s own word (igneum_getTransactionStatus, one of included, executed, proven, finalised)' : 'cut from this block\'s word (' + (j.tx_state_source || 'block') + '); a copy that did not execute here reads included')}.

` : '
No EVM transactions in this block.
'; if (j.evm && !j.evm.error) $('txs').insertAdjacentHTML('beforeend', `

EVM view of this chain block: gas used ${int(j.evm.gas_used)}, state root ${esc(shortHash(j.evm.state_root, 10, 6))}${j.evm.igneum ? `, rewards ${j.evm.igneum.rewards.map(r => ignFromWei(r.wei) + ' IGN to ' + shortHash(r.miner, 6, 4)).join(', ')}, proving pool credit ${ignFromWei(j.evm.igneum.proving_pool_credit)} IGN` : ''}.

`); const sh = j.shards; $('proof-eyebrow').textContent = b.chain ? `${sh.length} shard${sh.length === 1 ? '' : 's'} planned for this chain block` : 'not a chain block'; $('proofs').innerHTML = `

This block carries ${b.proof_records ?? 0} proof record${b.proof_records === 1 ? '' : 's'} for earlier chain blocks (274 bytes each, in the coinbase extra data).

` + - (sh.length ? `
${sh.map(s => ``).join('')}
ShardStateProverpgasProof lag, DAACarried byPayout
${s.i} of ${s.n}${esc(s.state)}${esc(s.prover || '')}${int(s.pgas)}${s.lag === null ? '' : int(s.lag)}${s.carried_by ? link(s.carried_by) : ''}${s.payout_wei ? ignFromWei(s.payout_wei, 6) + ' IGN' : ''}
` : (b.chain ? '
No shard plan recorded (the proving feed was not reading this block).
' : '')); + (sh.length ? `
${sh.map(s => ``).join('')}
ShardStateProverpgasProof lag, DAACarried byPayoutProof
${s.i} of ${s.n}${esc(s.state)}${esc(s.prover || '')}${int(s.pgas)}${s.lag === null ? '' : int(s.lag)}${s.carried_by ? link(s.carried_by) : ''}${s.payout_wei ? ignFromWei(s.payout_wei, 6) + ' IGN' : ''}${s.captured ? `${s.captured.native_verified === true ? 'verify (node: verified)' : s.captured.native_verified === false ? 'verify (node: NOT verified)' : 'verify (checks)'}` : (s.state === 'paid' || s.state === 'verified' ? `record only` : '')}

Proof: verify this block's proof hashes the captured bytes in your browser against the record the chain carries and shows the native verifier's verdict; "record only" means the bytes of that shard were not captured.

` : (b.chain ? '
No shard plan recorded (the proving feed was not reading this block).
' : '')); const cp = j.checkpoint, certs = j.certificates || []; $('fin-eyebrow').textContent = cp ? `checkpoint ${cp.index}` : (certs.length ? 'carries a certificate' : 'not a checkpoint'); $('finality').innerHTML = (cp ? `
${dl([['State', `${esc(cp.state)}`], ['Signed weight', `${int(cp.signed)} of ${int(cp.total)} (${(cp.fraction_total * 100).toFixed(1)}% of all weight, ${(cp.fraction_active * 100).toFixed(1)}% of active)`], ['Votes', `${int(cp.votes)} of ${int(cp.voters)} voters`], cp.locked_at ? ['Locked', utc(new Date(cp.locked_at).getTime())] : null])}
` : '') + diff --git a/site/build.mjs b/site/build.mjs index 7760c3fb7..9df1b5935 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -313,8 +313,24 @@ const downloads = await loadDownloads(); built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`); const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''], - // the DAG explorer (5 Oct 2026): /explorer, /block/ and /address/ (vercel.json rewrites the last two) - ['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']]; + // the DAG explorer (5 Oct 2026): /explorer, /block/ and /address/ (vercel.json rewrites the last two); + // /proof/ (6 Oct 2026): verify a block's shard proof in the browser + ['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live'], ['proof.html', 'live']]; + +// The pinned SP1 guests (proving/igneum-prove/elf/manifest.json, proving/README.md) as a module the verify API and +// /proof/ import: the program ids and verifying-key hashes a captured proof is shown beside. Regenerated on +// every build so the site never names a stale pin; the committed copy is what runs between builds. +{ + const manifestPath = join(repo, 'proving', 'igneum-prove', 'elf', 'manifest.json'); + if (existsSync(manifestPath)) { + const m = JSON.parse(readFileSync(manifestPath, 'utf8')); + const pick = g => ({ program_id: g.program_id, vk_sha256: g.vk_sha256, elf_sha256: g.elf_sha256, elf_bytes: g.elf_bytes }); + const pinned = { format: m.format, pinned_at: m.pinned_at, sp1_crate_version: m.sp1_crate_version, sp1_circuit_version: m.sp1_circuit_version, shard: pick(m.shard), aggregator: pick(m.aggregator) }; + const out = `// GENERATED by site/build.mjs from proving/igneum-prove/elf/manifest.json (the pinned SP1 guests, proving/README.md):\n// the program ids and verifying-key hashes /api/verify and /proof/ show beside a captured proof. Do not edit; re-run the build.\nexport const PINNED = ${JSON.stringify(pinned, null, 2)};\n`; + const target = join(here, 'lib', 'pinned-guests.mjs'); + if (!existsSync(target) || readFileSync(target, 'utf8') !== out) { writeFileSync(target, out); built.push('lib/pinned-guests.mjs'); } + } else console.warn('proving/igneum-prove/elf/manifest.json not found; site/lib/pinned-guests.mjs kept as committed'); +} for (const [file, active] of PAGES) { const p = join(here, file); if (!existsSync(p)) throw new Error(`missing page ${file}`); diff --git a/site/explorer.html b/site/explorer.html index d5b831119..b4215088a 100644 --- a/site/explorer.html +++ b/site/explorer.html @@ -221,12 +221,12 @@ main{padding-bottom:var(--sec)}

Latest blocks

newest first, every 5 s
- - + +
BlockNumberDAABlue scoreMinerTxsProof recordsTime
Loading.
BlockNumberStateDAABlue scoreMinerTxsProof recordsTime
Loading.
chain blockblue, merged and paidred, excludedpending, not merged yet★ locked checkpoint
-

Number is the chain block number, the same number the EVM reports; a block off the selected chain has none. Miner is the payout address the coinbase names. Proof records is how many shard proofs the block carries for earlier blocks. Click a row for the block.

+

Number is the chain block number, the same number the EVM reports; a block off the selected chain has none. State is one word per block, never stronger than the chain's own: pending (not merged yet), included (merged, or a chain block the executor has not run), executed, proven (every shard's proof carried and paid), finalised (at or below the latest locked checkpoint; "finality not active" while finality is paused). A proven or finalised block's state links to its proof page. Miner is the payout address the coinbase names. Proof records is how many shard proofs the block carries for earlier blocks. Click a row for the block.

@@ -235,6 +235,7 @@ main{padding-bottom:var(--sec)}
/api/stats
network hash rate, difficulty, block time, block reward now, blocks per day, DAA, blue score, height, last block, chain id, version
/api/supply
circulating by the emission rule, the 4,000,000,000 cap, the halving table, the 30-day ramp, the observer's coinbase check
/api/explorer
?blocks=N, ?block=hash, ?height=N, ?address=addr, ?search=q
+
/api/verify
?block=hash: the shard records and captured proofs with the native verdict; &shard=N&key=k&bytes=1: the proof bytes; ?latest=1

Reward and supply come from the emission rule of the specification (section 2.5) at the node's DAA score; the observer checks the chain's coinbase sums against it every hour and the result is in /api/supply under check.

@@ -302,17 +303,24 @@ $('search').addEventListener('submit', async e => { if (j.ok && j.href) location.href = j.href; else $('msg').textContent = j.error || 'Not found.'; } catch { $('msg').textContent = 'The API did not answer.'; } }); +const stateCls = s => s === 'finalised' || s === 'proven' ? 'ok' : s === 'executed' ? 'hot' : ''; +function stateCell(b) { + if (!b.state) return ''; + const chip = `${esc(b.state)}`; + const fail = b.failure ? `${esc(b.failure)}` : ''; + return (b.chain && (b.state === 'proven' || b.state === 'finalised' || b.state === 'finality not active') ? `${chip}` : chip) + fail; +} function rowHtml(b) { const sw = b.chain ? 'chain' : b.color; const miner = b.evm_miner ? `${esc(shortHash(b.evm_miner, 6, 4))}` : (b.miner ? `${esc(shortHash(b.miner, 14, 4))}` : '' + esc(b.miner_id || '?') + ''); - return `${esc(shortHash(b.hash, 10, 6))}${b.locked ? ' ★' : ''}${b.number === null ? 'off chain' : int(b.number)}${int(b.daa)}${int(b.blue_score)}${miner}${esc(b.miner_id || '')}${b.tx_count === null ? 'n/a' : b.tx_count}${b.proof_records === null ? 'n/a' : b.proof_records}${rel(vnow() - b.ts)}`; + return `${esc(shortHash(b.hash, 10, 6))}${b.locked ? ' ★' : ''}${b.number === null ? 'off chain' : int(b.number)}${stateCell(b)}${int(b.daa)}${int(b.blue_score)}${miner}${esc(b.miner_id || '')}${b.tx_count === null ? 'n/a' : b.tx_count}${b.proof_records === null ? 'n/a' : b.proof_records}${rel(vnow() - b.ts)}`; } async function loadBlocks(before) { const r = await fetch('/api/explorer?blocks=50' + (before ? '&before=' + before : '')); const j = await r.json(); if (!j.ok) throw new Error(j.error); serverNow = new Date(j.now).getTime(); serverAt = performance.now(); const html = j.blocks.map(rowHtml).join(''); - if (before) $('blocks').insertAdjacentHTML('beforeend', html); else $('blocks').innerHTML = html || 'No blocks in the last 24 hours.'; + if (before) $('blocks').insertAdjacentHTML('beforeend', html); else $('blocks').innerHTML = html || 'No blocks in the last 24 hours.'; if (j.blocks.length) oldest = j.blocks[j.blocks.length - 1].rx; $('blk-eyebrow').textContent = (j.stale ? 'observer offline, last known' : 'newest first, every 5 s') + (j.network ? ', ' + j.network : ''); } @@ -340,7 +348,7 @@ async function loadStats() { } const timer = setInterval(() => { loadBlocks().catch(() => {}); }, 5000); setInterval(() => { loadStats().catch(() => {}); }, 10000); -loadBlocks().catch(e => { $('blocks').innerHTML = `${esc(e.message)}`; }); +loadBlocks().catch(e => { $('blocks').innerHTML = `${esc(e.message)}`; }); loadStats().catch(() => {}); diff --git a/site/lib/pinned-guests.mjs b/site/lib/pinned-guests.mjs new file mode 100644 index 000000000..a180d31c5 --- /dev/null +++ b/site/lib/pinned-guests.mjs @@ -0,0 +1,20 @@ +// GENERATED by site/build.mjs from proving/igneum-prove/elf/manifest.json (the pinned SP1 guests, proving/README.md): +// the program ids and verifying-key hashes /api/verify and /proof/ show beside a captured proof. Do not edit; re-run the build. +export const PINNED = { + "format": "igneum-prove-elf-manifest-v1", + "pinned_at": "2026-10-05T16:20:38Z", + "sp1_crate_version": "6.8.1", + "sp1_circuit_version": "v6.1.0", + "shard": { + "program_id": "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a", + "vk_sha256": "0x8b4da5bff86d963f4210a78e5d800a1cd00ab41b158f6962f4ac009edc249d4c", + "elf_sha256": "0x150f4c05a2951fc56174a87089707a030b18df8fbe7e053a66459edb83053083", + "elf_bytes": 2832504 + }, + "aggregator": { + "program_id": "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896", + "vk_sha256": "0xad17bc1ae5be816554dbb13cb5b4d242678adfb8e1a4f7247ceb8b5ba9001b9f", + "elf_sha256": "0x143d9c243dd12e87e90be71f6b8cd42353e513bf8ce78903ef6f972f1bc9aa7b", + "elf_bytes": 319744 + } +}; diff --git a/site/lib/proof.mjs b/site/lib/proof.mjs new file mode 100644 index 000000000..c20281e56 --- /dev/null +++ b/site/lib/proof.mjs @@ -0,0 +1,201 @@ +// Igneum proof checks that run anywhere: in the browser on /proof/, 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 } }, sp1_version: String, tee_proof: Option> }` + * (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)`; +} diff --git a/site/lib/proof.test.mjs b/site/lib/proof.test.mjs new file mode 100644 index 000000000..87cdc1b82 --- /dev/null +++ b/site/lib/proof.test.mjs @@ -0,0 +1,119 @@ +// The proof checks a browser runs on /proof/, against a real tail. TAIL is the last 360 bytes of the compressed +// shard proof of devnet block 59199 shard 0 (made 5 October 2026 by the Mac's host before the guests were pinned; +// 1,272,897 bytes). The host printed for it: statement 0xdad25faf2aa6b9cfafa200562ff2e511677eae521af3d232bb3a256bfbd32a35, +// block 59199 shard 0 prover 0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8 (igneum-prove-host --mode verify, 6 October +// 2026), so the keccak of the parsed public values must reproduce that statement. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { parseProofTail, decodeStatement, statementOf, proofHashOf, checkProof, blockState, txState, lotteryLine, fromHex, toHex, keccak256, STATEMENT_LEN } from './proof.mjs'; +import { keccak256 as keccakEth } from './eth.mjs'; + +const TAIL = '06d52e065d50a6d1474801000000000000000000000000116f000000000000e73fe10881755e4dd2e316bad57c210e124546de87c3c66b54f285f54133ba82aead00000000000000000000000038046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d538046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f4501340178cce1f22de96fd23db5c21b3cd245b3cc061d0811859d047e23ee993b4d7c5484ab50c80c12ec38af13679fcafcc04de483849ee9e5afe1b7844f256e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421000000000000000000000000000000000000000000000000dd442fcbb964a3afdc90d49b408e8dd296fa86e8060000000000000076362e312e3000'; +const STATEMENT = '0xdad25faf2aa6b9cfafa200562ff2e511677eae521af3d232bb3a256bfbd32a35'; +const BLOCK = 'e10881755e4dd2e316bad57c210e124546de87c3c66b54f285f54133ba82aead'; + +// a stand-in proof file: 2,000 bytes of deterministic filler where the STARK would be, then the real tail +function fakeProof() { + const head = new Uint8Array(2000); for (let i = 0; i < head.length; i++) head[i] = (i * 7 + 3) & 0xff; + const tail = fromHex(TAIL); const out = new Uint8Array(head.length + tail.length); out.set(head); out.set(tail, head.length); return out; +} + +test('keccak here equals the faucet library\'s keccak', () => { + for (const s of ['', 'abc', 'x'.repeat(135), 'y'.repeat(136), 'z'.repeat(500)]) { + const b = new TextEncoder().encode(s); + assert.equal(toHex(keccak256(b)), toHex(keccakEth(b))); + } + assert.equal(toHex(keccak256(new Uint8Array(0))), '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'); +}); + +test('the tail parse finds the 328 public values and the SP1 version', () => { + const t = parseProofTail(fakeProof()); + assert.equal(t.publicValues.length, STATEMENT_LEN); + assert.equal(t.version, 'v6.1.0'); + assert.equal(t.proofEnd, 2000 + 9); // the 9 bytes of the real proof's end that TAIL starts with +}); + +test('the public values hash to the statement the host printed, and decode to block 59199 shard 0', () => { + const t = parseProofTail(fakeProof()); + assert.equal(statementOf(t.publicValues), STATEMENT); + const s = decodeStatement(t.publicValues); + assert.equal(s.chain_id, 4463); + assert.equal(s.number, 59199); + assert.equal(s.block_hash, BLOCK); + assert.equal(s.shard, 0); + assert.equal(s.tx_start, 0); + assert.equal(s.tx_count, 0); + assert.equal(s.prover, '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8'); + assert.equal(s.gas_used, 0); assert.equal(s.pgas_used, 0); assert.equal(s.executed, 0); assert.equal(s.skipped, 0); + assert.notEqual(s.pre_root, s.post_root); // no transactions, but the segment's rewards and payouts move the state +}); + +test('a file that is not an SP1 proof is refused with a reason', () => { + assert.throws(() => parseProofTail(new Uint8Array(100)), /too short/); + const b = fakeProof(); b[b.length - 1] = 1; + assert.throws(() => parseProofTail(b), /TEE/); + const c = new Uint8Array(600); c[599] = 0; + assert.throws(() => parseProofTail(c), /no SP1 version/); + assert.throws(() => decodeStatement(new Uint8Array(10)), /328 bytes/); +}); + +test('checkProof passes the genuine record and fails each tampered one', async () => { + const bytes = fakeProof(); + const ph = '0x' + createHash('sha256').update(bytes).digest('hex'); + assert.equal(await proofHashOf(bytes), ph); + const record = { block_hash: BLOCK, number: 59199, shard: 0, statement: STATEMENT, proof_hash: ph, payout: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8' }; + const ok = await checkProof(bytes, record); + assert.equal(ok.ok, true); assert.equal(ok.checks.length, 3); assert.equal(ok.statement.number, 59199); assert.equal(ok.bytes, bytes.length); + // one byte of the STARK flipped: the proof hash no longer matches, the statement still does + const flipped = new Uint8Array(bytes); flipped[100] ^= 1; + const r1 = await checkProof(flipped, record); + assert.equal(r1.ok, false); assert.equal(r1.checks[0].ok, false); assert.equal(r1.checks[1].ok, true); + // a record for another block: the statement and the fields disagree + const r2 = await checkProof(bytes, { ...record, block_hash: 'ab'.repeat(32), statement: '0x' + '11'.repeat(32) }); + assert.equal(r2.ok, false); assert.equal(r2.checks[0].ok, true); assert.equal(r2.checks[1].ok, false); assert.equal(r2.checks[2].ok, false); + // a public value altered inside the file: statement mismatch + const pv = new Uint8Array(bytes); pv[bytes.length - 1 - 6 - 8 - 1] ^= 1; + const r3 = await checkProof(pv, { ...record, proof_hash: await proofHashOf(pv) }); + assert.equal(r3.ok, false); assert.equal(r3.checks[1].ok, false); +}); + +test('blockState: the one word per block, never stronger than the chain', () => { + const chain = (x) => blockState({ chain: true, color: 'blue', number: 10, blue_score: 100, shards: ['paid'], locked_blue_score: 200, finality_active: true, ...x }); + assert.deepEqual(chain({}).state, 'finalised'); + assert.equal(chain({ finality_active: false }).state, 'finality not active'); + assert.equal(chain({ locked_blue_score: 50 }).state, 'proven'); + assert.equal(chain({ locked_blue_score: 50, shards: ['paid', 'planned'] }).state, 'executed'); + assert.match(chain({ locked_blue_score: 50, shards: ['paid', 'planned'] }).note, /1 of 2 shards proven/); + assert.equal(chain({ locked_blue_score: null, shards: [] }).state, 'executed'); + assert.equal(chain({ number: null }).state, 'included'); + assert.equal(chain({ locked_blue_score: 50, finality_active: false }).failure, 'finality paused'); + assert.equal(chain({ locked_blue_score: 50, finality_active: true }).failure, null); + const merged = blockState({ chain: false, color: 'blue', number: null, blue_score: 100, shards: [], locked_blue_score: 200, finality_active: true, merged_locked: null }); + assert.equal(merged.state, 'included'); + assert.equal(blockState({ chain: false, color: 'blue', number: null, merged_locked: true, finality_active: true }).state, 'finalised'); + assert.equal(blockState({ chain: false, color: 'red', number: null }).state, 'included'); + assert.match(blockState({ chain: false, color: 'red', number: null }).note, /red/); + assert.equal(blockState({ chain: false, color: 'pending', number: null }).state, 'pending'); + assert.equal(blockState({ chain: false, color: 'pending', number: 12 }).failure, null); + assert.match(blockState({ chain: false, color: 'pending', number: 12 }).note, /left the selected chain at number 12/); + assert.equal(blockState({ chain: false, color: 'blue', number: 12 }).state, 'included'); + assert.match(blockState({ chain: false, color: 'blue', number: 12 }).note, /reorg.*merged blue/); +}); + +test('txState: the node\'s word when it has one, else the block\'s', () => { + assert.deepEqual(txState({ state: 'proven', failure: null }, null, true), { state: 'proven', failure: null, source: 'node' }); + assert.deepEqual(txState({ state: 'included', failure: 'skipped' }, null, false).failure, 'skipped'); + assert.equal(txState(null, { state: 'finalised', failure: null }, true).state, 'finalised'); + assert.equal(txState(null, { state: 'finalised', failure: null }, false).state, 'included'); + assert.equal(txState(null, { state: 'pending', failure: null }, true).state, 'included'); + assert.equal(txState({ executed: true }, { state: 'executed' }, true).source, 'block'); // a pre-P17 node has flags, no state +}); + +test('lotteryLine names the live class and generator, never a fixed one', () => { + assert.match(lotteryLine({ program_class: 3, next_program_class: 3, epoch_index: 55 }), /class v3 \/ generator 3 \(epoch 55;/); + assert.match(lotteryLine({ program_class: 2, next_program_class: 3, epoch_index: 42 }), /class v2 \/ generator 2 \(epoch 42, next epoch class v3;/); + assert.match(lotteryLine({ program_class: 4, next_program_class: 4, epoch_index: 900 }), /class v4 \/ generator 4/); + assert.match(lotteryLine(null), /unknown until the observer reads/); + assert.doesNotMatch(lotteryLine(null), /generator v2/); +}); diff --git a/site/proof.html b/site/proof.html new file mode 100644 index 000000000..71c9c0c73 --- /dev/null +++ b/site/proof.html @@ -0,0 +1,385 @@ + + + + + +Verify an Igneum proof + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Explorer · verify a proof
+

Verify a block's proof

+

Paste a chain block hash or number. This page fetches the shard record the chain carries and the proof bytes the node handed out, hashes the bytes in your browser against the signed record, parses the public values out of the proof and checks them against the statement and the block. The SP1 proof itself is verified by this site's node; the in-browser verifier is coming.

+ +
+
+ + +
+ + + + + + diff --git a/site/vercel.json b/site/vercel.json index d9bb104ac..7f09fe45b 100644 --- a/site/vercel.json +++ b/site/vercel.json @@ -2,7 +2,7 @@ "cleanUrls": true, "trailingSlash": false, "redirects": [{"source": "/ledger", "destination": "/", "permanent": false}], - "rewrites": [{"source": "/block/:id", "destination": "/block"}, {"source": "/address/:addr", "destination": "/address"}], + "rewrites": [{"source": "/block/:id", "destination": "/block"}, {"source": "/address/:addr", "destination": "/address"}, {"source": "/proof/:id", "destination": "/proof"}], "headers": [ {"source": "/(.*)", "headers": [{"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "X-Frame-Options", "value": "DENY"}, {"key": "Referrer-Policy", "value": "strict-origin-when-cross-origin"}, {"key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload"}]}, {"source": "/fonts/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}]}, @@ -12,6 +12,7 @@ {"source": "/api/live", "headers": [{"key": "Cache-Control", "value": "public, max-age=1"}]}, {"source": "/api/stats", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]}, {"source": "/api/supply", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]}, - {"source": "/api/explorer", "headers": [{"key": "Cache-Control", "value": "public, max-age=5, s-maxage=5"}]} + {"source": "/api/explorer", "headers": [{"key": "Cache-Control", "value": "public, max-age=5, s-maxage=5"}]}, + {"source": "/api/verify", "headers": [{"key": "Cache-Control", "value": "public, max-age=5, s-maxage=5"}]} ] } diff --git a/tools/observer/README.md b/tools/observer/README.md index 466fe10ed..dc368b126 100644 --- a/tools/observer/README.md +++ b/tools/observer/README.md @@ -16,6 +16,10 @@ Environment, every value optional: | `DATABASE_URL` | read from `~/.config/igneum/env` | Neon connection string. Never commit it. | | `LIVE_RETAIN_HOURS` | `24` | Hours of blocks kept in `live_blocks`. Older rows are deleted once a minute. | | `LIVE_TABLE_PREFIX` | empty | Prefix for every table name, so a test observer against a test network can write `fintest_live_*` without touching the site. | +| `IGNEUM_PROOF_VERIFIER` | the Mac app bundle's `igneum-prove-host` when it exists, else none | Path to `igneum-prove-host`, the native SP1 verifier run on each captured proof (`--mode verify --proof --statement 0x..`, the pinned key, 0.2 s of key setup and 0.03 s of verify per proof; `docs/bench-log.md`). Its pinned ids are logged at start. Empty = captured proofs get the browser checks only. | +| `PROOF_CAPTURE_EVERY_MS` | `300000` | One pool proof is captured every 5 minutes: fetched with `igneum_getProofBytes [number, shard, keyHash]` while the node still holds it (600 chain blocks, about 10 minutes after the proven block), checked as a browser would (SHA-256 against the record's `proof_hash`, keccak of the public values against its `statement`, the decoded statement against the block), verified natively, written to `live_proof_bytes`. A compressed shard proof is 1,272,897 bytes, so every proof at one chain block per second would be 110 GB a day; the sample is the size that fits. | +| `PROOF_BYTES_KEEP` | `50` | How many captures keep their bytes (about 64 MB in Neon). Older rows keep the record, the checks and the verdict. | +| `IGNEUM_TEMPLATE_ADDRESS` | the newest block's coinbase address | The pay address `getBlockTemplate` is asked with every 10 s for the epoch line (`powEpoch`: program class, generator, epoch index), written to `live_state.pow_epoch` for `/api/stats` (reviewer C46). | | `IGNEUM_EVM_RPC` | `http://127.0.0.1:26800` | The execution layer's JSON-RPC (http) of a node on the proving build, for `igneum_getShardPlan`, `igneum_getProofRecords` and `igneum_getProvingStatus`. On the Mac that port is the Igneum Miner app's node, so an app restart (an OTA at its slot minute, a quit) blips the proving feed with `fetch failed` lines until it is back; the observer's own node (`observer-v4`) has no `--evm-rpclisten`. The default is the Mac app's node; the observer node itself has no EVM listener yet (start it with `--evm-rpclisten=127.0.0.1:` once it runs the proving build and point this at it). A node without the RPCs (method not found) gives `proving = {supported: false}`, rechecked every 5 minutes; an unreachable endpoint is retried every 20 s. | A node started by another tool may listen on gRPC only. Then run your own non-mining peer with a JSON listener, on ports that do not clash with the devnet's (gRPC 26610, P2P 26611): @@ -48,6 +52,8 @@ Created on start if missing. | `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%), `checkpoint_locked`. | | `live_checkpoints` | one per checkpoint index, kept 7 days | `index`, `hash`, `blue_score`, `daa_score`, `state` (proposed, certified, locked), `signed_weight`, `active_weight`, `total_weight`, `fraction_active`, `fraction_total`, `votes_seen`, `voters`, `aggregators` (key hashes whose sortition proof made them aggregators), `locked_at`, `first_seen_at`, `updated_at`. | | `live_proofs` | one per planned shard of a chain block, kept `LIVE_RETAIN_HOURS` | `block_hash`, `shard`, `shards` (in the plan), `block_number`, `block_daa`, `block_ts`, `pgas`, `state` (planned, proving, verified, paid), `prover` (id8), `verified`, `carried_by`, `carrier_number`, `carrier_daa`, `lag_daa`, `payout_wei`, `received_at`, `updated_at`. Primary key `(block_hash, shard)`, index on `received_at`. | +| `live_proof_bytes` | one per captured shard proof, kept `LIVE_RETAIN_HOURS` | `block_hash`, `shard`, `key_hash` (the prover's vote key hash), `number`, `prover` (id8), `payout`, `statement`, `proof_hash`, `proof` (bytea, the newest `PROOF_BYTES_KEEP` only), `proof_bytes`, `public_values` (the 328-byte shard statement parsed from the proof's tail), `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` (the id the proof names), `native_pinned_id`, `native_ours`, `native_note`, `verifier`, `received_at`. Primary key `(block_hash, shard, key_hash)`. `live_proofs` also gained `key_hash`, `payout`, `statement`, `proof_hash` (the record the chain carries per shard). | +| `live_state.pow_epoch` | jsonb, refreshed every 10 s | `epoch_index`, `epoch_seed`, `epoch_blocks`, `boundary_daa`, `virtual_daa`, `program_class` (2 or 3, later 4: the generator version of the running epoch), `next_program_class`, `class_v3_activation_daa`, `era_index`, `era_seed`, `dataset_log2`, `day_index`, `day_ms`, `read_at`, `source`. | | `live_state.proving` | jsonb, updated every 2 s | `supported` (false with `reason` when the node has no proving RPCs or the endpoint is unreachable), `active`, `activation_daa`, `tip_daa`, `verifier`, `pool` (entries, pending, verified, failed), `paid_shards_total`, `shard_budget_pgas`, `blocks_10m`, `blocks_fully_proven_10m`, `shards_proven_10m`, `shards_paid_10m`, `median_proof_lag_s` (median `lag_daa` of the last 10 minutes; the devnet targets one DAA step per second), `provers_10m`, `open_blocks`, `pending_plans`, `evm_rpc`. | | `live_state.finality` | jsonb, updated every 2 s | `params`, `chain_id`, `next_index`, `finality_active`, `latest_locked_index`, `latest_locked_hash`, `latest_locked_blue_score`, `weights` (`total_weight`, `active_weight`, `voters`, `keys[]` with `id`, `blocks`, `voter`, `participation`, `stripped_until_daa`, `revealed`). | @@ -57,4 +63,4 @@ Created on start if missing. ## Reading it -`site/api/stats.mjs`, `site/api/supply.mjs` and `site/api/explorer.mjs` serve `/api/stats`, `/api/supply` and `/api/explorer` (docs/api/public-stats.md). `site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s. +`site/api/stats.mjs`, `site/api/supply.mjs`, `site/api/explorer.mjs` and `site/api/verify.mjs` serve `/api/stats`, `/api/supply`, `/api/explorer` and `/api/verify` (docs/api/public-stats.md). `site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s. diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs index 9cc45255d..9645c6dfc 100644 --- a/tools/observer/observer.mjs +++ b/tools/observer/observer.mjs @@ -11,9 +11,24 @@ // LIVE_TABLE_PREFIX prefix for every table name default '' (a test observer can write fintest_live_* instead) // IGNEUM_EVM_RPC execution-layer JSON-RPC (http) of a node on the proving build, for the shard plans and proof // records default http://127.0.0.1:26800 (the Mac app's node; the observer node has no EVM listener yet) +// IGNEUM_PROOF_VERIFIER path to igneum-prove-host (the native SP1 verifier, --mode verify with the pinned key) for +// the captured proofs; default: the Mac app bundle's host when it exists, else none +// PROOF_CAPTURE_EVERY_MS how often one shard proof's bytes are fetched from the node's pool, checked, verified and +// stored for /proof/ default 300000 (one every 5 minutes; a proof is 1.27 MB) +// PROOF_BYTES_KEEP how many captured proofs keep their bytes (older rows keep the record, the checks and the +// verdict, their bytes are dropped) default 50 (about 64 MB in Neon) +// IGNEUM_TEMPLATE_ADDRESS the pay address getBlockTemplate is asked with for the epoch line (class, generator); +// default: the newest block's coinbase address // // Tables (created on start if missing): live_blocks, live_state, live_events, live_checkpoints, live_certificates, -// live_proofs. See README.md. +// live_proofs, live_proof_bytes. See README.md. +// Verify a proof (6 October 2026, docs/plans/explorer.md): a sample of the pool's shard proofs is captured through +// igneum_getProofBytes while the node still holds them, checked as a browser would (site/lib/proof.mjs: SHA-256 +// against the record's proof_hash, keccak of the public values against its statement, the statement's fields +// against the block), verified by the native SP1 verifier when one is configured, and written to live_proof_bytes +// for /api/verify and /proof/. Every live_proofs row also carries the record's key hash, payout, statement +// and proof hash. live_state.pow_epoch is getBlockTemplate's epoch line (program class, generator, epoch index) +// read every 10 s, so /api/stats names the live class instead of a fixed generator (reviewer C46). // Proving v0 (4 Oct 2026, spec 7.7): every chain block's shard plan is read over the EVM RPC (igneum_getShardPlan) as // the block joins the selected chain, one live_proofs row per shard (planned); the proof records of the chain blocks // of the last 10 minutes are polled in rotation (igneum_getProofRecords, 80 blocks per 2 s tick, four calls in flight) and move a shard to @@ -44,14 +59,20 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; +import { existsSync } from 'node:fs'; import { blockSubsidy } from '../../site/lib/emission.mjs'; import { keccak256 } from '../../site/lib/eth.mjs'; +import { captureProof, verifierIds, MAC_APP_HOST } from './proof-capture.mjs'; const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610'; const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24); const T = (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, ''); -const TB = `${T}live_blocks`, TS = `${T}live_state`, TE = `${T}live_events`, TC = `${T}live_checkpoints`, TX = `${T}live_certificates`, TP = `${T}live_proofs`; +const TB = `${T}live_blocks`, TS = `${T}live_state`, TE = `${T}live_events`, TC = `${T}live_checkpoints`, TX = `${T}live_certificates`, TP = `${T}live_proofs`, TV = `${T}live_proof_bytes`; const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:26800'; +const VERIFIER = process.env.IGNEUM_PROOF_VERIFIER !== undefined ? process.env.IGNEUM_PROOF_VERIFIER : (existsSync(MAC_APP_HOST) ? MAC_APP_HOST : ''); +const PROOF_CAPTURE_EVERY_MS = Number(process.env.PROOF_CAPTURE_EVERY_MS || 5 * 60_000); +const PROOF_BYTES_KEEP = Math.max(1, Number(process.env.PROOF_BYTES_KEEP || 50)); +const TEMPLATE_ADDRESS = process.env.IGNEUM_TEMPLATE_ADDRESS || ''; const STATE_EVERY_MS = 2000; const FLUSH_EVERY_MS = 500; const PRUNE_EVERY_MS = 60_000; @@ -125,6 +146,8 @@ async function setupSchema() { `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS queue_depth int`, // Proving v0 (4 Oct 2026, additive): {supported, active, activation_daa, tip_daa, blocks_fully_proven_10m, ...} `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS proving jsonb`, + // The epoch line (6 Oct 2026, reviewer C46): {epoch_index, program_class, next_program_class, ...} from getBlockTemplate + `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS pow_epoch jsonb`, // Explorer: this process's RPC calls per minute, and the hourly coinbase-versus-rule comparison `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS rpc_load jsonb`, `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS supply_check jsonb`, @@ -150,6 +173,42 @@ async function setupSchema() { updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (block_hash, shard))`, `CREATE INDEX IF NOT EXISTS ${TP}_received_at ON ${TP} (received_at)`, + // the record the chain carries for the shard (6 Oct 2026, additive): vote key hash, payout, statement, proof hash + `ALTER TABLE ${TP} ADD COLUMN IF NOT EXISTS key_hash text`, + `ALTER TABLE ${TP} ADD COLUMN IF NOT EXISTS payout text`, + `ALTER TABLE ${TP} ADD COLUMN IF NOT EXISTS statement text`, + `ALTER TABLE ${TP} ADD COLUMN IF NOT EXISTS proof_hash text`, + // Captured proofs (6 Oct 2026, docs/plans/explorer.md "Verify a proof"): a sample of the pool's shard proofs with + // the browser checks and the native verifier's verdict; `proof` is dropped beyond PROOF_BYTES_KEEP rows + `CREATE TABLE IF NOT EXISTS ${TV} ( + block_hash text NOT NULL, + shard int NOT NULL, + key_hash text NOT NULL, + number bigint, + prover text, + payout text, + statement text, + proof_hash text, + proof bytea, + proof_bytes int, + public_values bytea, + sp1_version text, + node_verified boolean, + proof_hash_check boolean, + statement_check boolean, + fields_check boolean, + native_verified boolean, + native_verify_ms double precision, + native_setup_ms double precision, + native_total_ms double precision, + native_program_id text, + native_pinned_id text, + native_ours boolean, + native_note text, + verifier text, + received_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (block_hash, shard, key_hash))`, + `CREATE INDEX IF NOT EXISTS ${TV}_received_at ON ${TV} (received_at)`, `CREATE TABLE IF NOT EXISTS ${TE} ( id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), @@ -461,6 +520,7 @@ function onBlock(block) { const now = Date.now(); const parents = (h.parentsByLevel && h.parentsByLevel[0]) || []; const miner = minerFromCoinbase(block, addressPrefix); + if (miner && miner.address) lastMinerAddress = miner.address; // the pay address the epoch line's getBlockTemplate is asked with for (const cert of certificatesIn(block)) pendingCerts.push({ cert, carrier: h.hash }); const vk = h.voteKeyHash || null; const vd = block.verboseData; @@ -613,22 +673,24 @@ async function tickInner(rpc) { // proving v0: activation state and the 10-minute counts (a node without the RPCs gives {supported: false}) await provingTick(); const proving = await provingState(); + // the epoch line (class, generator) every 10 s; the newest value is written every tick + const powEpoch = await epochTick(rpc); try { await sql(`INSERT INTO ${TS} (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool, - node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving, rpc_load, supply_check) - VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb, $17::jsonb, $18::jsonb) + node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving, rpc_load, supply_check, pow_epoch) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb, $17::jsonb, $18::jsonb, $19::jsonb) ON CONFLICT (id) DO UPDATE SET block_count = EXCLUDED.block_count, header_count = EXCLUDED.header_count, blue_score = EXCLUDED.blue_score, difficulty = EXCLUDED.difficulty, hashes_per_second_estimate = EXCLUDED.hashes_per_second_estimate, peers = EXCLUDED.peers, mempool = EXCLUDED.mempool, node_version = EXCLUDED.node_version, network = EXCLUDED.network, blocks_60s = EXCLUDED.blocks_60s, blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now(), finality = EXCLUDED.finality, observer_lag_s = EXCLUDED.observer_lag_s, queue_depth = EXCLUDED.queue_depth, proving = EXCLUDED.proving, - rpc_load = EXCLUDED.rpc_load, supply_check = EXCLUDED.supply_check`, + rpc_load = EXCLUDED.rpc_load, supply_check = EXCLUDED.supply_check, pow_epoch = EXCLUDED.pow_epoch`, [dag.blockCount, dag.headerCount, (await rpc.call('getSinkBlueScore', {}).catch(() => ({}))).blueScore ?? null, diff, hps ? hps.networkHashesPerSecond : localHashesPerSecond(), peers.length, info.mempoolSize ?? 0, nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString(), finality ? JSON.stringify(finality) : null, lagS === null ? null : Math.round(lagS * 10) / 10, queueDepth, JSON.stringify(proving), - JSON.stringify(rpcLoadState()), lastSupplyCheck ? JSON.stringify(lastSupplyCheck) : null]); + JSON.stringify(rpcLoadState()), lastSupplyCheck ? JSON.stringify(lastSupplyCheck) : null, powEpoch ? JSON.stringify(powEpoch) : null]); } catch (e) { log('state write failed', e.message); } } @@ -932,13 +994,87 @@ async function flushPlans() { // Per shard: paid (a carrying segment paid it) > verified (the proof verified in the pool, or the record carried and // checked by consensus, which is what happens before activation) > proving (a record in the pool) > planned. function shardStates(block, r) { - const n = block.shards.length, states = block.shards.map(() => 'planned'), out = states.map(() => ({ prover: null, verified: null, carrier: null, carrierNumber: null, payout: null })); - for (const e of r.pool || []) { const i = e.shard; if (i >= n) continue; const rank = e.verified === true ? 2 : 1; if (rank > ['planned', 'proving', 'verified', 'paid'].indexOf(states[i])) { states[i] = rank === 2 ? 'verified' : 'proving'; out[i] = { ...out[i], prover: short(String(e.keyHash || '').replace(/^0x/, '')), verified: e.verified, carrier: e.includedIn ? String(e.includedIn).replace(/^0x/, '') : null }; } } - for (const c of r.carried || []) { const i = c.shard; if (i >= n || !c.valid) continue; if (states[i] !== 'paid') { states[i] = 'verified'; out[i] = { ...out[i], prover: short(String(c.keyHash || '').replace(/^0x/, '')), carrier: String(c.carrier || '').replace(/^0x/, ''), carrierNumber: hx(c.carrierNumber) }; } } - (r.paid || []).forEach((p, i) => { if (!p || i >= n) return; states[i] = 'paid'; out[i] = { ...out[i], prover: short(String(p.keyHash || '').replace(/^0x/, '')), carrierNumber: hx(p.carrierNumber), payout: weiStr(p.wei) }; }); + const n = block.shards.length, states = block.shards.map(() => 'planned'), out = states.map(() => ({ prover: null, verified: null, carrier: null, carrierNumber: null, payout: null, keyHash: null, payoutAddress: null, statement: null, proofHash: null })); + const lo = v => (v === null || v === undefined ? null : String(v).toLowerCase()); + const record = (x) => ({ keyHash: lo(String(x.keyHash || '').replace(/^0x/, '')), payoutAddress: lo(x.payout), statement: lo(x.statement), proofHash: lo(x.proofHash) }); + for (const e of r.pool || []) { const i = e.shard; if (i >= n) continue; const rank = e.verified === true ? 2 : 1; if (rank > ['planned', 'proving', 'verified', 'paid'].indexOf(states[i])) { states[i] = rank === 2 ? 'verified' : 'proving'; out[i] = { ...out[i], ...record(e), prover: short(String(e.keyHash || '').replace(/^0x/, '')), verified: e.verified, carrier: e.includedIn ? String(e.includedIn).replace(/^0x/, '') : null }; } } + for (const c of r.carried || []) { const i = c.shard; if (i >= n || !c.valid) continue; if (states[i] !== 'paid') { states[i] = 'verified'; out[i] = { ...out[i], ...record(c), prover: short(String(c.keyHash || '').replace(/^0x/, '')), carrier: String(c.carrier || '').replace(/^0x/, ''), carrierNumber: hx(c.carrierNumber) }; } } + (r.paid || []).forEach((p, i) => { if (!p || i >= n) return; states[i] = 'paid'; const carried = (r.carried || []).find(c => c.shard === i && c.valid && String(c.keyHash || '').replace(/^0x/, '').toLowerCase() === String(p.keyHash || '').replace(/^0x/, '').toLowerCase()); out[i] = { ...out[i], ...(carried ? record(carried) : {}), prover: short(String(p.keyHash || '').replace(/^0x/, '')), carrierNumber: hx(p.carrierNumber), payout: weiStr(p.wei), payoutAddress: lo(p.payout) || out[i].payoutAddress }; }); return { states, out }; } +// ---------- Proof capture (6 Oct 2026, /proof/) ---------- +// One pool proof every PROOF_CAPTURE_EVERY_MS: fetched through igneum_getProofBytes while the node holds it, checked +// as a browser would, verified by the native verifier, written to live_proof_bytes. The queue holds candidates the +// record poll saw in the pool (bytes present, not captured before); captureTick takes the newest due one. +const captureSeen = new Set(); // block|shard|key already captured or attempted this run +const captureQueue = []; // [{ hash, number, entry, seenAt }] +let captureBusy = false, lastCaptureAt = 0, captureCount = 0, verifierInfo = null; +function queueCapture(hash, number, e) { + const key = `${hash}|${e.shard}|${String(e.keyHash || '').replace(/^0x/, '').toLowerCase()}`; + if (captureSeen.has(key) || !(e.proofBytes > 0)) return; + if (captureQueue.some(c => c.key === key)) return; + captureQueue.push({ key, hash, number, entry: e, seenAt: Date.now() }); + if (captureQueue.length > 200) captureQueue.splice(0, captureQueue.length - 200); +} +async function captureTick() { + if (captureBusy || !captureQueue.length) return; + const now = Date.now(); + if (now - lastCaptureAt < PROOF_CAPTURE_EVERY_MS) return; + captureBusy = true; + try { + // the newest candidate still inside the node's record window (about 10 minutes) + while (captureQueue.length && now - captureQueue[0].seenAt > PROOF_WINDOW_MS) captureSeen.add(captureQueue.shift().key); + const c = captureQueue.pop(); if (!c) return; + captureSeen.add(c.key); lastCaptureAt = now; + const row = await captureProof({ evm, blockHash: c.hash, number: c.number, entry: c.entry, host: VERIFIER }); + if (row.error) { log(`proof capture of block ${short(c.hash)} shard ${c.entry.shard}: ${row.error}`); lastCaptureAt = 0; return; } + const hexOf = u8 => Buffer.from(u8).toString('hex'); + await sql(`INSERT INTO ${TV} (block_hash, shard, key_hash, number, prover, payout, statement, proof_hash, proof, proof_bytes, public_values, 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) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, decode($9, 'hex'), $10, decode($11, 'hex'), $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) + ON CONFLICT (block_hash, shard, key_hash) DO UPDATE SET proof = EXCLUDED.proof, proof_bytes = EXCLUDED.proof_bytes, public_values = EXCLUDED.public_values, node_verified = EXCLUDED.node_verified, + proof_hash_check = EXCLUDED.proof_hash_check, statement_check = EXCLUDED.statement_check, fields_check = EXCLUDED.fields_check, native_verified = EXCLUDED.native_verified, + native_verify_ms = EXCLUDED.native_verify_ms, native_setup_ms = EXCLUDED.native_setup_ms, native_total_ms = EXCLUDED.native_total_ms, native_program_id = EXCLUDED.native_program_id, + native_pinned_id = EXCLUDED.native_pinned_id, native_ours = EXCLUDED.native_ours, native_note = EXCLUDED.native_note, verifier = EXCLUDED.verifier, received_at = now()`, + [row.block_hash, row.shard, row.key_hash, row.number, row.prover, row.payout, row.statement, row.proof_hash, hexOf(row.proof), row.proof_bytes, row.public_values ? hexOf(row.public_values) : null, row.sp1_version, row.node_verified, + row.proof_hash_check, row.statement_check, row.fields_check, row.native_verified, row.native_verify_ms, row.native_setup_ms, row.native_total_ms, row.native_program_id, row.native_pinned_id, row.native_ours, row.native_note, VERIFIER || null]); + // the bytes of older captures are dropped beyond PROOF_BYTES_KEEP; the record, the checks and the verdict stay + await sql(`UPDATE ${TV} SET proof = NULL WHERE proof IS NOT NULL AND (block_hash, shard, key_hash) NOT IN (SELECT block_hash, shard, key_hash FROM ${TV} WHERE proof IS NOT NULL ORDER BY received_at DESC LIMIT $1)`, [PROOF_BYTES_KEEP]).catch(e => log('proof bytes prune failed', e.message)); + captureCount++; + const verdict = row.native_verified === true ? `native VERIFIED in ${row.native_verify_ms} ms (setup ${row.native_setup_ms} ms)` : row.native_verified === false ? `native NOT VERIFIED: ${row.native_note}` : `no native verdict (${row.native_note})`; + log(`proof captured: block ${short(row.block_hash)} number ${row.number} shard ${row.shard} by ${row.prover}, ${row.proof_bytes} bytes, hash ${row.proof_hash_check ? 'ok' : 'MISMATCH'}, statement ${row.statement_check ? 'ok' : 'MISMATCH'}, fields ${row.fields_check ? 'ok' : 'MISMATCH'}, ${verdict}`); + if (captureCount === 1) recordEvent('proving', `First shard proof captured for the explorer: block ${short(row.block_hash)} shard ${row.shard}, ${row.proof_bytes} bytes, ${row.native_verified === true ? 'verified by the native verifier' : row.native_verified === false ? 'refused by the native verifier' : 'checks only, no verifier'}`); + if (row.proof_hash_check === false || row.statement_check === false || row.fields_check === false || row.native_verified === false) recordEvent('proving', `Captured proof of block ${short(row.block_hash)} shard ${row.shard} failed a check: ${row.native_note || 'hash or statement mismatch'}`); + } catch (e) { log('proof capture failed', e.message); } + finally { captureBusy = false; } +} + +// ---------- The epoch line (6 Oct 2026, reviewer C46) ---------- +// getBlockTemplate carries powEpoch (rpc/core/src/model/message.rs RpcPowEpochInfo): the program class of the epoch as +// a generator version (2 or 3 today, 4 later), the next epoch's class, the activation height of class v3, the epoch +// index and seed. Read every 10 s with a pay address (the newest block's coinbase address, or IGNEUM_TEMPLATE_ADDRESS). +let lastEpochAt = 0, lastEpoch = null, lastMinerAddress = null, epochFailLogged = false; +async function epochTick(rpc) { + const now = Date.now(); + if (now - lastEpochAt < 10_000) return lastEpoch; + const payAddress = TEMPLATE_ADDRESS || lastMinerAddress; + if (!payAddress) return lastEpoch; + lastEpochAt = now; + try { + const t = await rpc.call('getBlockTemplate', { payAddress, extraData: [] }); + const pe = t && t.powEpoch; + if (!pe) { if (!epochFailLogged) { log('getBlockTemplate carries no powEpoch (a node before the field); the stats line stays class-unknown'); epochFailLogged = true; } return lastEpoch; } + lastEpoch = { + epoch_index: hx(pe.epochIndex), epoch_seed: pe.epochSeed || null, epoch_blocks: hx(pe.epochBlocks), boundary_daa: hx(pe.boundaryDaaScore), virtual_daa: hx(pe.virtualDaaScore), + program_class: hx(pe.programClass), next_program_class: hx(pe.nextProgramClass), class_v3_activation_daa: pe.programClassV3ActivationDaa === undefined ? null : (Number(pe.programClassV3ActivationDaa) > 1e15 ? null : hx(pe.programClassV3ActivationDaa)), + era_index: hx(pe.eraIndex), era_seed: pe.eraSeed || null, dataset_log2: hx(pe.genesisDatasetLog2), day_index: hx(pe.dayIndex), day_ms: hx(pe.dayMs), read_at: new Date(now).toISOString(), source: 'getBlockTemplate.powEpoch', + }; + epochFailLogged = false; + } catch (e) { if (!epochFailLogged) { log('getBlockTemplate failed (the epoch line):', e.message); epochFailLogged = true; } } + return lastEpoch; +} + async function pollRecords() { if (recordsBusy || provingSupported !== true) return; recordsBusy = true; @@ -953,13 +1089,15 @@ async function pollRecords() { b.polledAt = now; let r; try { r = await evm('igneum_getProofRecords', [{ blockHash: '0x' + h }]); } catch (e) { if (!/not found/.test(e.message)) log('proof records failed', h.slice(0, 8), e.message); return; } const { states, out } = shardStates(b, r); + for (const e of r.pool || []) if (e.shard < states.length) queueCapture(h, b.number, e); for (let s = 0; s < states.length; s++) { - const key = `${states[s]}|${out[s].prover}|${out[s].carrierNumber}|${out[s].carrier}`; + const key = `${states[s]}|${out[s].prover}|${out[s].carrierNumber}|${out[s].carrier}|${out[s].proofHash}`; if (b.provers[s] === key) continue; b.provers[s] = key; b.shards[s] = states[s]; let carrierDaa = out[s].carrierNumber !== null ? daaOfNumber.get(out[s].carrierNumber) ?? null : null; if (carrierDaa === null && out[s].carrierNumber !== null) { try { const p = await evm('igneum_getShardPlan', ['0x' + out[s].carrierNumber.toString(16)]); carrierDaa = hx(p.daaScore); daaOfNumber.set(out[s].carrierNumber, carrierDaa); } catch { } } - updates.push({ hash: h, shard: s, state: states[s], prover: out[s].prover, verified: out[s].verified, carrier: out[s].carrier, carrierNumber: out[s].carrierNumber, carrierDaa, lag: carrierDaa !== null && b.daa !== null ? carrierDaa - b.daa : null, payout: out[s].payout }); + updates.push({ hash: h, shard: s, state: states[s], prover: out[s].prover, verified: out[s].verified, carrier: out[s].carrier, carrierNumber: out[s].carrierNumber, carrierDaa, lag: carrierDaa !== null && b.daa !== null ? carrierDaa - b.daa : null, payout: out[s].payout, + keyHash: out[s].keyHash, payoutAddress: out[s].payoutAddress, statement: out[s].statement, proofHash: out[s].proofHash }); if (out[s].prover && !provers.has(out[s].prover)) { provers.add(out[s].prover); recordEvent('prover_seen', `Prover ${out[s].prover} seen (shard ${s} of block ${short(h)})`); } if (states[s] === 'paid' && !firstPaidSeen) { firstPaidSeen = true; recordEvent('proving', `First paid shard seen: block ${short(h)} shard ${s}, carried ${updates[updates.length - 1].lag ?? '?'} DAA later`); } } @@ -967,8 +1105,9 @@ async function pollRecords() { })); } for (const u of updates) { - await sql(`UPDATE ${TP} SET state = $3, prover = $4, verified = $5, carried_by = $6, carrier_number = $7, carrier_daa = $8, lag_daa = $9, payout_wei = COALESCE($10, payout_wei), updated_at = now() - WHERE block_hash = $1 AND shard = $2`, [u.hash, u.shard, u.state, u.prover, u.verified, u.carrier, u.carrierNumber, u.carrierDaa, u.lag, u.payout]).catch(e => log('proof row update failed', e.message)); + await sql(`UPDATE ${TP} SET state = $3, prover = $4, verified = $5, carried_by = $6, carrier_number = $7, carrier_daa = $8, lag_daa = $9, payout_wei = COALESCE($10, payout_wei), + key_hash = COALESCE($11, key_hash), payout = COALESCE($12, payout), statement = COALESCE($13, statement), proof_hash = COALESCE($14, proof_hash), updated_at = now() + WHERE block_hash = $1 AND shard = $2`, [u.hash, u.shard, u.state, u.prover, u.verified, u.carrier, u.carrierNumber, u.carrierDaa, u.lag, u.payout, u.keyHash, u.payoutAddress, u.statement, u.proofHash]).catch(e => log('proof row update failed', e.message)); } } catch (e) { log('records poll failed', e.message); } finally { recordsBusy = false; } @@ -1033,6 +1172,7 @@ async function prune() { try { await sql(`DELETE FROM ${TB} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]); await sql(`DELETE FROM ${TP} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]); + await sql(`DELETE FROM ${TV} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]); await sql(`DELETE FROM ${TE} WHERE ts < now() - interval '7 days'`); await sql(`DELETE FROM ${TC} WHERE updated_at < now() - interval '7 days'`); } catch (e) { log('prune failed', e.message); } @@ -1071,6 +1211,8 @@ async function main() { await setupSchema(); await seedFromDb(); log(`observer started, rpc ${RPC}, evm rpc ${EVM}, keeping ${RETAIN_HOURS} h of blocks, ${miners.size} miners known, ${openBlocks.size} blocks open for proofs`); + // the native verifier for captured proofs: its pinned ids are logged once so a mismatch with the provers is visible + { const v = await verifierIds(VERIFIER); verifierInfo = v.ids; log(v.ids ? `proof verifier ${VERIFIER}: shard program id ${v.ids.shard_program_id}, SP1 ${v.ids.sp1_crate_version} circuit ${v.ids.sp1_circuit_version}, pinned ${v.ids.pinned_at}; one capture every ${PROOF_CAPTURE_EVERY_MS / 1000} s, bytes kept for ${PROOF_BYTES_KEEP}` : `no proof verifier: ${v.reason}; captured proofs get the browser checks only`); } const rpc = new Rpc(RPC); // The socket handler only enqueues; the drain loop does the work in bounded batches so the socket is always read rpc.onNotification = (method, params) => { inbox.push([method, params]); if (!draining) { draining = true; setImmediate(drain); } }; @@ -1135,6 +1277,7 @@ async function main() { setInterval(() => flushCertificates(rpc), FLUSH_EVERY_MS); setInterval(flushPlans, FLUSH_EVERY_MS); setInterval(pollRecords, STATE_EVERY_MS); + setInterval(captureTick, STATE_EVERY_MS); setInterval(() => tick(rpc), STATE_EVERY_MS); setInterval(prune, PRUNE_EVERY_MS); setInterval(supplyCheck, SUPPLY_CHECK_EVERY_MS); setTimeout(supplyCheck, 20_000); diff --git a/tools/observer/proof-capture.mjs b/tools/observer/proof-capture.mjs new file mode 100644 index 000000000..35c2e4997 --- /dev/null +++ b/tools/observer/proof-capture.mjs @@ -0,0 +1,128 @@ +// Proof capture for the explorer's /proof/ 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