From c3957f28798a2313c51f3414f164459b85d007b7 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:02:49 +0000 Subject: [PATCH 01/13] Explorer indexer: the observer's EVM-side sibling for Devnet 3 (live_txs, live_accounts, explorer_blocks, explorer_state under the dn3_ prefix; receipts, logs, balances, nonces, code; the three-deep reorg re-read; 7-day retention; igneum_getNodeInfo kept for the class floor) Co-Authored-By: Claude Fable 5.1 --- tools/observer/explorer-indexer.mjs | 275 +++++++++++++++++++++++ tools/observer/explorer-indexer.test.mjs | 38 ++++ 2 files changed, 313 insertions(+) create mode 100644 tools/observer/explorer-indexer.mjs create mode 100644 tools/observer/explorer-indexer.test.mjs diff --git a/tools/observer/explorer-indexer.mjs b/tools/observer/explorer-indexer.mjs new file mode 100644 index 000000000..69249965a --- /dev/null +++ b/tools/observer/explorer-indexer.mjs @@ -0,0 +1,275 @@ +// Igneum explorer indexer: the sibling of tools/observer/observer.mjs for the EVM side (8 October 2026, the explorer lane). +// The observer writes every DAG block, the shard plans, the checkpoints and the certificates. This process reads the +// execution layer of the same node (eth_getBlockByNumber with the transactions, eth_getBlockReceipts, eth_getBalance, +// eth_getTransactionCount, eth_getCode, igneum_getNodeInfo) and writes what /tx/, /address/ and the +// transaction lists need. It never touches the observer's tables; it creates its own three next to them under the same +// prefix, so one LIVE_TABLE_PREFIX names one chain on both sides. Node 22 or newer, no dependencies. +// +// node tools/observer/explorer-indexer.mjs run (env below) +// node tools/observer/explorer-indexer.mjs --once one tick, then exit (the smoke run) +// +// Environment, every value optional: +// DATABASE_URL Neon connection string (read from ~/.config/igneum/env when unset; never in the repo) +// LIVE_TABLE_PREFIX table prefix, default dn3_ (Devnet 3, the same prefix the observer unit on the build box writes) +// IGNEUM_EVM_RPC the node's execution JSON-RPC, default http://127.0.0.1:26850 (the Devnet 3 observer node's) +// EXPLORER_BACKFILL chain blocks to index behind the tip on an empty table, default 0 = from the first block +// EXPLORER_RETAIN_HOURS hours of transactions kept, default 168 (7 days); older rows are deleted once an hour +// EXPLORER_BATCH chain blocks per round while catching up, default 25 +// EXPLORER_TICK_MS the idle tick, default 2000 +// +// Tables (created on start if missing; the prefix is written as T below): +// T live_txs one row per EVM transaction: hash, block_hash, block_number, tx_index, from_addr, to_addr, value_wei, +// nonce, gas, gas_price, gas_used, effective_gas_price, status, contract_address, input_bytes, selector, +// logs_count, logs (jsonb), igneum (jsonb: the receipt's igneum section), tx_type, ts_ms, received_at +// T live_accounts one row per address seen: balance_wei, nonce, code_bytes, is_contract, sent, received, first_seen_number, +// last_seen_number, miner (mined a block in the observer's table), refreshed_at +// T explorer_blocks one row per indexed chain block: number, hash, tx_count, gas_used, ts_ms, indexed_at (the reorg check +// re-reads the newest three numbers every tick and re-indexes a number whose hash moved) +// T explorer_state one row: indexed_number, tip_number, chain_id, node_info (igneum_getNodeInfo: params, digest, genesis), +// txs_total, accounts_total, backlog, blocks_per_s, last_error, started_at, updated_at +// +// Reorgs: Devnet 3's rule is no reorg over depth 3 (release-0.3.22 gates), so three numbers are re-read each tick; a deeper +// one shows as a block hash in live_txs that the observer's live_blocks no longer calls a chain block, which the API reports. + +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; + +const ONCE = process.argv.includes('--once'); +const T = (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, ''); +const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:26850'; +const BACKFILL = Math.max(0, Number(process.env.EXPLORER_BACKFILL) || 0); +const RETAIN_H = Math.max(1, Number(process.env.EXPLORER_RETAIN_HOURS) || 168); +const BATCH = Math.max(1, Math.min(100, Number(process.env.EXPLORER_BATCH) || 25)); +const TICK_MS = Math.max(500, Number(process.env.EXPLORER_TICK_MS) || 2000); +const ACCOUNTS_PER_TICK = 20; +const RPC_CONCURRENCY = 6; + +// ---- pure helpers (tested by explorer-indexer.test.mjs) -------------------------------------------------------------- +export const hexInt = h => (h === null || h === undefined ? null : Number(BigInt(h))); +export const hexBig = h => (h === null || h === undefined ? null : BigInt(h).toString()); +export const lower = a => (a ? String(a).toLowerCase() : null); + +/** One live_txs row from the block's transaction object and its receipt (both as the node's JSON-RPC returns them). */ +export function txRow(tx, rc, block) { + const input = String(tx.input || '0x'); + return { + hash: lower(tx.hash), block_hash: lower(block.hash), block_number: hexInt(block.number), tx_index: hexInt(tx.transactionIndex) ?? 0, + from_addr: lower(tx.from), to_addr: lower(tx.to), value_wei: hexBig(tx.value) || '0', nonce: hexInt(tx.nonce), gas: hexInt(tx.gas), + gas_price: hexBig(tx.gasPrice ?? tx.maxFeePerGas) , gas_used: rc ? hexInt(rc.gasUsed) : null, effective_gas_price: rc ? hexBig(rc.effectiveGasPrice) : null, + status: rc && rc.status !== undefined && rc.status !== null ? hexInt(rc.status) : null, contract_address: rc ? lower(rc.contractAddress) : null, + input_bytes: Math.max(0, (input.length - 2) / 2), selector: input.length >= 10 ? input.slice(0, 10).toLowerCase() : null, + logs_count: rc && rc.logs ? rc.logs.length : 0, + logs: rc && rc.logs ? rc.logs.map(l => ({ i: hexInt(l.logIndex), address: lower(l.address), topics: l.topics || [], data: l.data || '0x' })) : [], + igneum: rc && rc.igneum ? rc.igneum : null, tx_type: hexInt(tx.type) ?? 0, ts_ms: hexInt(block.timestamp) === null ? null : hexInt(block.timestamp) * 1000, + }; +} + +/** The addresses a block touches, each with why (sender, receiver, contract, miner). */ +export function touched(rows, block) { + const m = new Map(); + const add = (a, kind) => { if (!a) return; const k = lower(a); if (!m.has(k)) m.set(k, new Set()); m.get(k).add(kind); }; + for (const r of rows) { add(r.from_addr, 'sender'); add(r.to_addr, 'receiver'); add(r.contract_address, 'contract'); } + if (block && block.igneum && Array.isArray(block.igneum.rewards)) for (const r of block.igneum.rewards) add(r.miner, 'miner'); + if (block && block.miner) add(block.miner, 'miner'); + return m; +} + +// ---- Neon over HTTP, the same pattern as the observer --------------------------------------------------------------- +function envFile() { + try { return Object.fromEntries(readFileSync(`${homedir()}/.config/igneum/env`, 'utf8').split('\n').filter(l => /^[A-Z_]+=/.test(l)).map(l => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).replace(/^"|"$/g, '')]; })); } catch { return {}; } +} +function neon(url) { + if (!url) throw new Error('DATABASE_URL is not set'); + const host = new URL(url).hostname.replace('-pooler', ''); + return async (query, params = []) => { + const r = await fetch(`https://${host}/sql`, { method: 'POST', headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, params }), signal: AbortSignal.timeout(30000) }); + const j = await r.json(); + if (!r.ok) throw new Error(j.message || JSON.stringify(j)); + return j.rows || []; + }; +} + +// ---- JSON-RPC with a small concurrency cap ---------------------------------------------------------------------------- +let inFlight = 0; const waiters = []; +async function rpc(method, params = []) { + if (inFlight >= RPC_CONCURRENCY) await new Promise(r => waiters.push(r)); + inFlight++; + try { + const r = await fetch(EVM, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(15000) }); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; + } finally { inFlight--; const w = waiters.shift(); if (w) w(); } +} +const hex = n => '0x' + Number(n).toString(16); + +// ---- schema ---------------------------------------------------------------------------------------------------------- +async function schema(sql) { + await sql(`CREATE TABLE IF NOT EXISTS ${T}live_txs ( + hash text PRIMARY KEY, block_hash text NOT NULL, block_number bigint NOT NULL, tx_index integer NOT NULL DEFAULT 0, + from_addr text, to_addr text, value_wei text NOT NULL DEFAULT '0', nonce bigint, gas bigint, gas_price text, gas_used bigint, + effective_gas_price text, status smallint, contract_address text, input_bytes integer NOT NULL DEFAULT 0, selector text, + logs_count integer NOT NULL DEFAULT 0, logs jsonb NOT NULL DEFAULT '[]'::jsonb, igneum jsonb, tx_type smallint NOT NULL DEFAULT 0, + ts_ms bigint, received_at timestamptz NOT NULL DEFAULT now())`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_number ON ${T}live_txs (block_number DESC, tx_index)`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_from ON ${T}live_txs (from_addr, block_number DESC)`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_to ON ${T}live_txs (to_addr, block_number DESC)`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_block ON ${T}live_txs (block_hash)`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_ts ON ${T}live_txs (ts_ms)`); + await sql(`CREATE TABLE IF NOT EXISTS ${T}live_accounts ( + address text PRIMARY KEY, balance_wei text, nonce bigint, code_bytes integer, is_contract boolean, sent integer NOT NULL DEFAULT 0, + received integer NOT NULL DEFAULT 0, first_seen_number bigint, last_seen_number bigint, miner boolean NOT NULL DEFAULT false, + refreshed_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now())`); + await sql(`CREATE INDEX IF NOT EXISTS ${T}live_accounts_seen ON ${T}live_accounts (last_seen_number DESC)`); + await sql(`CREATE TABLE IF NOT EXISTS ${T}explorer_blocks (number bigint PRIMARY KEY, hash text NOT NULL, tx_count integer NOT NULL DEFAULT 0, + gas_used bigint, ts_ms bigint, indexed_at timestamptz NOT NULL DEFAULT now())`); + await sql(`CREATE TABLE IF NOT EXISTS ${T}explorer_state (id integer PRIMARY KEY, indexed_number bigint, tip_number bigint, chain_id integer, + node_info jsonb, txs_total bigint, accounts_total bigint, backlog integer, blocks_per_s double precision, last_error text, + rpc text, started_at timestamptz, updated_at timestamptz)`); + await sql(`INSERT INTO ${T}explorer_state (id, started_at, updated_at, rpc) VALUES (1, now(), now(), $1) ON CONFLICT (id) DO UPDATE SET started_at = now(), rpc = $1`, [EVM]); +} + +// ---- the work ---------------------------------------------------------------------------------------------------------- +const log = (...a) => console.log(new Date().toISOString(), ...a); +const accountQueue = new Map(); // address -> {kinds:Set, number} +const contractKnown = new Map(); // address -> is_contract (code fetched once) +let txsPerMin = 0, blocksPerMin = 0, accountsPerMin = 0, lastError = null; + +async function indexNumbers(sql, numbers) { + const blocks = await Promise.all(numbers.map(n => rpc('eth_getBlockByNumber', [hex(n), true]))); + const receipts = await Promise.all(numbers.map((n, i) => (blocks[i] && blocks[i].transactions && blocks[i].transactions.length ? rpc('eth_getBlockReceipts', [hex(n)]).catch(() => null) : Promise.resolve([])))); + const rows = []; const blockRows = []; + for (let i = 0; i < numbers.length; i++) { + const b = blocks[i]; if (!b) continue; + const byHash = new Map((receipts[i] || []).map(r => [lower(r.transactionHash), r])); + const txs = (b.transactions || []).map(t => txRow(t, byHash.get(lower(t.hash)) || null, b)); + rows.push(...txs); + blockRows.push({ number: hexInt(b.number), hash: lower(b.hash), tx_count: txs.length, gas_used: hexInt(b.gasUsed), ts_ms: hexInt(b.timestamp) * 1000 }); + for (const [a, kinds] of touched(txs, b)) { const q = accountQueue.get(a) || { kinds: new Set(), number: 0 }; for (const k of kinds) q.kinds.add(k); q.number = Math.max(q.number, hexInt(b.number)); accountQueue.set(a, q); } + } + // a number re-indexed after a reorg drops its old rows first + await sql(`DELETE FROM ${T}live_txs WHERE block_number = ANY($1::bigint[])`, [numbers]); + for (let off = 0; off < rows.length; off += 200) { + const chunk = rows.slice(off, off + 200); const vals = []; const params = []; + chunk.forEach((r, k) => { + const base = k * 22; + vals.push(`(${Array.from({ length: 22 }, (_, j) => '$' + (base + j + 1)).join(',')})`); + params.push(r.hash, r.block_hash, r.block_number, r.tx_index, r.from_addr, r.to_addr, r.value_wei, r.nonce, r.gas, r.gas_price, r.gas_used, r.effective_gas_price, r.status, r.contract_address, r.input_bytes, r.selector, r.logs_count, JSON.stringify(r.logs), r.igneum ? JSON.stringify(r.igneum) : null, r.tx_type, r.ts_ms, new Date().toISOString()); + }); + await sql(`INSERT INTO ${T}live_txs (hash, block_hash, block_number, tx_index, from_addr, to_addr, value_wei, nonce, gas, gas_price, gas_used, effective_gas_price, status, contract_address, input_bytes, selector, logs_count, logs, igneum, tx_type, ts_ms, received_at) + VALUES ${vals.join(',')} ON CONFLICT (hash) DO UPDATE SET block_hash = EXCLUDED.block_hash, block_number = EXCLUDED.block_number, tx_index = EXCLUDED.tx_index, gas_used = EXCLUDED.gas_used, + effective_gas_price = EXCLUDED.effective_gas_price, status = EXCLUDED.status, contract_address = EXCLUDED.contract_address, logs_count = EXCLUDED.logs_count, logs = EXCLUDED.logs, igneum = EXCLUDED.igneum, ts_ms = EXCLUDED.ts_ms`, params); + } + if (blockRows.length) { + const vals = []; const params = []; + blockRows.forEach((b, k) => { vals.push(`($${k * 5 + 1},$${k * 5 + 2},$${k * 5 + 3},$${k * 5 + 4},$${k * 5 + 5})`); params.push(b.number, b.hash, b.tx_count, b.gas_used, b.ts_ms); }); + await sql(`INSERT INTO ${T}explorer_blocks (number, hash, tx_count, gas_used, ts_ms) VALUES ${vals.join(',')} ON CONFLICT (number) DO UPDATE SET hash = EXCLUDED.hash, tx_count = EXCLUDED.tx_count, gas_used = EXCLUDED.gas_used, ts_ms = EXCLUDED.ts_ms, indexed_at = now()`, params); + } + // sent and received counts and first/last seen, in one statement per batch + const seen = new Map(); + for (const r of rows) { + if (r.from_addr) { const s = seen.get(r.from_addr) || { sent: 0, received: 0, min: r.block_number, max: r.block_number }; s.sent++; s.min = Math.min(s.min, r.block_number); s.max = Math.max(s.max, r.block_number); seen.set(r.from_addr, s); } + if (r.to_addr) { const s = seen.get(r.to_addr) || { sent: 0, received: 0, min: r.block_number, max: r.block_number }; s.received++; s.min = Math.min(s.min, r.block_number); s.max = Math.max(s.max, r.block_number); seen.set(r.to_addr, s); } + } + const ents = [...seen.entries()]; + for (let off = 0; off < ents.length; off += 300) { + const chunk = ents.slice(off, off + 300); const vals = []; const params = []; + chunk.forEach(([a, s], k) => { vals.push(`($${k * 5 + 1},$${k * 5 + 2},$${k * 5 + 3},$${k * 5 + 4},$${k * 5 + 5})`); params.push(a, s.sent, s.received, s.min, s.max); }); + await sql(`INSERT INTO ${T}live_accounts (address, sent, received, first_seen_number, last_seen_number) VALUES ${vals.join(',')} + ON CONFLICT (address) DO UPDATE SET sent = ${T}live_accounts.sent + EXCLUDED.sent, received = ${T}live_accounts.received + EXCLUDED.received, + first_seen_number = LEAST(${T}live_accounts.first_seen_number, EXCLUDED.first_seen_number), last_seen_number = GREATEST(${T}live_accounts.last_seen_number, EXCLUDED.last_seen_number), updated_at = now()`, params); + } + txsPerMin += rows.length; blocksPerMin += blockRows.length; + return blockRows; +} + +async function refreshAccounts(sql, max) { + if (!accountQueue.size) return 0; + const picks = [...accountQueue.entries()].sort((a, b) => b[1].number - a[1].number).slice(0, max); + for (const [a] of picks) accountQueue.delete(a); + const reads = await Promise.all(picks.map(async ([a, q]) => { + try { + const [bal, nonce] = await Promise.all([rpc('eth_getBalance', [a, 'latest']), rpc('eth_getTransactionCount', [a, 'latest'])]); + let code = contractKnown.get(a); + if (code === undefined || q.kinds.has('contract')) { const c = await rpc('eth_getCode', [a, 'latest']); code = Math.max(0, (String(c || '0x').length - 2) / 2); contractKnown.set(a, code); } + return { a, bal: hexBig(bal), nonce: hexInt(nonce), code, miner: q.kinds.has('miner') }; + } catch (e) { lastError = String(e.message || e).slice(0, 200); accountQueue.set(a, q); return null; } + })); + const ok = reads.filter(Boolean); + if (ok.length) { + const vals = []; const params = []; + ok.forEach((r, k) => { vals.push(`($${k * 6 + 1},$${k * 6 + 2},$${k * 6 + 3},$${k * 6 + 4},$${k * 6 + 5},$${k * 6 + 6})`); params.push(r.a, r.bal, r.nonce, r.code, r.code > 0, r.miner); }); + await sql(`INSERT INTO ${T}live_accounts (address, balance_wei, nonce, code_bytes, is_contract, miner, refreshed_at) VALUES ${vals.map(v => v.replace(/\)$/, ', now())')).join(',')} + ON CONFLICT (address) DO UPDATE SET balance_wei = EXCLUDED.balance_wei, nonce = EXCLUDED.nonce, code_bytes = EXCLUDED.code_bytes, is_contract = EXCLUDED.is_contract, + miner = ${T}live_accounts.miner OR EXCLUDED.miner, refreshed_at = now(), updated_at = now()`, params); + } + accountsPerMin += ok.length; + return ok.length; +} + +async function queueMiners(sql) { + // the observer's miners of the last 10 minutes get a balance read every minute, so a miner page is never older than that + const rows = await sql(`SELECT DISTINCT evm_miner AS a FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes' AND evm_miner IS NOT NULL`).catch(() => []); + for (const r of rows) { const q = accountQueue.get(lower(r.a)) || { kinds: new Set(), number: 0 }; q.kinds.add('miner'); accountQueue.set(lower(r.a), q); } +} + +async function main() { + const env = envFile(); + const sql = neon(process.env.DATABASE_URL || env.DATABASE_URL); + await schema(sql); + for (const r of await sql(`SELECT address, code_bytes FROM ${T}live_accounts WHERE code_bytes IS NOT NULL`)) contractKnown.set(r.address, Number(r.code_bytes)); + const st = (await sql(`SELECT indexed_number FROM ${T}explorer_state WHERE id = 1`))[0] || {}; + let indexed = st.indexed_number === null || st.indexed_number === undefined ? null : Number(st.indexed_number); + const chainId = hexInt(await rpc('eth_chainId')); + log(`explorer indexer: prefix ${T}, rpc ${EVM}, chain id ${chainId}, indexed ${indexed === null ? 'nothing yet' : indexed}, batch ${BATCH}, retain ${RETAIN_H} h`); + let lastInfo = 0, lastMiners = 0, lastRetain = 0, lastLog = Date.now(), lastRate = Date.now(), rateBlocks = 0; + for (;;) { + const t0 = Date.now(); + try { + const tip = hexInt(await rpc('eth_blockNumber')); + if (indexed === null) { indexed = BACKFILL > 0 ? Math.max(-1, tip - BACKFILL) : -1; log(`starting at chain block ${indexed + 1}, tip ${tip}`); } + // the reorg check: the newest three indexed numbers must still carry the hash we stored + if (indexed >= 0) { + const lo = Math.max(0, indexed - 2); + const stored = await sql(`SELECT number, hash FROM ${T}explorer_blocks WHERE number BETWEEN $1 AND $2`, [lo, indexed]); + const live = await Promise.all(stored.map(s => rpc('eth_getBlockByNumber', [hex(Number(s.number)), false]).catch(() => null))); + const moved = stored.filter((s, i) => live[i] && lower(live[i].hash) !== s.hash).map(s => Number(s.number)); + if (moved.length) { const from = Math.min(...moved); log(`reorg: chain block ${from} moved, re-indexing from it`); indexed = from - 1; } + } + const behind = tip - indexed; + if (behind > 0) { + const n = Math.min(BATCH, behind); + const numbers = Array.from({ length: n }, (_, i) => indexed + 1 + i); + await indexNumbers(sql, numbers); + indexed += n; rateBlocks += n; + } + if (Date.now() - lastMiners > 60000) { await queueMiners(sql); lastMiners = Date.now(); } + // while catching up, only the newest addresses are refreshed; the rest wait for the tip + await refreshAccounts(sql, behind > BATCH ? 4 : ACCOUNTS_PER_TICK); + let info = null; + if (Date.now() - lastInfo > 300000) { info = await rpc('igneum_getNodeInfo').catch(() => null); lastInfo = Date.now(); } + if (Date.now() - lastRetain > 3600000) { + const cut = Date.now() - RETAIN_H * 3600000; + const d = await sql(`WITH d AS (DELETE FROM ${T}live_txs WHERE ts_ms < $1 RETURNING 1) SELECT count(*)::int AS n FROM d`, [cut]); + await sql(`DELETE FROM ${T}explorer_blocks WHERE ts_ms < $1`, [cut]); + if (d[0] && d[0].n) log(`retention: ${d[0].n} transactions older than ${RETAIN_H} h removed`); + lastRetain = Date.now(); + } + const dt = (Date.now() - lastRate) / 1000; let bps = null; if (dt >= 10) { bps = rateBlocks / dt; rateBlocks = 0; lastRate = Date.now(); } + await sql(`UPDATE ${T}explorer_state SET indexed_number = $1, tip_number = $2, chain_id = $3, backlog = $4, blocks_per_s = COALESCE($5, blocks_per_s), last_error = $6, updated_at = now(), + node_info = COALESCE($7::jsonb, node_info), txs_total = CASE WHEN $8 THEN (SELECT count(*) FROM ${T}live_txs) ELSE txs_total END, + accounts_total = CASE WHEN $8 THEN (SELECT count(*) FROM ${T}live_accounts) ELSE accounts_total END WHERE id = 1`, + [indexed, tip, chainId, Math.max(0, tip - indexed), bps, lastError, info ? JSON.stringify(info) : null, Date.now() - lastLog > 60000 || ONCE]); + if (Date.now() - lastLog > 60000) { log(`indexed ${indexed} of tip ${tip} (behind ${tip - indexed}); last minute: ${blocksPerMin} blocks, ${txsPerMin} txs, ${accountsPerMin} account reads, queue ${accountQueue.size}${lastError ? `, last error: ${lastError}` : ''}`); txsPerMin = blocksPerMin = accountsPerMin = 0; lastError = null; lastLog = Date.now(); } + if (ONCE) { log(`once: indexed ${indexed}, tip ${tip}`); return; } + if (tip - indexed <= 0) await new Promise(r => setTimeout(r, Math.max(0, TICK_MS - (Date.now() - t0)))); + } catch (e) { + lastError = String(e.message || e).slice(0, 200); + log(`tick failed: ${lastError}`); + if (ONCE) { process.exitCode = 1; return; } + await new Promise(r => setTimeout(r, 5000)); + } + } +} + +if (process.argv[1] && /explorer-indexer\.mjs$/.test(process.argv[1])) main().catch(e => { console.error(e); process.exit(1); }); diff --git a/tools/observer/explorer-indexer.test.mjs b/tools/observer/explorer-indexer.test.mjs new file mode 100644 index 000000000..53e5d3d97 --- /dev/null +++ b/tools/observer/explorer-indexer.test.mjs @@ -0,0 +1,38 @@ +// The explorer indexer's pure parts: a transaction row from the node's JSON, the touched-address set, the hex helpers. +// node --test tools/observer/explorer-indexer.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { txRow, touched, hexInt, hexBig } from './explorer-indexer.mjs'; + +const block = { hash: '0xE61CFFB1D306AD8D3F4F9FEC6D750100517572F54A14A82B40EBFD393531320A', number: '0x654d', timestamp: '0x6acd', miner: '0x8DB0505bbb03dffb58875897eeedc4e790a68d18', igneum: { rewards: [{ miner: '0x78fb14e43d3170dcf9abfdc70f15685c8ea31678', wei: '0x1' }] } }; +const tx = { hash: '0x4E7C2A9D54208B70A557184CB35E5847CDA4E90FFCA9578C6FC05B9C0DC4EF25', from: '0xE617a7f966fc009934e943fe0453821ff0c8716f', to: '0x7fe2f74b45dd8f0a739c906817f197631fdeff85', value: '0x0', nonce: '0x1a', gas: '0x38936', gasPrice: '0x178411b200', input: '0xa9059cbb000000000000000000000000', transactionIndex: '0x3', type: '0x2' }; +const rc = { transactionHash: tx.hash, gasUsed: '0x6e5e', effectiveGasPrice: '0x178411b200', status: '0x1', contractAddress: null, logs: [{ logIndex: '0x0', address: '0x7FE2f74b45dd8f0a739c906817f197631fdeff85', topics: ['0xaa'], data: '0x01' }], igneum: { minerTip: '0x148eb7b4f000', pgasUsed: '0x56c' } }; + +test('hex helpers: null stays null, big values keep every digit', () => { + assert.equal(hexInt(null), null); assert.equal(hexInt('0x654d'), 25933); + assert.equal(hexBig('0x18c55612df9b9f9a3600'), '116977006436888600000000'); assert.equal(hexBig(undefined), null); +}); + +test('txRow: lower-case addresses and hashes, numbers decoded, the receipt folded in, the selector and the log list', () => { + const r = txRow(tx, rc, block); + assert.equal(r.hash, tx.hash.toLowerCase()); assert.equal(r.block_hash, block.hash.toLowerCase()); assert.equal(r.block_number, 25933); assert.equal(r.tx_index, 3); + assert.equal(r.from_addr, '0xe617a7f966fc009934e943fe0453821ff0c8716f'); assert.equal(r.to_addr, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85'); + assert.equal(r.value_wei, '0'); assert.equal(r.nonce, 26); assert.equal(r.gas, 231734); assert.equal(r.gas_used, 28254); assert.equal(r.status, 1); + assert.equal(r.selector, '0xa9059cbb'); assert.equal(r.input_bytes, 16); assert.equal(r.logs_count, 1); assert.equal(r.logs[0].address, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85'); + assert.equal(r.igneum.minerTip, '0x148eb7b4f000'); assert.equal(r.tx_type, 2); assert.equal(r.ts_ms, 0x6acd * 1000); +}); + +test('txRow without a receipt: the receipt fields are null, nothing throws', () => { + const r = txRow({ ...tx, input: '0x', to: null }, null, block); + assert.equal(r.gas_used, null); assert.equal(r.status, null); assert.equal(r.selector, null); assert.equal(r.to_addr, null); assert.deepEqual(r.logs, []); assert.equal(r.igneum, null); +}); + +test('touched: senders, receivers, the created contract and the rewarded miners, each lower case with its reasons', () => { + const rows = [txRow(tx, { ...rc, contractAddress: '0xABCDEF0000000000000000000000000000000001' }, block)]; + const m = touched(rows, block); + assert.deepEqual([...m.get('0xe617a7f966fc009934e943fe0453821ff0c8716f')], ['sender']); + assert.deepEqual([...m.get('0x7fe2f74b45dd8f0a739c906817f197631fdeff85')], ['receiver']); + assert.deepEqual([...m.get('0xabcdef0000000000000000000000000000000001')], ['contract']); + assert.deepEqual([...m.get('0x78fb14e43d3170dcf9abfdc70f15685c8ea31678')], ['miner']); + assert.deepEqual([...m.get('0x8db0505bbb03dffb58875897eeedc4e790a68d18')], ['miner']); +}); From 64f74beb087ced7fc66c7eb91ab40dd6908ff88e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:09:05 +0000 Subject: [PATCH 02/13] Explorer indexer: block hashes stored without 0x, the DAG convention of live_blocks, so a transaction joins its block Co-Authored-By: Claude Fable 5.1 --- tools/observer/explorer-indexer.mjs | 8 ++++---- tools/observer/explorer-indexer.test.mjs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/observer/explorer-indexer.mjs b/tools/observer/explorer-indexer.mjs index 69249965a..287c3354d 100644 --- a/tools/observer/explorer-indexer.mjs +++ b/tools/observer/explorer-indexer.mjs @@ -18,7 +18,7 @@ // EXPLORER_TICK_MS the idle tick, default 2000 // // Tables (created on start if missing; the prefix is written as T below): -// T live_txs one row per EVM transaction: hash, block_hash, block_number, tx_index, from_addr, to_addr, value_wei, +// T live_txs one row per EVM transaction: hash (0x), block_hash (the DAG block's hash, no 0x, as live_blocks), block_number, tx_index, from_addr, to_addr, value_wei, // nonce, gas, gas_price, gas_used, effective_gas_price, status, contract_address, input_bytes, selector, // logs_count, logs (jsonb), igneum (jsonb: the receipt's igneum section), tx_type, ts_ms, received_at // T live_accounts one row per address seen: balance_wei, nonce, code_bytes, is_contract, sent, received, first_seen_number, @@ -53,7 +53,7 @@ export const lower = a => (a ? String(a).toLowerCase() : null); export function txRow(tx, rc, block) { const input = String(tx.input || '0x'); return { - hash: lower(tx.hash), block_hash: lower(block.hash), block_number: hexInt(block.number), tx_index: hexInt(tx.transactionIndex) ?? 0, + hash: lower(tx.hash), block_hash: lower(block.hash).replace(/^0x/, ''), block_number: hexInt(block.number), tx_index: hexInt(tx.transactionIndex) ?? 0, from_addr: lower(tx.from), to_addr: lower(tx.to), value_wei: hexBig(tx.value) || '0', nonce: hexInt(tx.nonce), gas: hexInt(tx.gas), gas_price: hexBig(tx.gasPrice ?? tx.maxFeePerGas) , gas_used: rc ? hexInt(rc.gasUsed) : null, effective_gas_price: rc ? hexBig(rc.effectiveGasPrice) : null, status: rc && rc.status !== undefined && rc.status !== null ? hexInt(rc.status) : null, contract_address: rc ? lower(rc.contractAddress) : null, @@ -144,7 +144,7 @@ async function indexNumbers(sql, numbers) { const byHash = new Map((receipts[i] || []).map(r => [lower(r.transactionHash), r])); const txs = (b.transactions || []).map(t => txRow(t, byHash.get(lower(t.hash)) || null, b)); rows.push(...txs); - blockRows.push({ number: hexInt(b.number), hash: lower(b.hash), tx_count: txs.length, gas_used: hexInt(b.gasUsed), ts_ms: hexInt(b.timestamp) * 1000 }); + blockRows.push({ number: hexInt(b.number), hash: lower(b.hash).replace(/^0x/, ''), tx_count: txs.length, gas_used: hexInt(b.gasUsed), ts_ms: hexInt(b.timestamp) * 1000 }); for (const [a, kinds] of touched(txs, b)) { const q = accountQueue.get(a) || { kinds: new Set(), number: 0 }; for (const k of kinds) q.kinds.add(k); q.number = Math.max(q.number, hexInt(b.number)); accountQueue.set(a, q); } } // a number re-indexed after a reorg drops its old rows first @@ -233,7 +233,7 @@ async function main() { const lo = Math.max(0, indexed - 2); const stored = await sql(`SELECT number, hash FROM ${T}explorer_blocks WHERE number BETWEEN $1 AND $2`, [lo, indexed]); const live = await Promise.all(stored.map(s => rpc('eth_getBlockByNumber', [hex(Number(s.number)), false]).catch(() => null))); - const moved = stored.filter((s, i) => live[i] && lower(live[i].hash) !== s.hash).map(s => Number(s.number)); + const moved = stored.filter((s, i) => live[i] && lower(live[i].hash).replace(/^0x/, '') !== s.hash).map(s => Number(s.number)); if (moved.length) { const from = Math.min(...moved); log(`reorg: chain block ${from} moved, re-indexing from it`); indexed = from - 1; } } const behind = tip - indexed; diff --git a/tools/observer/explorer-indexer.test.mjs b/tools/observer/explorer-indexer.test.mjs index 53e5d3d97..0c8faa63c 100644 --- a/tools/observer/explorer-indexer.test.mjs +++ b/tools/observer/explorer-indexer.test.mjs @@ -15,7 +15,7 @@ test('hex helpers: null stays null, big values keep every digit', () => { test('txRow: lower-case addresses and hashes, numbers decoded, the receipt folded in, the selector and the log list', () => { const r = txRow(tx, rc, block); - assert.equal(r.hash, tx.hash.toLowerCase()); assert.equal(r.block_hash, block.hash.toLowerCase()); assert.equal(r.block_number, 25933); assert.equal(r.tx_index, 3); + assert.equal(r.hash, tx.hash.toLowerCase()); assert.equal(r.block_hash, block.hash.toLowerCase().slice(2)); assert.equal(r.block_number, 25933); assert.equal(r.tx_index, 3); assert.equal(r.from_addr, '0xe617a7f966fc009934e943fe0453821ff0c8716f'); assert.equal(r.to_addr, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85'); assert.equal(r.value_wei, '0'); assert.equal(r.nonce, 26); assert.equal(r.gas, 231734); assert.equal(r.gas_used, 28254); assert.equal(r.status, 1); assert.equal(r.selector, '0xa9059cbb'); assert.equal(r.input_bytes, 16); assert.equal(r.logs_count, 1); assert.equal(r.logs[0].address, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85'); From b37edbeb73f006593cdd73349206a4df86e25e12 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:10:54 +0000 Subject: [PATCH 03/13] Explorer indexer: refuses a node that reports another network (EXPLORER_NETWORK, default igneum-devnet-3) and re-reads from the tip when the indexed number runs past it Co-Authored-By: Claude Fable 5.1 --- tools/observer/explorer-indexer.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/observer/explorer-indexer.mjs b/tools/observer/explorer-indexer.mjs index 287c3354d..e8f2ce5c0 100644 --- a/tools/observer/explorer-indexer.mjs +++ b/tools/observer/explorer-indexer.mjs @@ -12,6 +12,9 @@ // DATABASE_URL Neon connection string (read from ~/.config/igneum/env when unset; never in the repo) // LIVE_TABLE_PREFIX table prefix, default dn3_ (Devnet 3, the same prefix the observer unit on the build box writes) // IGNEUM_EVM_RPC the node's execution JSON-RPC, default http://127.0.0.1:26850 (the Devnet 3 observer node's) +// EXPLORER_NETWORK the network the node must report in igneum_getNodeInfo, default igneum-devnet-3; another name refuses +// to index (8 October 2026, 10:09 UTC: a unit pointed at the first devnet's node for 26 s wrote 4,797 of +// that chain's blocks into the dn3_ tables; the tables were wiped and re-filled) // EXPLORER_BACKFILL chain blocks to index behind the tip on an empty table, default 0 = from the first block // EXPLORER_RETAIN_HOURS hours of transactions kept, default 168 (7 days); older rows are deleted once an hour // EXPLORER_BATCH chain blocks per round while catching up, default 25 @@ -38,6 +41,7 @@ const ONCE = process.argv.includes('--once'); const T = (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, ''); const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:26850'; const BACKFILL = Math.max(0, Number(process.env.EXPLORER_BACKFILL) || 0); +const NETWORK = process.env.EXPLORER_NETWORK || 'igneum-devnet-3'; const RETAIN_H = Math.max(1, Number(process.env.EXPLORER_RETAIN_HOURS) || 168); const BATCH = Math.max(1, Math.min(100, Number(process.env.EXPLORER_BATCH) || 25)); const TICK_MS = Math.max(500, Number(process.env.EXPLORER_TICK_MS) || 2000); @@ -221,7 +225,9 @@ async function main() { const st = (await sql(`SELECT indexed_number FROM ${T}explorer_state WHERE id = 1`))[0] || {}; let indexed = st.indexed_number === null || st.indexed_number === undefined ? null : Number(st.indexed_number); const chainId = hexInt(await rpc('eth_chainId')); - log(`explorer indexer: prefix ${T}, rpc ${EVM}, chain id ${chainId}, indexed ${indexed === null ? 'nothing yet' : indexed}, batch ${BATCH}, retain ${RETAIN_H} h`); + const info0 = await rpc('igneum_getNodeInfo').catch(() => null); + if (!info0 || info0.network !== NETWORK) throw new Error(`the node at ${EVM} reports network ${info0 ? info0.network : 'unknown'}, this indexer writes ${NETWORK} (EXPLORER_NETWORK); refusing to index`); + log(`explorer indexer: prefix ${T}, rpc ${EVM} (${info0.network}, digest ${String(info0.digest).slice(0, 8)}), chain id ${chainId}, indexed ${indexed === null ? 'nothing yet' : indexed}, batch ${BATCH}, retain ${RETAIN_H} h`); let lastInfo = 0, lastMiners = 0, lastRetain = 0, lastLog = Date.now(), lastRate = Date.now(), rateBlocks = 0; for (;;) { const t0 = Date.now(); @@ -247,7 +253,8 @@ async function main() { // while catching up, only the newest addresses are refreshed; the rest wait for the tip await refreshAccounts(sql, behind > BATCH ? 4 : ACCOUNTS_PER_TICK); let info = null; - if (Date.now() - lastInfo > 300000) { info = await rpc('igneum_getNodeInfo').catch(() => null); lastInfo = Date.now(); } + if (Date.now() - lastInfo > 300000) { info = await rpc('igneum_getNodeInfo').catch(() => null); lastInfo = Date.now(); if (info && info.network !== NETWORK) throw new Error(`the node now reports network ${info.network}, not ${NETWORK}; stopping`); } + if (indexed > tip + 100) { log(`the node's tip ${tip} is behind the indexed number ${indexed}: a node that moved back; re-indexing from ${tip - 3}`); indexed = Math.max(-1, tip - 3); } if (Date.now() - lastRetain > 3600000) { const cut = Date.now() - RETAIN_H * 3600000; const d = await sql(`WITH d AS (DELETE FROM ${T}live_txs WHERE ts_ms < $1 RETURNING 1) SELECT count(*)::int AS n FROM d`, [cut]); From 960f081ad29108cc20992a7ca045f633b40428c5 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:24:03 +0000 Subject: [PATCH 04/13] Devnet 3 explorer: transactions (/tx/), accounts (balance, nonce, code, 7 days of transactions), the proving view (/proving), the DAG picture and the verified badge (the covering certificate re-checked in the browser with site/verify) on /explorer and /block; class and lock state per block; /api/explorer gains ?tx, ?txs, ?proving, ?stats, ?dag; the dn3_ default for /api/explorer, /api/stats, /api/supply and /api/checkpoint (?index=N); replaced chain blocks read as such against the indexer Co-Authored-By: Claude Fable 5.1 --- docs/plans/explorer.md | 18 ++ site/address.html | 67 +++++-- site/api/_neon.mjs | 5 +- site/api/checkpoint.mjs | 16 +- site/api/explorer.mjs | 226 ++++++++++++++++++--- site/block.html | 83 +++++--- site/build.mjs | 5 +- site/explorer.html | 201 +++++++++++++------ site/proving.html | 381 +++++++++++++++++++++++++++++++++++ site/sitemap.xml | 3 +- site/tx.html | 384 ++++++++++++++++++++++++++++++++++++ site/vercel.json | 144 ++++++++++++-- tools/ci/site-nav-check.mjs | 4 +- tools/observer/README.md | 4 + 14 files changed, 1387 insertions(+), 154 deletions(-) create mode 100644 site/proving.html create mode 100644 site/tx.html diff --git a/docs/plans/explorer.md b/docs/plans/explorer.md index f68f4757a..53fd65530 100644 --- a/docs/plans/explorer.md +++ b/docs/plans/explorer.md @@ -127,3 +127,21 @@ 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. Devnet 3 explorer (8 October 2026, the explorer lane; the founder's order of 11:00 UK: a public explorer the same day) + +What serves, all at igneum.network (the host explorer.igneum.network is a CNAME to the same Vercel project with a root rewrite to /explorer): + +| Page | What it shows | Data | +|---|---|---| +| `/explorer` | the Devnet 3 strip (status, height, DAA, hash rate, chain rate, finality, program class, proving), the DAG picture (newest 150 blocks, one lane per miner, parents drawn, lock and paid marks), latest blocks (class, shards paid, final), latest transactions, the proving summary, the API list | `/api/explorer?stats=1`, `?dag=150`, `?blocks=50`, `?txs=30`, `?proving=1` | +| `/block/` or `/block/` | header, parents, children, mergeset, coinbase, the EVM transactions it executed (from the indexer), the shard plan with each shard's state and the node's verdict, the checkpoint and certificate, the lock that covers it, the class; **the verified badge**: for a block under a lock the browser fetches `/api/checkpoint?index=N` and runs `site/verify/core.js` (header hashes recomputed, BLS keys aggregated, the signature checked over the weight rule) and the badge reads "verified under lock N in this browser" or the failure reason | `?block=`, `?height=`, `/api/checkpoint?index=` | +| `/tx/` | status, value, fee, gas, nonce, type, input, the receipt's Igneum section (miner tip, both burns, pgas, app share, including block and miner), the executing block's lock and shard state, the logs | `?tx=` | +| `/address/` | balance, nonce, code (contract or account), sent and received counts, the transactions of the last 7 days, the blocks mined and what they earned | `?address=` (`&before=` pages the transactions) | +| `/proving` | the node's proving state, the newest 60 chain blocks with their shards by state and median lag, the provers of 24 h with payouts, the block-to-paid latency of the last hour (p50, p90) and the 24-hour totals by state | `?proving=1` | + +The indexer: `tools/observer/explorer-indexer.mjs`, the observer's sibling on build-1 (unit `igneum-explorer-dn3`, User build, the same env file, `LIVE_TABLE_PREFIX=dn3_`, `IGNEUM_EVM_RPC` on loopback). It reads `eth_getBlockByNumber` with transactions and `eth_getBlockReceipts` per chain block, refreshes `eth_getBalance`, `eth_getTransactionCount` and `eth_getCode` for every address a block touches and for the observer's miners of the last 10 minutes, and keeps `igneum_getNodeInfo` (the class floors and every activation) in `dn3_explorer_state`. Tables `dn3_live_txs`, `dn3_live_accounts`, `dn3_explorer_blocks`, `dn3_explorer_state`; 7 days of transactions. Measured 10:04 to 10:07 UTC: 26,847 chain blocks and 71,218 transactions from genesis in 3 minutes; steady state 0.4 chain blocks per second at 16 percent of one core. Two classes met the same morning, each with its guard: the unit's first start read the first devnet's node for 25 s (the env file's `IGNEUM_EVM_RPC`), so the indexer now refuses any node whose `igneum_getNodeInfo.network` is not `EXPLORER_NETWORK`; and the observer node's restart at 10:21 UTC on 0324-5b673577 re-executed from block 0, so a tip below the indexed number re-reads from the tip and the API settles `chain_now` against the highest number ever indexed, not the current tip. + +Read against the EVM: a block the observer called a chain block whose number the EVM later gave to another block (tip reorgs on Devnet 3 run to depth 18, `igneum_getBudgets.deepestReorg` at 10:0x UTC) reads "replaced" on the list and the block page. The table prefix default of `/api/explorer`, `/api/stats`, `/api/supply` and `/api/checkpoint` moved to `dn3_` (the live deploy had served the first devnet on those four while `/api/live` served Devnet 3). + +Not yet: the STARK itself is not re-run in the browser (the node's verdict is shown as the node's; the explorer-verify branch's binding check of proof bytes to records is not merged); `eth_sendRawTransaction` and live balances from the public RPC (`rpc.devnet.igneum.network`, up since 11:08 UK) are not wired into the pages (`EXPLORER_EVM_RPC` on the deployment switches the balance read to live); a checkpoints list page; the program id per epoch (no RPC); internal transactions (no tracing RPC). diff --git a/site/address.html b/site/address.html index 1b21eab43..119ed7843 100644 --- a/site/address.html +++ b/site/address.html @@ -3,15 +3,15 @@ -Igneum address - +Igneum Devnet 3 address + - + @@ -19,7 +19,7 @@ - + @@ -92,6 +92,12 @@ dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums} @media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}} .two-wrap{margin-top:var(--gap)} main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0} +.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0} +.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)} +.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)} +.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer} +.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none} +.kv{color:var(--ash)} @@ -131,8 +137,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
Network
diff --git a/site/tx.html b/site/tx.html index 956a77e59..cf9db5afd 100644 --- a/site/tx.html +++ b/site/tx.html @@ -132,6 +132,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin GPU bench tableMeasured rates, card by card. The dev feeThe optional one, in full view, off with one flag. Testnet faucetTest coin for an address, once a day. + SwapTest tokens on Devnet 3; every swap is a proven block. Add to MetaMaskIgneum as a network in your wallet.
@@ -166,6 +167,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin GPU bench tableMeasured rates, card by card. The dev feeThe optional one, in full view, off with one flag. Testnet faucetTest coin for an address, once a day. + SwapTest tokens on Devnet 3; every swap is a proven block. Add to MetaMaskIgneum as a network in your wallet.
Network
From fa02dbcff2a2436eedb191e54765e2a1def40f80 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:40:17 +0000 Subject: [PATCH 07/13] Bridge: the Igneum certificate verifier in Solidity (BLS12-381 through the EIP-2537 precompiles, RFC 9380 hash-to-curve with the node's DST, the 2/3-of-total rule over an installed voter table, the Ethereum account proof), its Foundry suite (9 green on build-3, one certificate the old devnet carried verifies) and the vector generator Co-Authored-By: Claude Fable 5.1 --- contracts/bridge/foundry.toml | 18 ++ contracts/bridge/script/Deploy.s.sol | 43 ++++ contracts/bridge/script/Send.s.sol | 22 ++ contracts/bridge/src/BLS12381.sol | 113 +++++++++ .../bridge/src/IgneumCertificateVerifier.sol | 155 +++++++++++++ contracts/bridge/src/MerklePatricia.sol | 214 ++++++++++++++++++ contracts/bridge/test/Verifier.t.sol | 126 +++++++++++ contracts/bridge/test/Vm.sol | 33 +++ contracts/bridge/test/vectors/.gitignore | 5 + contracts/bridge/test/vectors/gen.mjs | 95 ++++++++ 10 files changed, 824 insertions(+) create mode 100644 contracts/bridge/foundry.toml create mode 100644 contracts/bridge/script/Deploy.s.sol create mode 100644 contracts/bridge/script/Send.s.sol create mode 100644 contracts/bridge/src/BLS12381.sol create mode 100644 contracts/bridge/src/IgneumCertificateVerifier.sol create mode 100644 contracts/bridge/src/MerklePatricia.sol create mode 100644 contracts/bridge/test/Verifier.t.sol create mode 100644 contracts/bridge/test/Vm.sol create mode 100644 contracts/bridge/test/vectors/.gitignore create mode 100644 contracts/bridge/test/vectors/gen.mjs diff --git a/contracts/bridge/foundry.toml b/contracts/bridge/foundry.toml new file mode 100644 index 000000000..f23f953a6 --- /dev/null +++ b/contracts/bridge/foundry.toml @@ -0,0 +1,18 @@ +[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = [] +solc_version = "0.8.28" +# The verifier calls the BLS12-381 precompiles of EIP-2537 (live on Sepolia and mainnet since Pectra), so the test EVM +# runs the Prague rules. +evm_version = "prague" +optimizer = true +optimizer_runs = 200 +via_ir = true +fs_permissions = [{ access = "read", path = "./test/vectors" }, { access = "read-write", path = "./deploy-out.json" }] +auto_detect_remappings = false + +[rpc_endpoints] +sepolia = "https://ethereum-sepolia-rpc.publicnode.com" diff --git a/contracts/bridge/script/Deploy.s.sol b/contracts/bridge/script/Deploy.s.sol new file mode 100644 index 000000000..095f8a3f5 --- /dev/null +++ b/contracts/bridge/script/Deploy.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "../test/Vm.sol"; +import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol"; + +/// Deploys the verifier on Sepolia from BRIDGE_DEPLOYER_KEY (environment, never printed), installs the voter table +/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and submits +/// that file's certificate, so the deployed contract carries one Devnet 3 checkpoint proven final from the start. +/// +/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()" +contract Deploy { + Vm constant vm = Vm(VM_ADDRESS); + + function run() external { + uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY"); + string memory j = vm.readFile(vm.envOr("BRIDGE_TABLE_JSON", "test/vectors/chain.json")); + bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys"); + uint256[] memory w = vm.parseJsonUintArray(j, ".weights"); + bytes memory packed; + uint64[] memory weights = new uint64[](w.length); + for (uint256 i = 0; i < keys.length; i++) { + packed = abi.encodePacked(packed, keys[i]); + weights[i] = uint64(w[i]); + } + uint64 index = uint64(vm.parseJsonUint(j, ".index")); + + vm.startBroadcast(key); + IgneumCertificateVerifier v = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id")); + v.installTable(index, packed, weights); + v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature")); + vm.stopBroadcast(); + + vm.writeFile( + "deploy-out.json", + string.concat( + "{\n \"IgneumCertificateVerifier\": \"", vm.toString(address(v)), "\",\n \"chain_id\": \"", vm.parseJsonString(j, ".chain_id"), + "\",\n \"table_index\": ", vm.toString(uint256(index)), ",\n \"voters\": ", vm.toString(keys.length), ",\n \"table_id\": \"", + vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", vm.toString(vm.parseJsonBytes32(j, ".checkpoint")), "\"\n}\n" + ) + ); + } +} diff --git a/contracts/bridge/script/Send.s.sol b/contracts/bridge/script/Send.s.sol new file mode 100644 index 000000000..f64168556 --- /dev/null +++ b/contracts/bridge/script/Send.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "../test/Vm.sol"; + +/// Sends SEND_WEI of the chain's coin from the key in BRIDGE_DEPLOYER_KEY to SEND_TO (Sepolia test ETH between the +/// lanes' throwaway deployers). The key is read from the environment and never printed. +/// +/// SEND_TO=0x.. SEND_WEI=20000000000000000 forge script script/Send.s.sol:Send --rpc-url sepolia --broadcast --sig "run()" +contract Send { + Vm constant vm = Vm(VM_ADDRESS); + + function run() external { + uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY"); + address to = vm.envAddress("SEND_TO"); + uint256 wei_ = vm.envUint("SEND_WEI"); + vm.startBroadcast(key); + (bool ok,) = to.call{value: wei_}(""); + require(ok, "send failed"); + vm.stopBroadcast(); + } +} diff --git a/contracts/bridge/src/BLS12381.sol b/contracts/bridge/src/BLS12381.sol new file mode 100644 index 000000000..69b842de8 --- /dev/null +++ b/contracts/bridge/src/BLS12381.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// BLS12-381 through the EIP-2537 precompiles (Ethereum mainnet and Sepolia since Pectra): the hash-to-curve of +/// RFC 9380 (BLS12381G2_XMD:SHA-256_SSWU_RO_) with the caller's domain separation tag, public-key aggregation in G1 +/// and the two-pairing check of a "minimal public key" signature (keys in G1, signatures in G2), the scheme of +/// Igneum's finality votes (consensus/core/src/finality.rs, blst "min_pk"). +/// +/// Encodings are the precompiles' own: a field element is 64 bytes (16 zero bytes then the 48-byte big-endian +/// value), a G1 point 128 bytes (x, y), a G2 point 256 bytes (x.c0, x.c1, y.c0, y.c1). Compressed chain forms +/// (48-byte keys, 96-byte signatures) are decompressed off chain by the submitter; the pairing precompile refuses +/// a point off the curve or outside the prime-order subgroup, so a wrong decompression fails the check. +library BLS12381 { + address internal constant G1ADD = address(0x0b); + address internal constant G2ADD = address(0x0d); + address internal constant PAIRING = address(0x0f); + address internal constant MAP_FP2_TO_G2 = address(0x11); + address internal constant MODEXP = address(0x05); + + uint256 internal constant G1_LEN = 128; + uint256 internal constant G2_LEN = 256; + + /// The field modulus p, big-endian, 48 bytes (the modexp precompile's modulus). + bytes internal constant P = hex"1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab"; + + /// The G1 generator with its y negated (p - y), in the 128-byte encoding, for the pairing check + /// e(pk, H(m)) * e(-G1, sig) == 1. + bytes internal constant NEG_G1 = + hex"0000000000000000000000000000000017f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb" + hex"00000000000000000000000000000000114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca"; + + error PrecompileFailed(address which); + error BadLength(string what); + + // ---- hash to curve ---- + + /// expand_message_xmd with SHA-256 (RFC 9380 section 5.3.1) to `len` bytes; len at most 255 * 32. + function expandMessageXmd(bytes memory msg_, bytes memory dst, uint256 len) internal pure returns (bytes memory out) { + require(dst.length <= 255, "BLS: DST too long"); + uint256 ell = (len + 31) / 32; + require(ell <= 255 && len > 0, "BLS: bad length"); + bytes memory dstPrime = abi.encodePacked(dst, uint8(dst.length)); + bytes32 b0 = sha256(abi.encodePacked(new bytes(64), msg_, uint16(len), uint8(0), dstPrime)); + bytes32 bi = sha256(abi.encodePacked(b0, uint8(1), dstPrime)); + out = new bytes(ell * 32); + assembly { + mstore(add(out, 32), bi) + } + for (uint256 i = 2; i <= ell; i++) { + bi = sha256(abi.encodePacked(b0 ^ bi, uint8(i), dstPrime)); + assembly { + mstore(add(add(out, 32), mul(sub(i, 1), 32)), bi) + } + } + assembly { + mstore(out, len) + } + } + + /// A 64-byte big-endian integer reduced mod p and returned in the precompiles' 64-byte field encoding. + function reduce64(bytes memory chunk, uint256 offset) internal view returns (bytes memory fe) { + require(chunk.length >= offset + 64, "BLS: chunk"); + bytes memory base = new bytes(64); + for (uint256 i = 0; i < 64; i++) { + base[i] = chunk[offset + i]; + } + // modexp(base^1 mod p): lengths 64, 1, 48 + bytes memory input = abi.encodePacked(uint256(64), uint256(1), uint256(48), base, uint8(1), P); + (bool ok, bytes memory r) = MODEXP.staticcall(input); + if (!ok || r.length != 48) revert PrecompileFailed(MODEXP); + fe = abi.encodePacked(bytes16(0), r); + } + + /// hash_to_curve for G2: two field elements of Fp2 from a 256-byte expansion, each mapped by the precompile + /// (which clears the cofactor), then added. + function hashToG2(bytes memory msg_, bytes memory dst) internal view returns (bytes memory point) { + bytes memory u = expandMessageXmd(msg_, dst, 256); + bytes memory q0 = mapFp2ToG2(abi.encodePacked(reduce64(u, 0), reduce64(u, 64))); + bytes memory q1 = mapFp2ToG2(abi.encodePacked(reduce64(u, 128), reduce64(u, 192))); + point = g2Add(q0, q1); + } + + function mapFp2ToG2(bytes memory fp2) internal view returns (bytes memory point) { + if (fp2.length != 128) revert BadLength("fp2"); + (bool ok, bytes memory r) = MAP_FP2_TO_G2.staticcall(fp2); + if (!ok || r.length != G2_LEN) revert PrecompileFailed(MAP_FP2_TO_G2); + point = r; + } + + // ---- group operations ---- + + function g1Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) { + if (a.length != G1_LEN || b.length != G1_LEN) revert BadLength("g1"); + (bool ok, bytes memory r) = G1ADD.staticcall(abi.encodePacked(a, b)); + if (!ok || r.length != G1_LEN) revert PrecompileFailed(G1ADD); + c = r; + } + + function g2Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) { + if (a.length != G2_LEN || b.length != G2_LEN) revert BadLength("g2"); + (bool ok, bytes memory r) = G2ADD.staticcall(abi.encodePacked(a, b)); + if (!ok || r.length != G2_LEN) revert PrecompileFailed(G2ADD); + c = r; + } + + /// e(pk, hm) * e(-G1, sig) == 1, which holds exactly when sig = sk * hm for pk = sk * G1. + function verifyMinPk(bytes memory pk, bytes memory hm, bytes memory sig) internal view returns (bool) { + if (pk.length != G1_LEN || hm.length != G2_LEN || sig.length != G2_LEN) revert BadLength("pairing"); + (bool ok, bytes memory r) = PAIRING.staticcall(abi.encodePacked(pk, hm, NEG_G1, sig)); + if (!ok || r.length != 32) return false; + return abi.decode(r, (uint256)) == 1; + } +} diff --git a/contracts/bridge/src/IgneumCertificateVerifier.sol b/contracts/bridge/src/IgneumCertificateVerifier.sol new file mode 100644 index 000000000..c0154b75d --- /dev/null +++ b/contracts/bridge/src/IgneumCertificateVerifier.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BLS12381} from "./BLS12381.sol"; +import {MerklePatricia} from "./MerklePatricia.sol"; + +interface IIgneumCertificateVerifier { + function chainId() external view returns (string memory); + function tableId() external view returns (bytes32); + function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) + external + view + returns (bool ok, uint256 signedWeight, uint256 totalWeight); + function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external; + function finalCheckpoint(uint64 index) external view returns (bytes32); + function isFinal(bytes32 checkpoint) external view returns (bool); + function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof) + external + pure + returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash); +} + +/// The Igneum light-client bridge primitive on Ethereum: verifies a Devnet 3 finality certificate (the aggregate +/// BLS signature of the canonical voter list over the vote message, under the 2/3-of-total-weight rule) against an +/// installed voter table, records the checkpoint hashes it proved final, and verifies an Ethereum-shape account +/// proof against a state root. What it proves and what it does not: docs/bridge/light-client-bridge.md. +/// +/// Devnet 3, test tokens, no value. +contract IgneumCertificateVerifier is IIgneumCertificateVerifier { + using MerklePatricia for bytes32; + + string public constant VOTE_PREFIX = "igneum-vote-v1/"; + bytes public constant DST_VOTE = "IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_"; + + string private _chainId; + address public owner; + + /// The canonical voter list at the installed checkpoint index: 128-byte G1 keys in the node's canonical order + /// (sorted by key hash, every key above dust and not stripped) and their weights (blue blocks in the window). + bytes[] private _keys; + uint64[] private _weights; + uint256 public totalWeight; + uint64 public tableIndex; + bytes32 public override tableId; + + mapping(uint64 => bytes32) public override finalCheckpoint; + mapping(bytes32 => bool) public override isFinal; + + event TableInstalled(uint64 indexed atIndex, uint256 voters, uint256 totalWeight, bytes32 tableId); + event CheckpointFinal(uint64 indexed index, bytes32 checkpoint, uint256 signedWeight, uint256 totalWeight, uint256 signers); + + error NotOwner(); + error NoTable(); + error BadCertificate(string why); + + constructor(string memory chainId_) { + _chainId = chainId_; + owner = msg.sender; + } + + function chainId() external view override returns (string memory) { + return _chainId; + } + + function voterCount() external view returns (uint256) { + return _keys.length; + } + + function voter(uint256 i) external view returns (bytes memory key, uint64 weight) { + return (_keys[i], _weights[i]); + } + + /// Installs the voter table read from a Devnet 3 node (igneum_getFinalityWeights at `atIndex`): `keys` is the + /// concatenation of 128-byte uncompressed G1 keys in canonical order, `weights` their weights. The table is a + /// trusted input of this first version (see the doc); only the installer may replace it. + function installTable(uint64 atIndex, bytes calldata keys, uint64[] calldata weights) external { + if (msg.sender != owner) revert NotOwner(); + if (keys.length != weights.length * BLS12381.G1_LEN || weights.length == 0) revert BadCertificate("table shape"); + delete _keys; + delete _weights; + uint256 total; + for (uint256 i = 0; i < weights.length; i++) { + _keys.push(keys[i * BLS12381.G1_LEN:(i + 1) * BLS12381.G1_LEN]); + _weights.push(weights[i]); + total += weights[i]; + } + totalWeight = total; + tableIndex = atIndex; + tableId = keccak256(abi.encodePacked(atIndex, keys, abi.encodePacked(weights))); + emit TableInstalled(atIndex, weights.length, total, tableId); + } + + /// The bytes every voter signs for (index, checkpoint): "igneum-vote-v1/" chain_id 0x00 index_le64 checkpoint. + function voteMessage(uint64 index, bytes32 checkpoint) public view returns (bytes memory) { + return abi.encodePacked(VOTE_PREFIX, _chainId, bytes1(0), le64(index), checkpoint); + } + + function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) + public + view + override + returns (bool ok, uint256 signedWeight, uint256 totalWeight_) + { + uint256 n = _keys.length; + if (n == 0) revert NoTable(); + if (bitmap.length != (n + 7) / 8) revert BadCertificate("bitmap length"); + if (signature.length != BLS12381.G2_LEN) revert BadCertificate("signature length"); + bytes memory agg; + uint256 signers; + for (uint256 p = 0; p < n; p++) { + if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) == 0) continue; + signedWeight += _weights[p]; + signers++; + agg = agg.length == 0 ? _keys[p] : BLS12381.g1Add(agg, _keys[p]); + } + totalWeight_ = totalWeight; + if (signers == 0) return (false, 0, totalWeight_); + // the rule decided 4 October 2026: signed weight at least two thirds of the whole window's weight + if (3 * signedWeight < 2 * totalWeight_) return (false, signedWeight, totalWeight_); + bytes memory hm = BLS12381.hashToG2(voteMessage(index, checkpoint), DST_VOTE); + ok = BLS12381.verifyMinPk(agg, hm, signature); + } + + function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external override { + (bool ok, uint256 signed, uint256 total) = verifyCertificate(index, checkpoint, bitmap, signature); + if (!ok) revert BadCertificate("certificate does not verify"); + bytes32 known = finalCheckpoint[index]; + if (known != bytes32(0) && known != checkpoint) revert BadCertificate("a different checkpoint is final at this index"); + finalCheckpoint[index] = checkpoint; + isFinal[checkpoint] = true; + uint256 signers; + for (uint256 p = 0; p < _keys.length; p++) { + if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) != 0) signers++; + } + emit CheckpointFinal(index, checkpoint, signed, total, signers); + } + + function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof) + external + pure + override + returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash) + { + MerklePatricia.Account memory a = MerklePatricia.verifyAccount(stateRoot, account, proof); + return (a.exists, a.nonce, a.balance, a.storageRoot, a.codeHash); + } + + function le64(uint64 v) internal pure returns (bytes8 out) { + uint64 r; + for (uint256 i = 0; i < 8; i++) { + r = (r << 8) | ((v >> (8 * i)) & 0xff); + } + out = bytes8(r); + } +} diff --git a/contracts/bridge/src/MerklePatricia.sol b/contracts/bridge/src/MerklePatricia.sol new file mode 100644 index 000000000..511c9c821 --- /dev/null +++ b/contracts/bridge/src/MerklePatricia.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// An Ethereum account proof (the eth_getProof shape) checked against a state root: the keccak-keyed Merkle +/// Patricia trie of reth's layout, which Igneum's executor uses for its stateRoot (igneum/exec/src/state.rs, +/// alloy_trie::root::state_root over keccak256(address) keys and RLP(nonce, balance, storageRoot, codeHash) +/// values). A proof is the list of RLP nodes from the root to the account's leaf, or to the branch or leaf that +/// shows the account absent. +library MerklePatricia { + struct Account { + bool exists; + uint256 nonce; + uint256 balance; + bytes32 storageRoot; + bytes32 codeHash; + } + + error BadProof(string why); + + /// Verifies `proof` for `account` under `stateRoot`; reverts when a node does not hash to its reference or + /// the path is malformed, returns exists=false when the trie shows no such account. + function verifyAccount(bytes32 stateRoot, address account, bytes[] memory proof) internal pure returns (Account memory out) { + bytes memory value = verifyPath(stateRoot, abi.encodePacked(keccak256(abi.encodePacked(account))), proof); + if (value.length == 0) return out; + (uint256 off, uint256 len, bool isList) = decode(value, 0); + if (!isList) revert BadProof("account value is not a list"); + uint256 end = off + len; + uint256 p = off; + (uint256 o1, uint256 l1,) = decode(value, p); + out.nonce = toUint(value, o1, l1); + p = o1 + l1; + (uint256 o2, uint256 l2,) = decode(value, p); + out.balance = toUint(value, o2, l2); + p = o2 + l2; + (uint256 o3, uint256 l3,) = decode(value, p); + if (l3 != 32) revert BadProof("storage root length"); + out.storageRoot = toBytes32(value, o3); + p = o3 + l3; + (uint256 o4, uint256 l4,) = decode(value, p); + if (l4 != 32) revert BadProof("code hash length"); + out.codeHash = toBytes32(value, o4); + if (o4 + l4 != end) revert BadProof("account value has extra fields"); + out.exists = true; + } + + /// Walks the proof for `key` (32 bytes, hashed already) from `root`; returns the value found, or empty bytes + /// when the trie proves the key absent. + function verifyPath(bytes32 root, bytes memory key, bytes[] memory proof) internal pure returns (bytes memory value) { + bytes memory nibbles = toNibbles(key); + uint256 pos = 0; + bytes32 want = root; + bytes memory embedded; + for (uint256 i = 0; i < proof.length; i++) { + bytes memory node = proof[i]; + if (embedded.length != 0) { + if (keccak256(node) != keccak256(embedded)) revert BadProof("embedded node mismatch"); + embedded = ""; + } else if (keccak256(node) != want) { + revert BadProof("node hash mismatch"); + } + (uint256 off, uint256 len, bool isList) = decode(node, 0); + if (!isList) revert BadProof("node is not a list"); + uint256 count = itemCount(node, off, len); + if (count == 17) { + if (pos == nibbles.length) { + // the branch's own value slot + (uint256 vo, uint256 vl,) = itemAt(node, off, 16); + return slice(node, vo, vl); + } + uint8 nib = uint8(nibbles[pos]); + (uint256 co, uint256 cl, bool clist) = itemAt(node, off, nib); + if (cl == 0 && !clist) return ""; // empty slot: the key is absent + pos++; + if (clist) { + embedded = slice(node, co - headerLen(node, co, cl, true), cl + headerLen(node, co, cl, true)); + } else { + if (cl != 32) revert BadProof("child reference length"); + want = toBytes32(node, co); + } + } else if (count == 2) { + (uint256 po, uint256 pl,) = itemAt(node, off, 0); + (bytes memory path, bool isLeaf) = decodePath(slice(node, po, pl)); + if (!matches(nibbles, pos, path)) return ""; // diverging path: the key is absent + pos += path.length; + (uint256 vo, uint256 vl, bool vlist) = itemAt(node, off, 1); + if (isLeaf) { + if (pos != nibbles.length) revert BadProof("leaf before the key's end"); + return slice(node, vo, vl); + } + if (vlist) { + embedded = slice(node, vo - headerLen(node, vo, vl, true), vl + headerLen(node, vo, vl, true)); + } else { + if (vl != 32) revert BadProof("extension reference length"); + want = toBytes32(node, vo); + } + } else { + revert BadProof("node arity"); + } + } + revert BadProof("proof ends before the key"); + } + + // ---- paths ---- + + function toNibbles(bytes memory key) internal pure returns (bytes memory n) { + n = new bytes(key.length * 2); + for (uint256 i = 0; i < key.length; i++) { + n[2 * i] = bytes1(uint8(key[i]) >> 4); + n[2 * i + 1] = bytes1(uint8(key[i]) & 0x0f); + } + } + + /// Hex-prefix decoding of a leaf or extension path. + function decodePath(bytes memory hp) internal pure returns (bytes memory path, bool isLeaf) { + if (hp.length == 0) revert BadProof("empty path"); + uint8 flag = uint8(hp[0]) >> 4; + isLeaf = flag >= 2; + bool odd = flag % 2 == 1; + uint256 n = (hp.length - 1) * 2 + (odd ? 1 : 0); + path = new bytes(n); + uint256 w = 0; + if (odd) path[w++] = bytes1(uint8(hp[0]) & 0x0f); + for (uint256 i = 1; i < hp.length; i++) { + path[w++] = bytes1(uint8(hp[i]) >> 4); + path[w++] = bytes1(uint8(hp[i]) & 0x0f); + } + } + + function matches(bytes memory nibbles, uint256 pos, bytes memory path) internal pure returns (bool) { + if (pos + path.length > nibbles.length) return false; + for (uint256 i = 0; i < path.length; i++) { + if (nibbles[pos + i] != path[i]) return false; + } + return true; + } + + // ---- RLP ---- + + /// The item at `p`: the offset of its payload, the payload length and whether it is a list. + function decode(bytes memory b, uint256 p) internal pure returns (uint256 off, uint256 len, bool isList) { + if (p >= b.length) revert BadProof("rlp out of range"); + uint8 first = uint8(b[p]); + if (first < 0x80) return (p, 1, false); + if (first < 0xb8) return (p + 1, first - 0x80, false); + if (first < 0xc0) { + uint256 n = first - 0xb7; + return (p + 1 + n, readLen(b, p + 1, n), false); + } + if (first < 0xf8) return (p + 1, first - 0xc0, true); + uint256 m = first - 0xf7; + return (p + 1 + m, readLen(b, p + 1, m), true); + } + + function headerLen(bytes memory b, uint256 off, uint256 len, bool isList) private pure returns (uint256) { + // the header length of an item whose payload starts at off: single bytes under 0x80 have none + if (!isList && len == 1 && uint8(b[off]) < 0x80) return 0; + if (len < 56) return 1; + uint256 n = 0; + uint256 l = len; + while (l > 0) { + n++; + l >>= 8; + } + return 1 + n; + } + + function readLen(bytes memory b, uint256 p, uint256 n) private pure returns (uint256 len) { + if (n == 0 || n > 32 || p + n > b.length) revert BadProof("rlp length"); + for (uint256 i = 0; i < n; i++) { + len = (len << 8) | uint8(b[p + i]); + } + } + + function itemCount(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 n) { + uint256 p = off; + uint256 end = off + len; + while (p < end) { + (uint256 o, uint256 l,) = decode(b, p); + p = o + l; + n++; + } + if (p != end) revert BadProof("rlp list overrun"); + } + + function itemAt(bytes memory b, uint256 off, uint256 index) private pure returns (uint256 o, uint256 l, bool isList) { + uint256 p = off; + for (uint256 i = 0; ; i++) { + (o, l, isList) = decode(b, p); + if (i == index) return (o, l, isList); + p = o + l; + } + } + + function toUint(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 v) { + if (len > 32) revert BadProof("integer too long"); + for (uint256 i = 0; i < len; i++) { + v = (v << 8) | uint8(b[off + i]); + } + } + + function toBytes32(bytes memory b, uint256 off) private pure returns (bytes32 v) { + assembly { + v := mload(add(add(b, 32), off)) + } + } + + function slice(bytes memory b, uint256 off, uint256 len) private pure returns (bytes memory out) { + if (off + len > b.length) revert BadProof("slice out of range"); + out = new bytes(len); + for (uint256 i = 0; i < len; i++) { + out[i] = b[off + i]; + } + } +} diff --git a/contracts/bridge/test/Verifier.t.sol b/contracts/bridge/test/Verifier.t.sol new file mode 100644 index 000000000..0018d7547 --- /dev/null +++ b/contracts/bridge/test/Verifier.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "./Vm.sol"; +import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol"; +import {BLS12381} from "../src/BLS12381.sol"; + +/// The verifier on Foundry's Prague EVM (the EIP-2537 precompiles): the synthetic vectors made by +/// test/vectors/gen.mjs (five keys, a certificate by four of them, a small account trie) and, when present, a real +/// certificate from the chain (test/vectors/chain.json, as /api/checkpoint serves it, decompressed by gen.mjs). +contract VerifierTest { + Vm constant vm = Vm(VM_ADDRESS); + + string json; + IgneumCertificateVerifier v; + + function setUp() public { + json = vm.readFile("test/vectors/synthetic.json"); + v = new IgneumCertificateVerifier(vm.parseJsonString(json, ".chain_id")); + _install(v, json); + } + + function _install(IgneumCertificateVerifier target, string memory j) internal { + bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys"); + uint256[] memory w = vm.parseJsonUintArray(j, ".weights"); + bytes memory packed; + uint64[] memory weights = new uint64[](w.length); + for (uint256 i = 0; i < keys.length; i++) { + packed = abi.encodePacked(packed, keys[i]); + weights[i] = uint64(w[i]); + } + target.installTable(uint64(vm.parseJsonUint(j, ".index")), packed, weights); + } + + function test_vote_message_matches_the_node() public view { + bytes memory want = vm.parseJsonBytes(json, ".vote_message"); + bytes memory got = v.voteMessage(uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint")); + require(keccak256(want) == keccak256(got), "vote message"); + } + + function test_expand_message_xmd_known_answer() public view { + // RFC 9380 appendix K.1 (expand_message_xmd with SHA-256, DST "QUUX-V01-CS02-with-expander-SHA256-128"): the + // empty message at 32 bytes is the appendix's own first answer; the "abc" at 128 bytes answer comes from noble + bytes memory dst = bytes(vm.parseJsonString(json, ".xmd_dst")); + bytes memory out = BLS12381.expandMessageXmd("", dst, 32); + require(keccak256(out) == keccak256(hex"68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235"), "xmd 32 (RFC)"); + require(keccak256(out) == keccak256(vm.parseJsonBytes(json, ".xmd_empty_32")), "xmd 32 (noble)"); + bytes memory out2 = BLS12381.expandMessageXmd("abc", dst, 128); + require(keccak256(out2) == keccak256(vm.parseJsonBytes(json, ".xmd_abc_128")), "xmd 128 (noble)"); + } + + function test_certificate_verifies() public view { + (bool ok, uint256 signed, uint256 total) = v.verifyCertificate( + uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature") + ); + require(ok, "certificate"); + require(signed == vm.parseJsonUint(json, ".signed_weight") && total == vm.parseJsonUint(json, ".total_weight"), "weights"); + } + + function test_certificate_under_two_thirds_is_refused() public view { + (bool ok, uint256 signed,) = v.verifyCertificate( + uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature") + ); + require(!ok && signed * 3 < vm.parseJsonUint(json, ".total_weight") * 2, "weak certificate accepted"); + } + + function test_wrong_checkpoint_or_index_fails() public view { + bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint"); + uint64 index = uint64(vm.parseJsonUint(json, ".index")); + bytes memory bm = vm.parseJsonBytes(json, ".bitmap"); + bytes memory sig = vm.parseJsonBytes(json, ".signature"); + (bool ok1,,) = v.verifyCertificate(index, cp ^ bytes32(uint256(1)), bm, sig); + (bool ok2,,) = v.verifyCertificate(index + 1, cp, bm, sig); + require(!ok1 && !ok2, "forged certificate accepted"); + // the right signers' weight with a bitmap naming a different signer set does not match the signature + bytes memory other = vm.parseJsonBytes(json, ".weak_bitmap"); + other[0] = bytes1(uint8(other[0]) | 0x1f); + (bool ok3,,) = v.verifyCertificate(index, cp, other, sig); + require(!ok3, "wrong signer set accepted"); + } + + function test_submit_records_the_checkpoint() public { + bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint"); + uint64 index = uint64(vm.parseJsonUint(json, ".index")); + v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature")); + require(v.isFinal(cp) && v.finalCheckpoint(index) == cp, "not recorded"); + vm.expectRevert(abi.encodeWithSelector(IgneumCertificateVerifier.BadCertificate.selector, "certificate does not verify")); + v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature")); + } + + function test_account_proof_present_and_absent() public view { + bytes32 root = vm.parseJsonBytes32(json, ".state_root"); + (bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) = + v.verifyAccount(root, vm.parseJsonAddress(json, ".account"), vm.parseJsonBytesArray(json, ".account_proof")); + require(exists, "account absent"); + require(nonce == vm.parseJsonUint(json, ".account_nonce") && balance == vm.parseJsonUint(json, ".account_balance"), "account fields"); + require(sroot == vm.parseJsonBytes32(json, ".account_storage_root") && chash == vm.parseJsonBytes32(json, ".account_code_hash"), "account roots"); + (bool exists2,,,,) = v.verifyAccount(root, vm.parseJsonAddress(json, ".absent_account"), vm.parseJsonBytesArray(json, ".absent_proof")); + require(!exists2, "absent account present"); + } + + function test_account_proof_against_a_wrong_root_reverts() public { + bytes32 root = vm.parseJsonBytes32(json, ".state_root") ^ bytes32(uint256(1)); + bytes[] memory proof = vm.parseJsonBytesArray(json, ".account_proof"); + address a = vm.parseJsonAddress(json, ".account"); + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("BadProof(string)")), "node hash mismatch")); + v.verifyAccount(root, a, proof); + } + + /// A certificate the chain actually carried (test/vectors/chain.json; skipped when the file is absent). + function test_chain_certificate_verifies() public { + string memory j; + try vm.readFile("test/vectors/chain.json") returns (string memory s) { + j = s; + } catch { + return; + } + IgneumCertificateVerifier c = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id")); + _install(c, j); + (bool ok, uint256 signed, uint256 total) = c.verifyCertificate( + uint64(vm.parseJsonUint(j, ".index")), vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature") + ); + require(signed == vm.parseJsonUint(j, ".signed_weight") && total == vm.parseJsonUint(j, ".total_weight"), "chain weights"); + require(ok, "the chain's certificate does not verify"); + } +} diff --git a/contracts/bridge/test/Vm.sol b/contracts/bridge/test/Vm.sol new file mode 100644 index 000000000..57f628e6c --- /dev/null +++ b/contracts/bridge/test/Vm.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency. +interface Vm { + function startBroadcast(uint256 privateKey) external; + function stopBroadcast() external; + function envUint(string calldata name) external view returns (uint256); + function envAddress(string calldata name) external view returns (address); + function envOr(string calldata name, string calldata defaultValue) external view returns (string memory); + function toString(address value) external pure returns (string memory); + function toString(uint256 value) external pure returns (string memory); + function toString(bytes32 value) external pure returns (string memory); + function toString(bytes calldata value) external pure returns (string memory); + function readFile(string calldata path) external view returns (string memory); + function writeFile(string calldata path, string calldata data) external; + function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory); + function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32); + function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256); + function parseJsonString(string calldata json, string calldata key) external pure returns (string memory); + function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory); + function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory); + function parseJsonAddress(string calldata json, string calldata key) external pure returns (address); + function deal(address who, uint256 newBalance) external; + function prank(address msgSender) external; + function startPrank(address msgSender) external; + function stopPrank() external; + function warp(uint256 newTimestamp) external; + function expectRevert(bytes calldata revertData) external; + function addr(uint256 privateKey) external pure returns (address); +} + +address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); diff --git a/contracts/bridge/test/vectors/.gitignore b/contracts/bridge/test/vectors/.gitignore new file mode 100644 index 000000000..7741964bf --- /dev/null +++ b/contracts/bridge/test/vectors/.gitignore @@ -0,0 +1,5 @@ +node_modules +synthetic.json +chain.json +checkpoint-live.json +checkpoint-dn3.json diff --git a/contracts/bridge/test/vectors/gen.mjs b/contracts/bridge/test/vectors/gen.mjs new file mode 100644 index 000000000..df273fc82 --- /dev/null +++ b/contracts/bridge/test/vectors/gen.mjs @@ -0,0 +1,95 @@ +// Test vectors for the Igneum certificate verifier. Two sources: +// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four +// of them over the Devnet 3 vote message, a small account trie with proofs +// node gen.mjs chain > dn3.json a real certificate as igneum.network/api/checkpoint?source=dn3 serves it: +// the voter table and the aggregate signature decompressed to the +// precompiles' encodings (the verifier checks the same bytes the node signed) +// Encodings: field element 64 bytes (16 zero bytes then 48), G1 128 bytes, G2 256 bytes (x.c0, x.c1, y.c0, y.c1). +// The DST and the vote message follow consensus/core/src/finality.rs and site/verify/core.js. +import { bls12_381 } from '@noble/curves/bls12-381'; +import { expand_message_xmd } from '@noble/curves/abstract/hash-to-curve'; +import { sha256 } from '@noble/hashes/sha256'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const DST = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_'; +const CHAIN_ID = 'igneum-devnet-3'; +const te = new TextEncoder(); +const hex = b => '0x' + Array.from(b, x => x.toString(16).padStart(2, '0')).join(''); +const unhex = h => Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex')); +const be = (n, len) => { const out = new Uint8Array(len); let v = BigInt(n); for (let i = len - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; }; +const fe = n => { const out = new Uint8Array(64); out.set(be(n, 48), 16); return out; }; +const concat = parts => { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; }; +const u64le = n => { const out = new Uint8Array(8); let v = BigInt(n); for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; }; + +const G1 = bls12_381.G1.ProjectivePoint, G2 = bls12_381.G2.ProjectivePoint; +function g1Enc(p) { const a = p.toAffine(); return concat([fe(a.x), fe(a.y)]); } +function g2Enc(p) { const a = p.toAffine(); return concat([fe(a.x.c0), fe(a.x.c1), fe(a.y.c0), fe(a.y.c1)]); } +function voteMessage(index, checkpointHex) { return concat([te.encode('igneum-vote-v1/' + CHAIN_ID), new Uint8Array([0]), u64le(index), unhex(checkpointHex)]); } +function bitmapOf(positions, n) { const bm = new Uint8Array(Math.ceil(n / 8)); for (const p of positions) bm[p >> 3] |= 1 << (p & 7); return bm; } + +async function synthetic() { + const sks = [1, 2, 3, 4, 5].map(i => { const s = new Uint8Array(32); s[31] = i; s[0] = 0x11 * i; return bls12_381.utils.randomPrivateKey ? bls12_381.G1.normPrivateKeyToScalar(s) : s; }); + const keys = sks.map(sk => G1.BASE.multiply(sk)); + const weights = [100, 250, 400, 300, 150]; + const index = 1234, checkpoint = '0x' + 'ab'.repeat(32); + const msg = voteMessage(index, checkpoint); + const hm = bls12_381.G2.hashToCurve(msg, { DST }); + const signers = [0, 1, 2, 3]; // 1,050 of 1,200: above two thirds + const weakSigners = [0, 2, 4]; // 650 of 1,200: under two thirds + const sign = who => who.map(i => hm.multiply(sks[i])).reduce((a, b) => a.add(b)); + const sig = sign(signers), weak = sign(weakSigners); + // the account trie: three accounts, proofs for one present and one absent + const { Trie } = require('@ethereumjs/trie'); + const { RLP } = require('@ethereumjs/rlp'); + const { keccak256 } = require('ethereum-cryptography/keccak'); + const trie = new Trie({ useKeyHashing: true }); + const accounts = [ + { address: '0x07dd4dbca5c1a66755af28bacca1d901a2d209aa', nonce: 7n, balance: 999174011168718479514n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' }, + { address: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', nonce: 1n, balance: 0n, storageRoot: '0x' + '11'.repeat(32), codeHash: '0x' + '22'.repeat(32) }, + { address: '0x53fe98022c2ac26d5d721457fb1c374b4d56144b', nonce: 3n, balance: 2580n * 10n ** 18n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' }, + ]; + for (const a of accounts) { + const v = RLP.encode([a.nonce === 0n ? new Uint8Array() : be(a.nonce, Math.ceil(a.nonce.toString(2).length / 8)), a.balance === 0n ? new Uint8Array() : be(a.balance, Math.ceil(a.balance.toString(2).length / 8)), unhex(a.storageRoot), unhex(a.codeHash)]); + await trie.put(unhex(a.address), v); + } + const root = hex(trie.root()); + const proofFor = async addr => (await trie.createProof(unhex(addr))).map(hex); + const absent = '0x000000000000000000000000000000000000dead'; + return { + chain_id: CHAIN_ID, dst: DST, index, checkpoint, + keys: keys.map(k => hex(g1Enc(k))), weights, total_weight: weights.reduce((a, b) => a + b, 0), + bitmap: hex(bitmapOf(signers, keys.length)), signature: hex(g2Enc(sig)), signed_weight: signers.reduce((a, i) => a + weights[i], 0), + weak_bitmap: hex(bitmapOf(weakSigners, keys.length)), weak_signature: hex(g2Enc(weak)), + vote_message: hex(msg), + // RFC 9380 expand_message_xmd(SHA-256) answers, computed by noble, for the Solidity port's own check + xmd_dst: 'QUUX-V01-CS02-with-expander-SHA256-128', + xmd_abc_128: hex(expand_message_xmd(te.encode('abc'), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 128, sha256)), + xmd_empty_32: hex(expand_message_xmd(new Uint8Array(), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 32, sha256)), + state_root: root, + account: accounts[0].address, account_nonce: accounts[0].nonce.toString(), account_balance: accounts[0].balance.toString(), + account_storage_root: accounts[0].storageRoot, account_code_hash: accounts[0].codeHash, + account_proof: await proofFor(accounts[0].address), + absent_account: absent, absent_proof: await proofFor(absent), + }; +} + +function chain(file) { + const d = JSON.parse(readFileSync(file, 'utf8')); + const voters = d.voters.map(v => ({ key: hex(g1Enc(G1.fromHex(v.pubkey_hex.replace(/^0x/, '')))), weight: Math.round(Number(v.weight)) })); + const sig = G2.fromHex(d.certificate.aggregate_signature_hex.replace(/^0x/, '')); + const positions = []; const bm = unhex(d.certificate.bitmap_hex); + for (let p = 0; p < voters.length; p++) if (bm[p >> 3] & (1 << (p & 7))) positions.push(p); + return { + source: d.source, chain_id: d.chain_id, dst: DST, index: d.index, checkpoint: '0x' + d.hash, + keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: voters.reduce((a, v) => a + v.weight, 0), + bitmap: '0x' + d.certificate.bitmap_hex, signature: hex(g2Enc(sig)), signed_weight: positions.reduce((a, p) => a + voters[p].weight, 0), + signers: positions.length, voters_at_index: d.voters_at_index, stored_at: d.stored_at, + }; +} + +const mode = process.argv[2]; +if (mode === 'synthetic') synthetic().then(v => console.log(JSON.stringify(v, null, 1))); +else if (mode === 'chain') console.log(JSON.stringify(chain(process.argv[3]), null, 1)); +else { console.error('usage: gen.mjs synthetic | chain '); process.exit(2); } From d5e8a959e9136d1ad928c3de030ad303b73e5a53 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:42:40 +0000 Subject: [PATCH 08/13] Explorer: names its one source, the observer node (the chain the certificates follow), on the index, block and transaction pages Co-Authored-By: Claude Fable 5.1 --- site/block.html | 2 +- site/explorer.html | 2 +- site/tx.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/block.html b/site/block.html index 1b0b39660..3fba3f34d 100644 --- a/site/block.html +++ b/site/block.html @@ -400,7 +400,7 @@ async function load() { 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).${b.chain ? ` Its own execution is split into ${sum.total} shard${sum.total === 1 ? '' : 's'}: ${sum.paid} paid, ${sum.verified} verified, ${sum.proving} proving, ${sum.planned} planned.` : ''}

` + - (sh.length ? `
${sh.map(s => ``).join('')}
ShardStateProverpgasProof lag, DAACarried byPayoutNode verdict
${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.node_verified === true ? 'proof verified by the node' : s.node_verified === false ? 'not verified' : 'no record yet'}

State follows the record: planned when the node published the shard plan, proving when a prover's record is in the pool, verified when the node's SP1 verifier accepted the proof, paid when a later coinbase carried and paid it. The explorer does not re-run the STARK itself; the node's verdict is shown as the node's.

` : (b.chain ? '
No shard plan recorded (the proving feed has not read this block).
' : '')); + (sh.length ? `
${sh.map(s => ``).join('')}
ShardStateProverpgasProof lag, DAACarried byPayoutNode verdict
${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.node_verified === true ? 'proof verified by the node' : s.node_verified === false ? 'not verified' : 'no record yet'}

State follows the record: planned when the node published the shard plan, proving when a prover's record is in the pool, verified when the node's SP1 verifier accepted the proof, paid when a later coinbase carried and paid it. The explorer does not re-run the STARK itself; the node's verdict is shown as the node's. Every state, number and record on this page is read from the observer node, the chain the finality certificates follow.

` : (b.chain ? '
No shard plan recorded (the proving feed has not read 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/explorer.html b/site/explorer.html index b4c51e9bf..e47c83d5d 100644 --- a/site/explorer.html +++ b/site/explorer.html @@ -242,7 +242,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
Devnet 3, chain id 4463

Devnet 3 explorer.

-

Every block Devnet 3 made in the last day and every transaction of the last seven days, read from a node by the observer and the indexer. Paste a block hash, a transaction hash, a chain block number or an address, or click any row. Devnet 3 is a test network: its coins have no value and the chain may reset.

+

Every block Devnet 3 made in the last day and every transaction of the last seven days, read from a node by the observer and the indexer. Paste a block hash, a transaction hash, a chain block number or an address, or click any row. Devnet 3 is a test network: its coins have no value and the chain may reset. Every number here comes from one node, the observer node on the build box, the chain the finality certificates follow; the indexer reads that node's execution RPC and nothing else.