igneum/site/api/live.mjs
igneum-labs 2c58912211 Live proving feed: the page says when its shard states are stale, and a chain block with an empty plan reads "no shards in this block" (8 October 2026, main's order: the project lead wants to watch proving and shards in real time; a stale snapshot must read as stale). Reading of the day: /api/live's proven and paid states come from the Devnet 3 observer's own node on igneum-build-1 (igneum-observer-node-dn3, EVM 26850), not from hub-1; that node stopped crediting payouts at 23:57Z on 7 October (paidShards frozen at 7,304 while blocks carried 8,457 proof records by 03:00Z and hub-1 counted 11,861 by 08:56Z), and the page kept showing the paid total as fresh. site/lib/proving-health.mjs (tests known-failed first: "Cannot find module", then 10 green on build-2): stale when the observer's last status read is over 60 s old (status_at, new in the observer's live_state.proving), when the node's tip trails the newest block by over 300 DAA, or when blocks carried proof records in the last 10 minutes and no shard moved to verified or paid for 10 minutes (the blind-to-records class); a quiet chain is not stale for lack of credits. /api/live carries stale, stale_reason, status_age_s, tip_lag_daa, records_10m and last_change_at on proving (two more indexed readings; scene/feed-contract.json lists the keys; the recorded fixture carries them). /live: the Proven tile's line reads "proving feed stale: ..." in ember, else "... last credit N s ago"; the inspector's count adds "feed stale". Scene 2.0.7: a chain block whose plan is known (block.number set, carried as plan on the normalised block) and empty reads "no shards in this block"; loading and late stay for an unread plan (shard-words test, known-failed on 2.0.6). Gate: the health test joins the site unit-test line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:12:56 +00:00

201 lines
13 KiB
JavaScript

// Igneum live devnet feed. GET /api/live returns what tools/observer wrote to Neon:
// {now, state (incl. height = chain block number), blocks (last 90 s, or ?window=N up to 1800, at most 1,800 blocks), miners (last 10 min), events (last 30), finality (checkpoints, newest lock),
// proving (activation and 10-minute counts; every chain block carries shards: [{i, state, prover, lag, payout}])}.
// Five queries, all on indexed columns. Cached one second at the edge.
// LIVE_TABLE_PREFIX (default dn3_: Devnet 3, the igneum-observer-dn3 unit on build-1, chain id 4463, igneum-devnet-3, from
// 7 October 2026 evening by the founder word) reads another observer's tables (empty = the first devnet's, fintest_live_*, provtest_live_*).
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
import { provingHealth } from '../lib/proving-health.mjs';
const STALE_AFTER_S = 30;
const T = (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, '');
function neon() {
const url = process.env.DATABASE_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 }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
return j.rows || [];
};
}
const num = v => (v === null || v === undefined ? null : Number(v));
const short = h => (h ? String(h).slice(0, 16) : null);
const minerId = vk => (vk ? String(vk).slice(0, 8) : null);
export default async function handler(req, res) {
res.setHeader('Cache-Control', 'public, max-age=1');
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' });
}
try {
const sql = neon();
// ?window=N seconds of blocks (30 to 1800, default 90): the page's long views; the row cap of 1,800 blocks keeps a busy DAG bounded
// (7 October 2026: the cap was 300 s while proofs land two to five minutes after the block, so no window could show a proven block)
const q = new URL(req.url || '/', 'http://x').searchParams;
const windowS = Math.max(30, Math.min(1800, Number(q.get('window')) || 90));
// ?summary=1: the counts of the window alone (blocks, chain blocks, proven, shards by state), for a tile that reads a long
// window every 10 s while the scene keeps its short one (7 October 2026); no block rows, no miners, no events
const summary = q.get('summary') === '1';
const [head, blockRows, minerRows, checkpointRows, proofRows, provingRows] = await Promise.all([
sql(`SELECT now() AS now,
(SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state,
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM ${T}live_events ORDER BY ts DESC LIMIT 30) e) AS events,
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS height,
(SELECT max(daa_score) FROM ${T}live_blocks WHERE received_at > now() - interval '2 minutes') AS newest_daa`),
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color, number
FROM ${T}live_blocks
WHERE received_at > now() - ($1 || ' seconds')::interval
ORDER BY received_at DESC
LIMIT 1800`, [String(windowS)]),
sql(`SELECT vote_key_hash, count(*)::int AS blocks, max(received_at) AS last_seen, max(engine) AS engine, max(miner_address) AS address
FROM ${T}live_blocks
WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL
GROUP BY vote_key_hash
ORDER BY blocks DESC, last_seen DESC`),
// Finality v2: the most recent checkpoints; an older observer has no table yet, so this query may fail
sql(`SELECT index, hash, blue_score, daa_score, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at, updated_at
FROM ${T}live_checkpoints
ORDER BY index DESC
LIMIT 40`).catch(() => []),
// Proving v0: the planned shards of the blocks in the window and what became of them (an older observer has no table).
// The rows are joined by block hash, never by when the plan or the proof landed: a block in view carries its shard
// states (planned, proving, verified, paid) whatever its age, and the proof's own landing is what moves them.
sql(`SELECT p.block_hash, p.shard, p.shards, p.state, p.prover, p.lag_daa, p.payout_wei, p.pgas
FROM ${T}live_proofs p
WHERE p.block_hash IN (SELECT hash FROM ${T}live_blocks WHERE received_at > now() - ($1 || ' seconds')::interval ORDER BY received_at DESC LIMIT 1800)
ORDER BY p.block_hash, p.shard`, [String(windowS)]).catch(() => []),
// the proving feed's own health (8 October 2026, site/lib/proving-health.mjs): the records blocks carried in the last
// 10 minutes against the last shard the observer's node credited; an older observer has no proofs table
sql(`SELECT (SELECT coalesce(sum(proof_records), 0)::int FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes') AS records_10m,
(SELECT max(updated_at) FROM ${T}live_proofs WHERE state IN ('verified', 'paid')) AS last_change`).catch(() => [{ records_10m: 0, last_change: null }]),
]);
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 age_s = updated === null ? null : Math.max(0, (now - updated) / 1000);
const stale = age_s === null || age_s > STALE_AFTER_S;
const total10m = minerRows.reduce((a, m) => a + m.blocks, 0);
const state = {
stale,
age_s: age_s === null ? null : Math.round(age_s * 10) / 10,
network: s ? s.network : null,
node_version: s ? s.node_version : null,
// 6 Oct 2026: height is the chain block number (the EVM block number of the newest chain block the observer planned),
// the same field /api/stats reports; block_count counts every DAG block the node holds (pruned nodes hold fewer)
height: num(head[0].height),
block_count: s ? num(s.block_count) : null,
header_count: s ? num(s.header_count) : null,
blue_score: s ? num(s.blue_score) : null,
difficulty: s ? num(s.difficulty) : null,
hashes_per_second_estimate: s ? num(s.hashes_per_second_estimate) : null,
peers: s ? num(s.peers) : null,
mempool: s ? num(s.mempool) : null,
blocks_60s: s ? num(s.blocks_60s) : null,
blocks_per_second_60s: s && s.blocks_60s !== null ? Math.round(num(s.blocks_60s) / 60 * 1000) / 1000 : null,
blocks_per_minute: s ? s.blocks_per_minute || [] : [],
miners_10m: minerRows.length,
observer_started_at: s ? s.observer_started_at : null,
// 4 Oct 2026: how far the observer's newest stored block is behind the clock, and what it still has queued
observer_lag_s: s ? num(s.observer_lag_s) : null,
queue_depth: s ? num(s.queue_depth) : null,
updated_at: s ? s.updated_at : null,
};
const fin = (s && s.finality) || null;
const checkpoints = checkpointRows.reverse().map(c => ({
index: num(c.index), hash: short(c.hash), blue_score: num(c.blue_score), daa: num(c.daa_score), state: c.state,
signed: num(c.signed_weight), active: num(c.active_weight), total: num(c.total_weight),
fraction_active: num(c.fraction_active), fraction_total: num(c.fraction_total), votes: num(c.votes_seen), voters: num(c.voters),
locked_at: c.locked_at, updated_at: c.updated_at,
}));
const lockedHashes = new Set(checkpoints.filter(c => c.state === 'locked').map(c => c.hash));
const newestLock = checkpoints.filter(c => c.state === 'locked').slice(-1)[0] || null;
const finality = {
supported: !!fin,
active: fin ? !!fin.finality_active : false,
next_index: fin ? num(fin.next_index) : null,
latest_locked_index: newestLock ? newestLock.index : (fin ? num(fin.latest_locked_index) || null : null),
latest_locked_hash: newestLock ? newestLock.hash : null,
latest_locked_blue_score: newestLock ? newestLock.blue_score : null,
params: fin ? fin.params || null : null,
weights: fin ? fin.weights || null : null,
checkpoints,
};
// proving v0: one cell per planned shard; payout in IGN (wei / 1e18) to four decimals, never an address (R4.6.8)
const shardsOf = new Map();
for (const r of proofRows) {
const k = short(r.block_hash);
if (!shardsOf.has(k)) shardsOf.set(k, []);
let payout = null;
if (r.payout_wei) { try { payout = Number(BigInt(r.payout_wei) / 100000000000000n) / 10000; } catch { payout = null; } }
shardsOf.get(k).push({ i: num(r.shard), n: num(r.shards), state: r.state, prover: r.prover || null, lag: num(r.lag_daa), payout, pgas: num(r.pgas) });
}
const pv = (s && s.proving) || null;
const provingBase = pv && pv.supported ? {
supported: true, active: !!pv.active, activation_daa: num(pv.activation_daa), tip_daa: num(pv.tip_daa), verifier: pv.verifier || null, pool: pv.pool || null,
paid_shards_total: num(pv.paid_shards_total), shard_budget_pgas: num(pv.shard_budget_pgas),
blocks_10m: num(pv.blocks_10m), blocks_fully_proven_10m: num(pv.blocks_fully_proven_10m), shards_proven_10m: num(pv.shards_proven_10m), shards_paid_10m: num(pv.shards_paid_10m),
median_proof_lag_s: num(pv.median_proof_lag_s), provers_10m: num(pv.provers_10m),
} : { supported: false, reason: pv ? (pv.reason || 'node without proving') : 'observer has not reported the proving layer' };
// the feed's own staleness beside the counts: stale (bool) and stale_reason in words, status_age_s (the observer's last
// status read, null on an older observer), tip_lag_daa, records_10m, last_change_at (the last shard credited)
const ph = provingHealth({ proving: provingBase, newestDaa: num(head[0].newest_daa), records10m: provingRows[0] ? provingRows[0].records_10m : 0,
lastChangeAt: provingRows[0] && provingRows[0].last_change ? new Date(provingRows[0].last_change).toISOString() : null, statusAt: pv && pv.status_at ? pv.status_at : null, now });
const proving = provingBase.supported ? { ...provingBase, stale: ph.stale, stale_reason: ph.reason, status_age_s: ph.status_age_s, tip_lag_daa: ph.tip_lag_daa, records_10m: ph.records_10m, last_change_at: ph.last_change_at } : provingBase;
const blocks = blockRows.reverse().map(b => ({
hash: short(b.hash),
// the chain block number (the EVM block number) once the observer has planned the block; null off the chain or before the plan
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: (b.parent_hashes || []).map(short),
chain: !!b.is_chain_block,
miner: minerId(b.vote_key_hash),
// GHOSTDAG colour from the observer: blue (merged by a chain block, paid), red (excluded) or pending (not merged yet)
color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending',
// Finality v2: a locked checkpoint block, and whether the block sits at or before the newest lock
locked: lockedHashes.has(short(b.hash)),
final: !!(newestLock && b.is_chain_block && num(b.blue_score) <= newestLock.blue_score),
// Proving v0: the chain block's shards (planned, proving, verified, paid) and whether every shard is proven
shards: shardsOf.get(short(b.hash)) || [],
proven: (shardsOf.get(short(b.hash)) || []).length > 0 && (shardsOf.get(short(b.hash)) || []).every(x => x.state === 'verified' || x.state === 'paid'),
}));
const miners = minerRows.map(m => ({
id: minerId(m.vote_key_hash),
blocks: m.blocks,
share: total10m ? Math.round(m.blocks / total10m * 1000) / 10 : 0,
last_seen: m.last_seen,
engine: m.engine || null,
}));
// peer events never carry an address on the public API (review round 4, R4.6.2); older stored rows are redacted here too
const events = (head[0].events || []).map(e => ({ ts: e.ts, kind: e.kind, text: String(e.text).replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, 'a peer') }));
if (summary) {
const by = {}; for (const b of blocks) for (const x of b.shards) by[x.state] = (by[x.state] || 0) + 1;
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), window: windowS, stale, blocks: blocks.length, chain: blocks.filter(b => b.chain).length, planned: blocks.filter(b => b.shards.length).length, proven: blocks.filter(b => b.proven).length, shards: by, proving });
}
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), state, blocks, miners, events, finality, proving });
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
}