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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 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 7/8] 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.