igneum/site/api/explorer.mjs
igneum-labs 1cd9c1dd91 Explorer: /proof/<hash> verifies a block's shard proof in the browser against the chain's record; the native SP1 verdict beside it; P17 state words on the block and explorer pages; /api/stats names the live program class (C46)
What a stranger sees: paste a chain block hash on /proof, the page downloads the captured proof bytes
(1,272,897 bytes), hashes them in the tab against the proof_hash the signed record carries, parses the
328-byte public values out of the SP1 container and checks keccak against the record's statement and the
decoded fields against the block (site/lib/proof.mjs, no library). The STARK is verified by this site's
node (the observer runs igneum-prove-host --mode verify with the pinned key on each capture: 29 ms verify,
197 ms key setup on the fixture proof); the page says so and labels the in-browser STARK verifier as coming.
docs/plans/explorer.md section 8 carries the size and time numbers and the two routes (Groth16 wrap plus
sp1-verifier in wasm, or the compressed verifier ported to wasm32).

Observer: a sample of pool proofs captured through igneum_getProofBytes while the node holds them
(PROOF_CAPTURE_EVERY_MS, PROOF_BYTES_KEEP), checked and verified, written to live_proof_bytes; every
live_proofs row carries the record (key_hash, payout, statement, proof_hash); getBlockTemplate.powEpoch
read every 10 s into live_state.pow_epoch. RPC load: wrpc 230 to 248 per minute against 222 to 224 before,
evm unchanged.

P17: the node release 0.3.13 (bb43e9a8) does not carry the state field (it is on ledger-fixes-0311
fbb0082a), so the explorer cuts the one word from the observer's tables by the design 2.4 rule and takes the
node's word per transaction when the fork answers one. A block that left the selected chain reads included
with a note, never reorged out.

C46: /api/stats algorithm reads "class v3 / generator 3 (epoch 55; ...)" from the node's epoch line, v4
when the node reports 4, "unknown" before the observer has read it; new lottery field.

Tests: site/lib/proof.test.mjs (the real tail of block 59199's proof reproduces the host's statement),
site/api/verify.test.mjs, tools/observer/proof-capture.test.mjs (the native verifier refusing a pre-pin
proof), site/api/public-stats.test.mjs. Dry run on the fixture proof of block 56 through the local preview:
VERIFIED, 5.8 ms of checks and 139 ms of download in the browser, STARK 29 ms on the node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:15:44 +00:00

181 lines
17 KiB
JavaScript

