Until an observer has started on new code, a read of a column it adds fails with Postgres 42703; the three public handlers now answer 503 "the observer has not been restarted on the explorer code yet" instead of 500, and the pages show it. The Circulating tile reads "cap 4,000,000,000, 3.96 billion ever minted" with the withheld figure on hover; the homepage tile and the litepaper keep the project lead's words (consequences-decisions D3, D7). The live observer was restarted on this code at 19:42:50 UTC by another session; its counters are in the plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
154 lines
14 KiB
JavaScript
154 lines
14 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, schemaGap } 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');
|
|
const gap = schemaGap(e);
|
|
if (gap) return res.status(503).json({ ok: false, error: gap });
|
|
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
}
|
|
};
|
|
}
|
|
export default createHandler();
|