// Igneum DAG explorer, server half. GET /api/explorer with one of: // ?blocks=N[&before=] the latest N blocks (10 to 200, default 50), older than `before` when given // ?block= 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= 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'; 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; 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 }; // ---- 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 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 locked = new Set((await sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => [])).map(c => c.hash)); return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash) })) }); } // ---- one block ---- if (q.has('block') || q.has('height')) { let hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase(); if (q.has('height')) { const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [Number(q.get('height'))]); 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([ sql(`SELECT shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number FROM ${T}live_proofs WHERE block_hash = $1 ORDER BY shard`, [hash]).catch(() => []), sql(`SELECT index, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at FROM ${T}live_checkpoints WHERE hash = $1`, [hash]).catch(() => []), sql(`SELECT index, hash, carrier, voter_count, aggregator, total_weight, active_weight, prev_index, prev_hash, headers_complete, created_at FROM ${T}live_certificates WHERE hash = $1 OR carrier = $1`, [hash]).catch(() => []), sql(`SELECT hash FROM ${T}live_blocks WHERE received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND $1 = ANY(parent_hashes) ORDER BY received_at LIMIT 20`, [hash, b.received_at]), b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash FROM ${T}live_blocks WHERE is_chain_block AND received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND (detail->'mergeset'->'blues' ? $1 OR detail->'mergeset'->'reds' ? $1) LIMIT 1`, [hash, b.received_at]), ]); 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) }; } } return res.status(200).json({ ...base, block: { ...row(b), detail: b.detail || null }, shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number) })), 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();