322 lines
34 KiB
JavaScript
322 lines
34 KiB
JavaScript
// Igneum Devnet 3 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; each with its shard
|
|
// summary (planned, paid), its lock state and its program class
|
|
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions (from the indexer), proof records,
|
|
// shards, checkpoint and certificate when it is one, children, the chain block that merged it,
|
|
// the lock that covers it, its class
|
|
// ?height=N the chain block with that number (the EVM block number), same shape as ?block
|
|
// ?tx=<hash> one EVM transaction: the indexed row (receipt, logs, the igneum fee split) and its block's state
|
|
// ?txs=N[&before=<number>] the latest N transactions (10 to 200, default 50), from chain blocks numbered below `before`
|
|
// ?address=<0x.. | igneum..:bech32> the account (balance, nonce, code, from the indexer), its transactions, the blocks it
|
|
// mined, 24-hour and 10-minute counts, what those blocks earned; the balance comes live through
|
|
// eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC, else from the indexer's read
|
|
// ?proving=1 the proving view: the node's proving state, the last 60 chain blocks with their shards, the
|
|
// provers of the last 24 hours with their payouts, the block-to-paid latency
|
|
// ?stats=1 the network: hash rate, difficulty, DAA, blue score, chain rate, voters and weight, the newest
|
|
// lock, the class floor and every activation the node reports, the indexer's state
|
|
// ?dag=N the last N blocks (20 to 400, default 120) with parents, colour, miner, shards and lock, for the DAG view
|
|
// ?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 and tools/observer/explorer-indexer wrote to Neon (table prefix dn3_ by default,
|
|
// LIVE_TABLE_PREFIX otherwise); the only live call is the balance. Cached 5 s. Every answer names the network: Devnet 3.
|
|
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
|
import { classify } from '../lib/explorer.mjs';
|
|
|
|
const STALE_AFTER_S = 30;
|
|
export const NETWORK_LABEL = 'Devnet 3';
|
|
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`;
|
|
// the EVM's own hash for the block's number (the indexer's explorer_blocks): a block the observer called a chain block that a tip
|
|
// reorg later replaced keeps its number in live_blocks, so the page reads it against this and says "replaced" (Devnet 3 reorgs at the
|
|
// tip run to depth 18, igneum_getBudgets.deepestReorg read 8 October 2026 10:0x UTC)
|
|
const evmHash = T => `(SELECT e.hash FROM ${T}explorer_blocks e WHERE e.number = b.number LIMIT 1) AS evm_hash`;
|
|
const TXROW = `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, tx_type, ts_ms`;
|
|
|
|
/** The program class a block of this DAA score was mined under, from the node's parameters (program_class_v5_activation_daa). */
|
|
export function classAt(daa, params) {
|
|
const p = params || {};
|
|
const floors = Object.entries(p).filter(([k]) => /^program_class_v(\d+)_activation_daa$/.test(k)).map(([k, v]) => ({ v: Number(k.match(/v(\d+)/)[1]), daa: Number(v) })).filter(x => Number.isFinite(x.daa)).sort((a, b) => a.v - b.v);
|
|
let cls = 4; // class v4 is the floor of Devnet 3 (every activation at 0 in its network object)
|
|
for (const f of floors) if (daa !== null && daa !== undefined && daa >= f.daa && f.v > cls) cls = f.v;
|
|
return { class: `v${cls}`, floors: floors.map(f => ({ class: `v${f.v}`, daa: f.daa })) };
|
|
}
|
|
|
|
let settledBelow = -1; // numbers above this are still moving at the tip; chain_now is left null for them (set per request from the indexer's number)
|
|
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,
|
|
// chain_now: still the chain block of its number by the EVM's reading (null when the indexer has not reached the number)
|
|
chain_now: b.is_chain_block ? (b.evm_hash === undefined || b.evm_hash === null || num(b.number) > settledBelow ? null : b.evm_hash === b.hash) : false,
|
|
};
|
|
}
|
|
function txrow(t) {
|
|
return {
|
|
hash: t.hash, block_hash: t.block_hash, number: num(t.block_number), index: num(t.tx_index), from: t.from_addr, to: t.to_addr, value_wei: t.value_wei === null || t.value_wei === undefined ? '0' : String(t.value_wei),
|
|
nonce: num(t.nonce), gas: num(t.gas), gas_price: t.gas_price === null || t.gas_price === undefined ? null : String(t.gas_price), gas_used: num(t.gas_used),
|
|
effective_gas_price: t.effective_gas_price === null || t.effective_gas_price === undefined ? null : String(t.effective_gas_price),
|
|
status: t.status === null || t.status === undefined ? null : (Number(t.status) === 1 ? 'success' : 'failed'), contract_address: t.contract_address || null,
|
|
input_bytes: num(t.input_bytes), selector: t.selector || null, logs_count: num(t.logs_count), type: num(t.tx_type), ts: num(t.ts_ms),
|
|
};
|
|
}
|
|
const shardSummary = s => ({ planned: num(s.planned) || 0, proving: num(s.proving) || 0, verified: num(s.verified) || 0, paid: num(s.paid) || 0, total: num(s.total) || 0, median_lag: s.median_lag === null || s.median_lag === undefined ? null : Number(s.median_lag) });
|
|
|
|
/** Lock state of a block against the newest lock: a chain block at or under the lock's blue score is final; a merged block is final with its merger. */
|
|
function lockState(b, lock, mergerBlue) {
|
|
if (!lock) return { final: false, reason: 'no lock yet' };
|
|
if (b.chain) return b.blue_score <= lock.blue_score ? { final: true, index: lock.index, reason: `chain block under lock ${lock.index}` } : { final: false, index: lock.index, reason: `above lock ${lock.index} (blue score ${lock.blue_score})` };
|
|
if (mergerBlue !== null && mergerBlue !== undefined) return mergerBlue <= lock.blue_score ? { final: true, index: lock.index, reason: `merged by a chain block under lock ${lock.index}` } : { final: false, index: lock.index, reason: `merged above lock ${lock.index}` };
|
|
return { final: false, index: lock.index, reason: 'not merged yet' };
|
|
}
|
|
|
|
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,
|
|
(SELECT row_to_json(x) FROM ${T}explorer_state x WHERE x.id = 1) AS indexer, (SELECT max(number) FROM ${T}explorer_blocks) AS indexed_max`).catch(async e => {
|
|
if (/explorer_state/.test(String(e.message))) return sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state, NULL AS indexer`);
|
|
throw e;
|
|
});
|
|
const now = new Date(head[0].now).getTime(); const s = head[0].state || null; const ix = head[0].indexer || null;
|
|
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
|
const params = ix && ix.node_info && ix.node_info.params ? ix.node_info.params : null;
|
|
const indexer = ix ? { indexed_number: num(ix.indexed_number), tip_number: num(ix.tip_number), backlog: num(ix.backlog), txs_total: num(ix.txs_total), accounts_total: num(ix.accounts_total), blocks_per_s: num(ix.blocks_per_s), updated_at: ix.updated_at, stale: !ix.updated_at || (now - new Date(ix.updated_at).getTime()) / 1000 > STALE_AFTER_S, last_error: ix.last_error || null } : { stale: true, reason: 'the explorer indexer has not written yet' };
|
|
// the highest number the indexer has ever read, less three: a node re-syncing from a snapshot reports a low tip for a while
|
|
// (10:21 UTC on 8 October 2026: the observer node restarted and its tip read 1,981 against 27,145 indexed) and must not unsettle the table
|
|
settledBelow = head[0].indexed_max === null || head[0].indexed_max === undefined ? -1 : Number(head[0].indexed_max) - 3;
|
|
const base = { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, network_label: NETWORK_LABEL, chain_id: ix && ix.chain_id ? num(ix.chain_id) : (s ? chainIdOf(s.network) : null), stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S, evm_rpc_configured: !!EVM, indexer };
|
|
const lockRow = await sql(`SELECT index, hash, blue_score, daa_score, fraction_total, voters, locked_at FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 1`).catch(() => []);
|
|
const lock = lockRow[0] ? { index: num(lockRow[0].index), hash: lockRow[0].hash, blue_score: num(lockRow[0].blue_score), daa: num(lockRow[0].daa_score), fraction_total: num(lockRow[0].fraction_total), voters: num(lockRow[0].voters), locked_at: lockRow[0].locked_at } : null;
|
|
base.lock = lock;
|
|
|
|
// ---- 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_txs WHERE hash = $1`, ['0x' + c.value]).catch(() => []);
|
|
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/tx/0x${c.value}` });
|
|
return res.status(404).json({ ...base, ok: false, error: 'No block in the last 24 hours and no transaction in the last 7 days with that hash.' });
|
|
}
|
|
|
|
// ---- stats ----
|
|
if (q.has('stats')) {
|
|
const [chain10, fin, paid10] = await Promise.all([
|
|
sql(`SELECT count(*)::int AS blocks_10m, count(*) FILTER (WHERE is_chain_block)::int AS chain_10m, count(DISTINCT vote_key_hash)::int AS miners_10m, max(daa_score) AS daa, max(number) AS height FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes'`),
|
|
Promise.resolve(s && s.finality ? s.finality : null),
|
|
// payments landing in the window for blocks of any age (the observer's shards_paid_10m counts only blocks of the last 10 minutes, which reads 0 while the lag runs to 500 DAA)
|
|
sql(`SELECT count(*)::int AS n, count(DISTINCT prover)::int AS provers, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '10 minutes'`).catch(() => [{ n: null, provers: null, payout_wei: null }]),
|
|
]);
|
|
const c = chain10[0] || {};
|
|
const p10 = paid10[0] || {};
|
|
const w = fin && fin.weights ? fin.weights : null;
|
|
const activations = params ? Object.entries(params).filter(([k, v]) => /_activation_daa$/.test(k) && Number.isFinite(Number(v))).map(([k, v]) => ({ name: k.replace(/_activation_daa$/, ''), daa: Number(v) })) : [];
|
|
return res.status(200).json({
|
|
...base,
|
|
node_version: s ? s.node_version : null, height: num(c.height), daa: num(c.daa), blue_score: s ? num(s.blue_score) : null, block_count: s ? num(s.block_count) : null,
|
|
difficulty: s ? num(s.difficulty) : null, hashrate: s ? num(s.hashes_per_second_estimate) : null, peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null,
|
|
blocks_10m: num(c.blocks_10m), chain_blocks_10m: num(c.chain_10m), blocks_per_s_10m: c.blocks_10m === null ? null : Math.round(Number(c.blocks_10m) / 600 * 100) / 100, chain_blocks_per_s_10m: c.chain_10m === null ? null : Math.round(Number(c.chain_10m) / 600 * 100) / 100,
|
|
miners_10m: num(c.miners_10m),
|
|
finality: fin ? { active: !!fin.finality_active, voters: w ? num(w.voters) : null, total_weight: w ? num(w.total_weight) : null, active_weight: w ? num(w.active_weight) : null, keys: w && Array.isArray(w.keys) ? w.keys.length : null, latest_locked_index: num(fin.latest_locked_index), latest_locked_blue_score: num(fin.latest_locked_blue_score), params: fin.params || null } : null,
|
|
proving: s && s.proving ? s.proving : null,
|
|
paid_10m_any_age: { shards: num(p10.n), provers: num(p10.provers), payout_wei: p10.payout_wei || '0', note: 'shards paid in the last 10 minutes for blocks of any age' },
|
|
class: classAt(num(c.daa), params), activations, digest: ix && ix.node_info ? ix.node_info.digest : null, genesis: ix && ix.node_info ? ix.node_info.genesis : null,
|
|
budgets: null,
|
|
});
|
|
}
|
|
|
|
// ---- proving view ----
|
|
if (q.has('proving')) {
|
|
const [blocks, provers24, provers1, lat, totals, paid10] = await Promise.all([
|
|
sql(`SELECT b.hash, b.number, b.daa_score, b.blue_score, b.timestamp_ms, b.vote_key_hash, b.evm_miner, b.tx_count, b.proof_records,
|
|
count(p.shard)::int AS total, count(*) FILTER (WHERE p.state = 'planned')::int AS planned, count(*) FILTER (WHERE p.state = 'proving')::int AS proving,
|
|
count(*) FILTER (WHERE p.state = 'verified')::int AS verified, count(*) FILTER (WHERE p.state = 'paid')::int AS paid,
|
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.lag_daa) FILTER (WHERE p.state = 'paid') AS median_lag, sum(p.pgas) AS pgas
|
|
FROM ${T}live_blocks b LEFT JOIN ${T}live_proofs p ON p.block_hash = b.hash
|
|
WHERE b.is_chain_block AND b.number IS NOT NULL AND b.received_at > now() - interval '24 hours'
|
|
GROUP BY b.hash, b.number, b.daa_score, b.blue_score, b.timestamp_ms, b.vote_key_hash, b.evm_miner, b.tx_count, b.proof_records ORDER BY b.number DESC LIMIT 60`),
|
|
sql(`SELECT prover, count(*)::int AS shards, sum(payout_wei::numeric)::text AS payout_wei, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) AS median_lag, max(updated_at) AS last_paid
|
|
FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '24 hours' AND prover IS NOT NULL GROUP BY prover ORDER BY shards DESC LIMIT 64`),
|
|
sql(`SELECT prover, count(*)::int AS shards, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '1 hour' AND prover IS NOT NULL GROUP BY prover`),
|
|
sql(`SELECT count(*)::int AS paid_1h, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) AS p50, percentile_cont(0.9) WITHIN GROUP (ORDER BY lag_daa) AS p90, max(lag_daa) AS max_lag, min(lag_daa) AS min_lag
|
|
FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '1 hour'`),
|
|
sql(`SELECT state, count(*)::int AS n, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE received_at > now() - interval '24 hours' GROUP BY state`),
|
|
sql(`SELECT count(*)::int AS n, count(DISTINCT prover)::int AS provers, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '10 minutes'`).catch(() => []),
|
|
]);
|
|
const hour = new Map(provers1.map(p => [p.prover, p]));
|
|
return res.status(200).json({
|
|
...base, proving: s && s.proving ? s.proving : null,
|
|
blocks: blocks.map(b => ({ hash: b.hash, number: num(b.number), daa: num(b.daa_score), blue_score: num(b.blue_score), ts: num(b.timestamp_ms), miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, evm_miner: b.evm_miner, tx_count: num(b.tx_count), proof_records: num(b.proof_records), pgas: num(b.pgas), shards: shardSummary(b), class: classAt(num(b.daa_score), params).class })),
|
|
provers: provers24.map(p => ({ id: p.prover, shards_24h: num(p.shards), payout_wei_24h: p.payout_wei || '0', median_lag_24h: p.median_lag === null ? null : Number(p.median_lag), shards_1h: hour.has(p.prover) ? num(hour.get(p.prover).shards) : 0, payout_wei_1h: hour.has(p.prover) ? hour.get(p.prover).payout_wei || '0' : '0', last_paid: p.last_paid })),
|
|
latency_1h: lat[0] ? { paid: num(lat[0].paid_1h), p50_daa: lat[0].p50 === null ? null : Number(lat[0].p50), p90_daa: lat[0].p90 === null ? null : Number(lat[0].p90), min_daa: num(lat[0].min_lag), max_daa: num(lat[0].max_lag), note: 'lag in DAA score from the proven block to the block whose coinbase paid the shard; Devnet 3 targets one DAA step per second' } : null,
|
|
totals_24h: Object.fromEntries(totals.map(t => [t.state, { shards: num(t.n), payout_wei: t.payout_wei || '0' }])),
|
|
paid_10m_any_age: paid10[0] ? { shards: num(paid10[0].n), provers: num(paid10[0].provers), payout_wei: paid10[0].payout_wei || '0' } : null,
|
|
});
|
|
}
|
|
|
|
// ---- the DAG ----
|
|
if (q.has('dag')) {
|
|
const n = Math.max(20, Math.min(400, Number(q.get('dag')) || 120));
|
|
const rows = await sql(`SELECT ${ROW}, ${evmHash(T)}, (SELECT json_build_object('total', count(*), 'paid', count(*) FILTER (WHERE state = 'paid'), 'planned', count(*) FILTER (WHERE state = 'planned'), 'proving', count(*) FILTER (WHERE state = 'proving'), 'verified', count(*) FILTER (WHERE state = 'verified')) FROM ${T}live_proofs p WHERE p.block_hash = b.hash) AS sh
|
|
FROM ${T}live_blocks b ORDER BY received_at DESC LIMIT $1`, [n]).catch(() => sql(`SELECT ${ROW} FROM ${T}live_blocks b ORDER BY received_at DESC LIMIT $1`, [n]));
|
|
const cps = await sql(`SELECT hash, index, state FROM ${T}live_checkpoints ORDER BY index DESC LIMIT 400`).catch(() => []);
|
|
const cp = new Map(cps.map(c => [c.hash, { index: num(c.index), state: c.state }]));
|
|
return res.status(200).json({ ...base, blocks: rows.map(b => { const r = row(b); return { ...r, shards: b.sh ? shardSummary(b.sh) : null, checkpoint: cp.get(b.hash) || null, final: lockState(r, lock, null).final, class: classAt(r.daa, params).class }; }).reverse() });
|
|
}
|
|
|
|
// ---- latest transactions ----
|
|
if (q.has('txs')) {
|
|
const n = Math.max(10, Math.min(200, Number(q.get('txs')) || 50));
|
|
const before = Number(q.get('before')) || 0;
|
|
const rows = before
|
|
? await sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE block_number < $2 ORDER BY block_number DESC, tx_index ASC LIMIT $1`, [n, before])
|
|
: await sql(`SELECT ${TXROW} FROM ${T}live_txs ORDER BY block_number DESC, tx_index ASC LIMIT $1`, [n]);
|
|
return res.status(200).json({ ...base, txs: rows.map(txrow) });
|
|
}
|
|
|
|
// ---- one transaction ----
|
|
if (q.has('tx')) {
|
|
const h = String(q.get('tx') || '').toLowerCase().replace(/^(0x)?/, '0x');
|
|
if (!/^0x[0-9a-f]{64}$/.test(h)) return res.status(400).json({ ...base, ok: false, error: 'A transaction hash is 0x plus 64 hex characters.' });
|
|
const rows = await sql(`SELECT ${TXROW}, logs, igneum FROM ${T}live_txs WHERE hash = $1`, [h]);
|
|
if (!rows.length) return res.status(404).json({ ...base, ok: false, error: 'No transaction with that hash in the last 7 days. The explorer keeps seven days of transactions; older ones live in the node.' });
|
|
const t = rows[0];
|
|
const [blk, sh, acct] = await Promise.all([
|
|
sql(`SELECT ${ROW}, ${evmHash(T)} FROM ${T}live_blocks b WHERE hash = $1`, [t.block_hash]),
|
|
sql(`SELECT count(*)::int AS total, count(*) FILTER (WHERE state = 'planned')::int AS planned, count(*) FILTER (WHERE state = 'proving')::int AS proving, count(*) FILTER (WHERE state = 'verified')::int AS verified, count(*) FILTER (WHERE state = 'paid')::int AS paid, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) FILTER (WHERE state = 'paid') AS median_lag FROM ${T}live_proofs WHERE block_hash = $1`, [t.block_hash]).catch(() => []),
|
|
sql(`SELECT address, is_contract, code_bytes FROM ${T}live_accounts WHERE address = ANY($1::text[])`, [[t.from_addr, t.to_addr, t.contract_address].filter(Boolean)]).catch(() => []),
|
|
]);
|
|
const b = blk[0] ? row(blk[0]) : null;
|
|
const contracts = Object.fromEntries(acct.filter(a => a.is_contract).map(a => [a.address, num(a.code_bytes)]));
|
|
return res.status(200).json({
|
|
...base, tx: { ...txrow(t), logs: t.logs || [], igneum: t.igneum || null, contracts },
|
|
block: b ? { ...b, class: classAt(b.daa, params).class, shards: sh[0] ? shardSummary(sh[0]) : null, lock: lockState(b, lock, null), in_observer: true } : { hash: t.block_hash, number: num(t.block_number), in_observer: false, note: 'the observer keeps one day of blocks; the transaction is older or its block left the observer window' },
|
|
});
|
|
}
|
|
|
|
// ---- 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 sel = `SELECT ${ROW}, ${evmHash(T)}, (SELECT json_build_object('total', count(*), 'paid', count(*) FILTER (WHERE state = 'paid'), 'planned', count(*) FILTER (WHERE state = 'planned'), 'proving', count(*) FILTER (WHERE state = 'proving'), 'verified', count(*) FILTER (WHERE state = 'verified')) FROM ${T}live_proofs p WHERE p.block_hash = b.hash) AS sh FROM ${T}live_blocks b`;
|
|
const rows = before
|
|
? await sql(`${sel} WHERE received_at < to_timestamp($2 / 1000.0) ORDER BY received_at DESC LIMIT $1`, [n, before])
|
|
: await sql(`${sel} 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 => { const r = row(b); return { ...r, locked: locked.has(b.hash), shards: b.sh ? shardSummary(b.sh) : null, final: lockState(r, lock, null).final, class: classAt(r.daa, params).class }; }) });
|
|
}
|
|
|
|
// ---- 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}, ${evmHash(T)}, detail FROM ${T}live_blocks b WHERE hash = $1`, [hash]).catch(() => 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 of blocks; older blocks live in the node.' });
|
|
const b = rows[0];
|
|
const [shards, cps, certs, children, mergedBy, txs] = await Promise.all([
|
|
sql(`SELECT shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number, verified 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, 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]),
|
|
sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE block_hash = $1 ORDER BY tx_index LIMIT 500`, [hash]).catch(() => []),
|
|
]);
|
|
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, false]);
|
|
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,
|
|
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) }; }
|
|
}
|
|
const r = row(b);
|
|
const mergerBlue = b.is_chain_block ? null : (mergedBy[0] ? num(mergedBy[0].blue_score) : null);
|
|
const counts = { total: shards.length, planned: shards.filter(x => x.state === 'planned').length, proving: shards.filter(x => x.state === 'proving').length, verified: shards.filter(x => x.state === 'verified').length, paid: shards.filter(x => x.state === 'paid').length };
|
|
return res.status(200).json({
|
|
...base, block: { ...r, detail: b.detail || null, class: classAt(r.daa, params), lock: lockState(r, lock, mergerBlue), shard_summary: counts },
|
|
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), node_verified: x.verified === null || x.verified === undefined ? null : !!x.verified })),
|
|
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, txs: txs.map(txrow),
|
|
});
|
|
}
|
|
|
|
// ---- 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}, ${evmHash(T)} FROM ${T}live_blocks b 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 account = null, txs = [];
|
|
if (evmAddress) {
|
|
const before = Number(q.get('before')) || 0;
|
|
const [a, tx] = await Promise.all([
|
|
sql(`SELECT address, balance_wei, nonce, code_bytes, is_contract, sent, received, first_seen_number, last_seen_number, miner, refreshed_at FROM ${T}live_accounts WHERE address = $1`, [evmAddress]).catch(() => []),
|
|
sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE (from_addr = $1 OR to_addr = $1) ${before ? 'AND block_number < $2' : ''} ORDER BY block_number DESC, tx_index ASC LIMIT 50`, before ? [evmAddress, before] : [evmAddress]).catch(() => []),
|
|
]);
|
|
if (a[0]) account = { address: a[0].address, balance_wei: a[0].balance_wei === null ? null : String(a[0].balance_wei), nonce: num(a[0].nonce), code_bytes: num(a[0].code_bytes), is_contract: !!a[0].is_contract, sent: num(a[0].sent), received: num(a[0].received), first_seen_number: num(a[0].first_seen_number), last_seen_number: num(a[0].last_seen_number), miner: !!a[0].miner, refreshed_at: a[0].refreshed_at, source: 'the explorer indexer (eth_getBalance, eth_getTransactionCount, eth_getCode on the observer node)' };
|
|
txs = tx.map(txrow);
|
|
}
|
|
let balance = account && account.balance_wei !== null ? { available: true, wei: account.balance_wei, address: evmAddress, source: 'indexer', refreshed_at: account.refreshed_at } : { available: false, reason: evmAddress ? 'not read yet: the indexer reads an address after its first transaction or mined block' : 'no EVM address known for this coinbase address' };
|
|
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) { if (!balance.available) balance = { available: false, reason: String(err.message || err).slice(0, 120) }; }
|
|
}
|
|
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),
|
|
account, txs,
|
|
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, tx, txs, address, proving, stats, dag or search' });
|
|
} catch (e) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(500).json({ ok: false, network_label: NETWORK_LABEL, error: String(e.message || e) });
|
|
}
|
|
};
|
|
}
|
|
export default createHandler();
|