Observer (tools/observer/observer.mjs): reads the execution layer's JSON-RPC of a node on the proving build
(IGNEUM_EVM_RPC, default the Mac app's node 26800): every chain block's shard plan as it joins the chain
(igneum_getShardPlan by blockHash, one live_proofs row per shard, planned), the proof records of the chain
blocks of the last 10 minutes polled in rotation (igneum_getProofRecords, four in flight, 40 blocks per tick
while active, 10 before activation): proving (in the pool), verified (SP1 proof verified, or carried and checked
by consensus), paid (a carrying segment paid it), with the prover's id8, the carrier, lag in DAA and the payout.
live_state.proving = {supported, active, activation_daa, tip_daa, verifier, pool, blocks_10m,
blocks_fully_proven_10m, shards_proven_10m, shards_paid_10m, median_proof_lag_s, provers_10m}. A node without
the RPCs gives supported false (rechecked every 5 min); an unreachable endpoint is retried every 20 s. Events:
proving (activation, first paid shard), prover_seen. Additive schema (live_proofs, live_state.proving).
API (site/api/live.mjs): proving, and per block shards: [{i, n, state, prover, lag, payout (IGN), pgas}] and
proven; ?window=N (30 to 300 s) for the page's diagnostic long view; LIVE_TABLE_PREFIX reads a test observer's
tables.
Live page (site/live.html), the design change of 4 Oct 2026: three thin strips sharing one time axis, newest at
the right. BLOCKS keeps the per-miner lanes, chain path, blue/red/pending colouring, arrival glow and tooltips;
the lock ring, dashed lock line and final band leave it. FINALITY is an 18 px bar: ember wash = final (up to the
newest locked checkpoint on screen), molten tick = locked checkpoint, faint = proposed, one label at the newest
lock ("locked #522, 12 s ago"); while finality is not active it reads "finality paused: N% of weight silent" and
nothing else (R4.6.3). PROVING shows one cell per shard under each chain block, outline (planned), molten
(proving), prover colour (verified), tick (paid), a dashed "proofs land N s behind the tip" line, or the one
honest line before activation ("Proving layer: not yet activated on this devnet; activation at DAA N" / "node
without proving"). Header stats: on screen, chain, identities, last lock, proven. Legend: one line per strip.
Hover and tap tooltips on blocks and cells (block, shard, prover, lag, payout). Lanes snap on resize (they used
to ease from a zero-height layout). Phone width, no horizontal scroll; draw 0.6 ms avg, 1 ms max with 110 blocks
on screen (playwright, 1280 px).
Hero (site/index.html): a faint second glint behind a real block once every shard of it is verified, only while
the proving layer is active; pace and sampling untouched.
Verified on the private 3-node proving network (tools/proving-v0/run.mjs --network-only, activation 60) with a
CPU prover loop signing as v0/v1/v2: records relayed, verified on node 0, carried and paid (block 155 by 405,
lag 259 DAA, 0.634 IGN); screenshots in docs/design/live-proving (devnet before activation at 1280 and 375 px,
test network active, the ?window=300 view with paid cells). The live devnet shows the "not yet activated;
activation not set" line once the observer runs this build.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
172 lines
9.7 KiB
JavaScript
172 lines
9.7 KiB
JavaScript
// Igneum live devnet feed. GET /api/live returns what tools/observer wrote to Neon:
|
|
// {now, state, blocks (last 90 s, or ?window=N up to 300), 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 empty) reads a test observer's tables (fintest_live_*, provtest_live_*) instead of the devnet's.
|
|
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
|
|
|
|
const STALE_AFTER_S = 30;
|
|
const T = (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 300, default 90): the page's diagnostic long view; the row cap keeps a busy DAG bounded
|
|
const q = new URL(req.url || '/', 'http://x').searchParams;
|
|
const windowS = Math.max(30, Math.min(300, Number(q.get('window')) || 90));
|
|
const [head, blockRows, minerRows, checkpointRows, proofRows] = 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`),
|
|
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color
|
|
FROM ${T}live_blocks
|
|
WHERE received_at > now() - ($1 || ' seconds')::interval
|
|
ORDER BY received_at DESC
|
|
LIMIT 2000`, [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 chain blocks in the window and what became of them (an older observer has no table)
|
|
sql(`SELECT block_hash, shard, shards, state, prover, lag_daa, payout_wei, pgas
|
|
FROM ${T}live_proofs
|
|
WHERE received_at > now() - ($1 || ' seconds')::interval
|
|
ORDER BY block_hash, shard`, [String(windowS + 30)]).catch(() => []),
|
|
]);
|
|
|
|
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,
|
|
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 proving = 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' };
|
|
|
|
const blocks = blockRows.reverse().map(b => ({
|
|
hash: short(b.hash),
|
|
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') }));
|
|
|
|
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) });
|
|
}
|
|
}
|