Devnet 3: the litepaper's live table reads Devnet 3 (first block 18:06 UK, first lock 20:02 UK, every upgrade on from block zero), its status chip, the precedents rows, the roadmap's phase 3 and the journey's phase 3; the nav pill names igneum-devnet-3 and chain id 4463 on every page (the live-dn3 edit had been made on the injected copy, which the build overwrote from the partial); evidence rows 7 and 10 carry Devnet 3's first block and first lock, row 17 the 5090 class v4 efficiency pass (136.84 MH/s at 475.5 W unlocked, 134.98 at 316.3 W at the 1,400 MHz lock, class v3 control 134.68 at 228.0 W), and a "what moved on 7 October" table. Versions: Ember 0.3.22 and Wallet 0.1.5 wherever the litepaper and the MetaMask page named 0.3.9, 0.3.13, 0.3.14 or 0.1.4; downloads.json refreshed from the live index (hive 0.3.22; the Mac and Windows aliases still read 0.3.20 there). Testnet: "weeks away, when the go checklist closes" becomes "armed, opens on the go word" on the home, download and miner pages, the litepaper (twice, plus roadmap row 5), the journey's phase 5, the ledger's X31 and O-X.2 rows and the ledger text check; no date anywhere. Repository: the nav and footer partials point at git.igneum.network/igneum-network/spec (label Source, a neutral glyph); the X1 sentence no longer says "private repository until the public testnet" (litepaper, ledger X1, the public ledger table, the text check); evidence rule 1 and the "what would move a row" table drop the private-repository condition; provenance drops "before the repository goes public" and gains a tooling table (Forgejo, Caddy, SP1, CaDiCaL, licences approximate). Cryptanalysis: the in-house adversarial pass replaces "paid independent cryptanalysis" on the litepaper (twice) and the public ledger page; the ledger page drops the USD 50,000 prize clauses (decision item 1 of 6 October: no bounty; nothing public about a prize). The staged outside-check clause stays as main's own cut. Dev fee: the miner page's fee table (and /dev-fee from it) adds the pool row: no software fee in pool mode, the pool's own fee is the only one (docs/plans/pool.md C6, the Hive README). MetaMask: Devnet 3 answers chain id 4463 until its class v5 floor, then 4464. Two founder-name comments reworded (site/api/live.mjs, tools/ci/README.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
189 lines
12 KiB
JavaScript
189 lines
12 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 on the founder's 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.
|
|
|
|
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] = 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`),
|
|
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(() => []),
|
|
]);
|
|
|
|
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 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),
|
|
// 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) });
|
|
}
|
|
}
|