// Igneum DAG explorer, server half. GET /api/explorer with one of:
// ?blocks=N[&before=<ms>] the latest N blocks (10 to 200, default 50), older than `before` when given
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions, proof records, shards,
// checkpoint and certificate when it is one, children, the chain block that merged it
// ?height=N the chain block with that number (the EVM block number), same shape as ?block
// ?address=<0x.. | igneum..:bech32> blocks mined, 24-hour and 10-minute counts, what those blocks earned, and the
// balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC
// ?search=<q> routes a hash, a height or an address to its page, or says what it is not
// Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s.
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
import { classify } from '../lib/explorer.mjs';
import { blockState, txState } from '../lib/proof.mjs';
const STALE_AFTER_S = 30;
const ROW = `hash, number, blue_score, daa_score, timestamp_ms, parents, parent_hashes, is_chain_block, vote_key_hash, miner_address, evm_miner, engine,
tx_count, proof_records, subsidy_sompi, paid_sompi, selected_parent, color, received_at`;
function row(b) {
return {
hash: b.hash, number: num(b.number), blue_score: num(b.blue_score), daa: num(b.daa_score), ts: num(b.timestamp_ms), rx: new Date(b.received_at).getTime(),
parents: num(b.parents), parent_hashes: b.parent_hashes || [], chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending',
miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, vote_key_hash: b.vote_key_hash || null, miner: b.miner_address || null, evm_miner: b.evm_miner || null, engine: b.engine || null,
tx_count: num(b.tx_count), proof_records: num(b.proof_records), subsidy_sompi: b.subsidy_sompi === null || b.subsidy_sompi === undefined ? null : String(b.subsidy_sompi),
paid_sompi: b.paid_sompi === null || b.paid_sompi === undefined ? null : String(b.paid_sompi), selected_parent: b.selected_parent || null,
};
}
async function evmCall(url, method, params) {
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(4000) });
const j = await r.json();
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
return j.result;
}
export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
return async function handler(req, res) {
res.setHeader('Cache-Control', 'public, max-age=5, s-maxage=5');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
const q = new URL(req.url || '/', 'http://x').searchParams;
const T = tablePrefix();
const EVM = env.EXPLORER_EVM_RPC || null;
try {
sql = sql || neon(env.DATABASE_URL);
const head = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`);
const now = new Date(head[0].now).getTime(); const s = head[0].state || null;
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
// the finality view every state word is cut against (design 2.4, ledger P17): the latest locked checkpoint and whether finality is active
const fin = s && s.finality ? { active: !!s.finality.finality_active, latest_locked_index: num(s.finality.latest_locked_index), latest_locked_blue_score: num(s.finality.latest_locked_blue_score) } : { active: false, latest_locked_index: null, latest_locked_blue_score: null };
const base = { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S, evm_rpc_configured: !!EVM, finality: fin };
const stateOf = (b, shardStates, mergedLocked = null) => blockState({ chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending', number: num(b.number), blue_score: num(b.blue_score), shards: shardStates, locked_blue_score: fin.latest_locked_blue_score, finality_active: fin.active, merged_locked: mergedLocked });
// ---- search ----
if (q.has('search')) {
const c = classify(q.get('search'));
if (c.type === 'none') return res.status(404).json({ ...base, ok: false, error: 'Not a block hash, a transaction hash, a chain block number or an address.' });
if (c.type === 'address') return res.status(200).json({ ...base, type: 'address', href: `/address/${c.value}` });
if (c.type === 'height') {
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 ORDER BY is_chain_block DESC, received_at DESC LIMIT 1`, [c.value]);
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${c.value} in the last 24 hours.` });
return res.status(200).json({ ...base, type: 'block', href: `/block/${r[0].hash}` });
}
const b = await sql(`SELECT hash FROM ${T}live_blocks WHERE hash = $1`, [c.value]);
if (b.length) return res.status(200).json({ ...base, type: 'block', href: `/block/${c.value}` });
const t = await sql(`SELECT hash FROM ${T}live_blocks WHERE tx_count > 0 AND detail->'txs' @> $1::jsonb LIMIT 1`, [JSON.stringify([{ hash: '0x' + c.value }])]);
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/block/${t[0].hash}#tx-${c.value}` });
return res.status(404).json({ ...base, ok: false, error: 'No block or transaction with that hash in the last 24 hours.' });
}
// ---- latest blocks ----
if (q.has('blocks') || (!q.has('block') && !q.has('height') && !q.has('address'))) {
const n = Math.max(10, Math.min(200, Number(q.get('blocks')) || 50));
const before = Number(q.get('before')) || 0;
const rows = before
? await sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE received_at < to_timestamp($2 / 1000.0) ORDER BY received_at DESC LIMIT $1`, [n, before])
: await sql(`SELECT ${ROW} FROM ${T}live_blocks ORDER BY received_at DESC LIMIT $1`, [n]);
const hashes = rows.map(b => b.hash);
const [lockedRows, shardRows] = await Promise.all([
sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => []),
hashes.length ? sql(`SELECT block_hash, array_agg(state ORDER BY shard) AS states FROM ${T}live_proofs WHERE block_hash = ANY($1::text[]) GROUP BY 1`, [`{${hashes.map(h => `"${h}"`).join(',')}}`]).catch(() => []) : Promise.resolve([]),
]);
const locked = new Set(lockedRows.map(c => c.hash));
const shardsOf = new Map(shardRows.map(r => [r.block_hash, r.states || []]));
return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash), ...stateOf(b, shardsOf.get(b.hash) || []) })) });
}
// ---- one block ----
if (q.has('block') || q.has('height')) {
let hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase();
if (q.has('height')) {
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 ORDER BY is_chain_block DESC, received_at DESC LIMIT 1`, [Number(q.get('height'))]);
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${q.get('height')} in the last 24 hours.` });
hash = r[0].hash;
}
if (!/^[0-9a-f]{64}$/.test(hash)) return res.status(400).json({ ...base, ok: false, error: 'A block hash is 64 hex characters.' });
const rows = await sql(`SELECT ${ROW}, detail FROM ${T}live_blocks WHERE hash = $1`, [hash]);
if (!rows.length) return res.status(404).json({ ...base, ok: false, error: 'No block with that hash in the last 24 hours. The explorer keeps one day; older blocks live in the node.' });
const b = rows[0];
const [shards, cps, certs, children, mergedBy] = await Promise.all([
// to_jsonb: the record columns (key_hash, statement, proof_hash) exist once the observer has restarted on the 6 Oct 2026 code; until then they read as undefined
sql(`SELECT to_jsonb(p) AS j FROM ${T}live_proofs p WHERE block_hash = $1 ORDER BY shard`, [hash]).then(r => r.map(x => x.j)).catch(() => []),
sql(`SELECT index, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at FROM ${T}live_checkpoints WHERE hash = $1`, [hash]).catch(() => []),
sql(`SELECT index, hash, carrier, voter_count, aggregator, total_weight, active_weight, prev_index, prev_hash, headers_complete, created_at FROM ${T}live_certificates WHERE hash = $1 OR carrier = $1`, [hash]).catch(() => []),
sql(`SELECT hash FROM ${T}live_blocks WHERE received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND $1 = ANY(parent_hashes) ORDER BY received_at LIMIT 20`, [hash, b.received_at]),
b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash, blue_score FROM ${T}live_blocks WHERE is_chain_block AND received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND (detail->'mergeset'->'blues' ? $1 OR detail->'mergeset'->'reds' ? $1) LIMIT 1`, [hash, b.received_at]),
]);
const captured = await sql(`SELECT shard, key_hash, native_verified, proof_hash_check, statement_check, fields_check, proof IS NOT NULL AS has_bytes FROM ${T}live_proof_bytes WHERE block_hash = $1 ORDER BY shard, received_at DESC`, [hash]).catch(() => []);
const capOf = new Map(); for (const c of captured) if (!capOf.has(num(c.shard))) capOf.set(num(c.shard), c);
let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC
if (EVM && b.is_chain_block) {
try {
const e = await evm(EVM, 'eth_getBlockByHash', ['0x' + hash, true]);
if (e) evmView = { number: parseInt(e.number, 16), gas_used: parseInt(e.gasUsed, 16), base_fee_per_gas: e.baseFeePerGas, state_root: e.stateRoot, miner: e.miner,
transactions: (e.transactions || []).map(t => ({ hash: t.hash, from: t.from, to: t.to, value: t.value, nonce: parseInt(t.nonce, 16), gas: parseInt(t.gas, 16) })),
igneum: e.igneum ? { rewards: e.igneum.rewards, proving_pool_credit: e.igneum.provingPoolCredit, pgas_used: e.igneum.pgasUsed, skipped: e.igneum.skipped } : null };
} catch (err) { evmView = { error: String(err.message || err).slice(0, 120) }; }
}
// the one state word (design 2.4, ledger P17) for the block, and per transaction: the node's `state` when the
// fork carries it (ledger-fixes-0311), else cut from the block's word; at most 20 transactions are asked
const merged = mergedBy[0] || null;
const st = stateOf(b, shards.map(x => x.state), merged && fin.latest_locked_blue_score !== null ? num(merged.blue_score) <= fin.latest_locked_blue_score : null);
const txHashes = ((b.detail && b.detail.txs) || []).map(t => t.hash).filter(Boolean).slice(0, 20);
const executedHere = new Set(evmView && evmView.transactions ? evmView.transactions.map(t => String(t.hash).toLowerCase()) : []);
const txStates = {};
let txSource = EVM ? 'block' : 'block (no EVM RPC on this deployment)';
if (EVM && txHashes.length) {
const answers = await Promise.all(txHashes.map(h => evm(EVM, 'igneum_getTransactionStatus', [h]).catch(() => null)));
answers.forEach((a, i) => { const w = txState(a, st, executedHere.has(String(txHashes[i]).toLowerCase())); txStates[txHashes[i]] = w; if (w.source === 'node') txSource = 'node'; });
if (txSource !== 'node' && answers.some(a => a)) txSource = 'block (the node predates the P17 state field; its flags were read)';
} else for (const h of txHashes) txStates[h] = txState(null, st, executedHere.has(String(h).toLowerCase()));
return res.status(200).json({
...base, block: { ...row(b), detail: b.detail || null, ...st }, tx_states: txStates, tx_state_source: txSource,
shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number),
key_hash: x.key_hash || null, statement: x.statement || null, proof_hash: x.proof_hash || null,
captured: capOf.has(num(x.shard)) ? { native_verified: capOf.get(num(x.shard)).native_verified, checks_ok: capOf.get(num(x.shard)).proof_hash_check === true && capOf.get(num(x.shard)).statement_check === true && capOf.get(num(x.shard)).fields_check === true, has_bytes: !!capOf.get(num(x.shard)).has_bytes } : null })),
checkpoint: cps[0] ? { index: num(cps[0].index), state: cps[0].state, signed: num(cps[0].signed_weight), active: num(cps[0].active_weight), total: num(cps[0].total_weight), fraction_active: num(cps[0].fraction_active), fraction_total: num(cps[0].fraction_total), votes: num(cps[0].votes_seen), voters: num(cps[0].voters), locked_at: cps[0].locked_at } : null,
certificates: certs.map(c => ({ index: num(c.index), checkpoint: c.hash, carrier: c.carrier, role: c.hash === hash ? 'checkpoint' : 'carrier', voter_count: num(c.voter_count), aggregator: c.aggregator, total_weight: num(c.total_weight), active_weight: num(c.active_weight), previous: c.prev_index === null ? null : { index: num(c.prev_index), hash: c.prev_hash }, headers_complete: !!c.headers_complete })),
children: children.map(c => c.hash), merged_by: b.is_chain_block ? hash : (mergedBy[0] ? mergedBy[0].hash : null), evm: evmView,
});
}
// ---- address ----
if (q.has('address')) {
const c = classify(q.get('address'));
if (c.type !== 'address') return res.status(400).json({ ...base, ok: false, error: 'An address is 0x plus 40 hex characters, or an igneum bech32 address.' });
const col = c.kind === 'evm' ? 'evm_miner' : 'miner_address';
const [sum, recent, ids] = await Promise.all([
sql(`SELECT count(*)::int AS blocks_24h, count(*) FILTER (WHERE received_at > now() - interval '10 minutes')::int AS blocks_10m,
count(*) FILTER (WHERE color = 'blue')::int AS blue_24h, count(*) FILTER (WHERE color = 'red')::int AS red_24h, count(*) FILTER (WHERE is_chain_block)::int AS chain_24h,
min(timestamp_ms) AS first_ts, max(timestamp_ms) AS last_ts,
coalesce(sum(subsidy_sompi) FILTER (WHERE color = 'blue'), 0)::text AS blue_subsidy_sompi,
max(evm_miner) AS evm_miner, max(miner_address) AS miner_address
FROM ${T}live_blocks WHERE ${col} = $1`, [c.value]),
sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE ${col} = $1 ORDER BY received_at DESC LIMIT 50`, [c.value]),
sql(`SELECT vote_key_hash, count(*)::int AS n FROM ${T}live_blocks WHERE ${col} = $1 AND vote_key_hash IS NOT NULL GROUP BY 1 ORDER BY n DESC LIMIT 32`, [c.value]),
]);
const t = sum[0];
const evmAddress = c.kind === 'evm' ? c.value : (t.evm_miner || null);
let balance = { available: false, reason: EVM ? 'no EVM address known for this coinbase address' : 'no EVM RPC configured on this deployment (EXPLORER_EVM_RPC)' };
if (EVM && evmAddress) {
try { const wei = await evm(EVM, 'eth_getBalance', [evmAddress, 'latest']); balance = { available: true, wei: BigInt(wei).toString(), address: evmAddress, source: 'eth_getBalance latest' }; }
catch (err) { balance = { available: false, reason: String(err.message || err).slice(0, 120) }; }
}
// what the blocks earned: each blue block pays 80% of the subsidy its payload declares to this miner, through the coinbase of the block that merges it
const blue = BigInt(t.blue_subsidy_sompi || 0); const earned = blue * 80n / 100n;
return res.status(200).json({
...base, address: c.value, kind: c.kind, evm_address: evmAddress, utxo_address: c.kind === 'utxo' ? c.value : (t.miner_address || null),
blocks_24h: t.blocks_24h, blocks_10m: t.blocks_10m, blue_24h: t.blue_24h, red_24h: t.red_24h, chain_24h: t.chain_24h,
first_seen: t.first_ts ? new Date(num(t.first_ts)).toISOString() : null, last_seen: t.last_ts ? new Date(num(t.last_ts)).toISOString() : null,
earned_24h_sompi: earned.toString(), earned_note: '80% of the declared subsidy of every blue block this address mined in the last 24 hours, paid by the coinbase of the block that merged each one (coinbase.rs); red blocks pay the merging miner instead',
miner_ids: ids.map(i => ({ id: String(i.vote_key_hash).slice(0, 8), blocks: i.n })), balance, recent: recent.map(row),
});
}
return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, address or search' });
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};
}
export default createHandler();