Live devnet: observer, /api/live and the /live page
tools/observer: Node 22 observer on the node's wRPC JSON port (blockAdded and
virtualChainChanged subscriptions, 2 s state ticks) writing live_blocks,
live_state and live_events to Neon, miner address decoded from the coinbase
payload, events for new or quiet miners, peers and difficulty steps.
site/api/live.mjs: three indexed queries, max-age=1.
site/live.html: status strip, DAG stream with real parent edges and a lane per
miner, miners table, events feed, blocks-per-minute sparkline, OFFLINE freeze.
The homepage live path and the /api/live cache header went in with 8a222c2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
8a222c27e9
commit
fabd725bc9
4 changed files with 869 additions and 0 deletions
108
site/api/live.mjs
Normal file
108
site/api/live.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Igneum live devnet feed. GET /api/live returns what tools/observer wrote to Neon:
|
||||
// {now, state, blocks (last 90 s), miners (last 10 min), events (last 30)}.
|
||||
// Three queries, all on indexed timestamps. Cached one second at the edge.
|
||||
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
|
||||
|
||||
const STALE_AFTER_S = 30;
|
||||
|
||||
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();
|
||||
const [head, blockRows, minerRows] = await Promise.all([
|
||||
sql(`SELECT now() AS now,
|
||||
(SELECT row_to_json(s) FROM live_state s WHERE s.id = 1) AS state,
|
||||
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM 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
|
||||
FROM live_blocks
|
||||
WHERE received_at > now() - interval '90 seconds'
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 600`),
|
||||
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 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`),
|
||||
]);
|
||||
|
||||
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,
|
||||
updated_at: s ? s.updated_at : null,
|
||||
};
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const miners = minerRows.map(m => ({
|
||||
id: minerId(m.vote_key_hash),
|
||||
vote_key_hash: 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,
|
||||
address: m.address || null,
|
||||
}));
|
||||
|
||||
const events = (head[0].events || []).map(e => ({ ts: e.ts, kind: e.kind, text: e.text }));
|
||||
|
||||
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), state, blocks, miners, events });
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
332
site/live.html
Normal file
332
site/live.html
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Live devnet</title>
|
||||
<meta name="description" content="The Igneum devnet as it runs: blocks, miners, difficulty and peers, read from a node every two seconds.">
|
||||
<meta property="og:title" content="Igneum live devnet">
|
||||
<meta property="og:description" content="Watch the Igneum devnet mine. Real blocks, real parents, real miners.">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
<style>
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--fs-hero:clamp(32px,5.5vw,56px);--gutter:clamp(16px,4vw,32px);--max:1200px}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:'IBM Plex Sans',system-ui,-apple-system,sans-serif;font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}
|
||||
a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
img,svg,canvas{max-width:100%}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:'IBM Plex Mono',monospace}
|
||||
.eyebrow{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2,h3{font-family:'Unbounded',sans-serif;margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-hero);letter-spacing:-.01em}
|
||||
h2{font-weight:700;font-size:clamp(18px,2vw,22px)}
|
||||
p{margin:0}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:18px;padding:clamp(16px,3vw,28px);min-width:0}
|
||||
|
||||
/* nav, same as the homepage */
|
||||
.nav{position:sticky;top:0;z-index:20;background:rgba(12,12,14,.82);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.6)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:68px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.brand .word{font-family:'Unbounded',sans-serif;font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.links{display:flex;align-items:center;gap:26px;font-size:15px;font-weight:500}
|
||||
.links .btn{min-height:42px;padding:10px 16px;font-size:15px}
|
||||
.links a.here{color:var(--molten)}
|
||||
.burger{display:none;background:none;border:1px solid var(--line-2);color:var(--bone);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer}
|
||||
@media (max-width:900px){
|
||||
.links{display:none;position:absolute;left:0;right:0;top:68px;background:var(--obsidian);border-bottom:1px solid var(--line);padding:16px var(--gutter) 24px;flex-direction:column;align-items:stretch;gap:6px}
|
||||
.links a{padding:12px 6px;border-radius:8px}
|
||||
.links .btn{width:100%;margin-top:6px}
|
||||
.links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
|
||||
/* page */
|
||||
.head{padding-block:clamp(36px,6vw,64px) clamp(20px,3vw,28px);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:60ch;font-size:16px}
|
||||
.strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));gap:12px;margin-bottom:24px}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:'Unbounded',sans-serif;font-weight:700;font-size:clamp(18px,2.2vw,24px);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .s{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash)}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}
|
||||
.off .cell.status .v{color:var(--ash)}
|
||||
.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.viz-stats{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:12px;color:var(--ash)}
|
||||
.viz-stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
#dag{display:block;width:100%;height:clamp(240px,38vw,380px);border-radius:12px;background:var(--obsidian)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:16px 20px;margin-top:16px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:8px}
|
||||
.sw{width:14px;height:14px;border-radius:4px;display:inline-block}
|
||||
|
||||
.two{display:grid;gap:24px;grid-template-columns:1fr;margin-top:24px}
|
||||
@media (min-width:900px){.two{grid-template-columns:1.1fr .9fr}}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n{font-variant-numeric:tabular-nums;text-align:right}th.n{text-align:right}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.id{font-family:'IBM Plex Mono',monospace;color:var(--molten)}
|
||||
.bar{height:6px;border-radius:3px;background:var(--line);overflow:hidden;min-width:60px}
|
||||
.bar i{display:block;height:100%;background:var(--ember)}
|
||||
.feed{display:flex;flex-direction:column;margin-top:12px;font-family:'IBM Plex Mono',monospace;font-size:13px;color:var(--ink-2)}
|
||||
.feed div{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding:9px 0;align-items:baseline}
|
||||
.feed div:last-child{border-bottom:0}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:12px}
|
||||
.feed .k{color:var(--molten)}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
#spark{display:block;width:100%;height:120px;border-radius:12px;background:var(--obsidian);margin-top:12px}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px}
|
||||
main{padding-bottom:clamp(56px,8vw,96px)}
|
||||
|
||||
/* footer, same as the homepage */
|
||||
footer{border-top:1px solid var(--line);padding-block:48px 32px}
|
||||
.foot-grid{display:grid;gap:32px;grid-template-columns:1fr;margin-bottom:32px}
|
||||
@media (min-width:720px){.foot-grid{grid-template-columns:1.4fr 1fr 1fr}}
|
||||
.foot-brand p{color:var(--ash);font-size:15px;margin-top:12px;max-width:40ch}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{margin-bottom:6px}
|
||||
.foot-col a{color:var(--ink-2)}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding-top:24px;font-size:13px;color:var(--ash)}
|
||||
</style>
|
||||
</head>
|
||||
<body class="off">
|
||||
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg viewBox="0 0 100 100" width="30" height="30" aria-hidden="true"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="burger" aria-expanded="false" aria-controls="links" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg></button>
|
||||
<div class="links" id="links">
|
||||
<a href="/#mine">Mine</a>
|
||||
<a href="/#prove">Prove</a>
|
||||
<a href="/#build">Build</a>
|
||||
<a href="/#randomx">vs RandomX</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/#economics">Economics</a>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/bench">Log</a>
|
||||
<a href="/live" class="here" aria-current="page">Live</a>
|
||||
<a href="/#mine" class="btn primary">Get the miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow">devnet v0</div>
|
||||
<h1>Live devnet</h1>
|
||||
<p>A node is read every two seconds. Every block below is real: its parents, its miner and whether it sits on the selected chain. Proving and checkpoints are not in the devnet yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="strip" aria-live="polite">
|
||||
<div class="cell status"><div class="k">Status</div><div class="v"><span class="dot"></span><span id="st-status">OFFLINE</span></div><div class="s" id="st-age">no observer update yet</div></div>
|
||||
<div class="cell"><div class="k">Network</div><div class="v mono" id="st-network" style="font-family:'IBM Plex Mono',monospace;font-size:16px;font-weight:500">igneum-devnet</div><div class="s" id="st-node">node</div></div>
|
||||
<div class="cell"><div class="k">Blocks / s</div><div class="v" id="st-bps">0.00</div><div class="s">over 60 s</div></div>
|
||||
<div class="cell"><div class="k">Blue score</div><div class="v" id="st-blue">0</div><div class="s" id="st-count">0 blocks</div></div>
|
||||
<div class="cell"><div class="k">Difficulty</div><div class="v" id="st-diff">0</div><div class="s">target per block</div></div>
|
||||
<div class="cell"><div class="k">Hash rate</div><div class="v" id="st-hps">0</div><div class="s">network estimate</div></div>
|
||||
<div class="cell"><div class="k">Miners</div><div class="v" id="st-miners">0</div><div class="s">active in 10 min</div></div>
|
||||
<div class="cell"><div class="k">Peers</div><div class="v" id="st-peers">0</div><div class="s" id="st-mempool">mempool 0</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" aria-label="Block DAG, live">
|
||||
<div class="viz-head">
|
||||
<div class="eyebrow"><span class="dot" style="margin-right:8px"></span>block dag</div>
|
||||
<div class="viz-stats mono"><span>on screen <b id="c-screen">0</b></span><span>chain <b id="c-chain">0</b></span><span>side <b id="c-side">0</b></span><span>newest blue <b id="c-newest">0</b></span></div>
|
||||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend">
|
||||
<span><i class="sw" style="background:var(--ember)"></i>chain block</span>
|
||||
<span><i class="sw" style="border:2px solid var(--ash)"></i>side block (blue or red, off the selected chain)</span>
|
||||
<span><i class="sw" style="border:2px solid var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)"></i>just arrived</span>
|
||||
<span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>parent edge</span>
|
||||
<span class="mono" style="color:var(--molten)">2ea5116e</span><span style="margin-left:-12px">miner short id, one lane each</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div class="card">
|
||||
<div class="viz-head"><h2>Miners</h2><div class="eyebrow">last 10 min</div></div>
|
||||
<div class="tbl">
|
||||
<table>
|
||||
<thead><tr><th>Miner</th><th class="n">Blocks</th><th>Share</th><th>Last seen</th><th>Engine</th></tr></thead>
|
||||
<tbody id="miners"><tr><td colspan="5" class="empty">No blocks in the last 10 minutes.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="note">The short id is the first 8 hex characters of the vote key hash in each header. The engine is what the miner writes into the coinbase; the devnet miner writes nothing yet.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="viz-head"><h2>Events</h2><div class="eyebrow">newest first</div></div>
|
||||
<div class="feed" id="events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:24px">
|
||||
<div class="viz-head"><h2>Blocks per minute</h2><div class="eyebrow" id="spark-range">last hour</div></div>
|
||||
<canvas id="spark" aria-hidden="true"></canvas>
|
||||
<p class="note" id="spark-note">Counted by the observer as blocks arrive.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<div class="brand"><svg viewBox="0 0 100 100" width="26" height="26" aria-hidden="true"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></svg><span class="word" style="font-size:16px;color:var(--bone)">IGNEUM</span></div>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="https://github.com/igneum-network/igneum" rel="noopener" style="display:inline-flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>GitHub</a>
|
||||
<a href="/#mine">Get the miner</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var reduce=window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var dpr=Math.min(2,window.devicePixelRatio||1);
|
||||
|
||||
// nav
|
||||
var burger=document.getElementById('burger'),links=document.getElementById('links');
|
||||
burger.addEventListener('click',function(){var open=links.classList.toggle('open');burger.setAttribute('aria-expanded',open?'true':'false');});
|
||||
|
||||
// formatting
|
||||
function $(id){return document.getElementById(id);}
|
||||
function compact(n){if(n===null||n===undefined||isNaN(n))return '0';var a=Math.abs(n);if(a>=1e12)return (n/1e12).toFixed(2)+'T';if(a>=1e9)return (n/1e9).toFixed(2)+'G';if(a>=1e6)return (n/1e6).toFixed(2)+'M';if(a>=1e3)return (n/1e3).toFixed(1)+'k';return String(Math.round(n));}
|
||||
function hashrate(n){if(n===null||n===undefined)return 'n/a';var u=['H/s','kH/s','MH/s','GH/s','TH/s'],i=0;while(n>=1000&&i<u.length-1){n/=1000;i++;}return (i?n.toFixed(2):Math.round(n))+' '+u[i];}
|
||||
function rel(ms){var s=Math.max(0,Math.round(ms/1000));if(s<60)return s+' s ago';var m=Math.floor(s/60);if(m<60)return m+' min ago';var h=Math.floor(m/60);if(h<24)return h+' h '+(m%60)+' min ago';return Math.floor(h/24)+' d ago';}
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
// ---------- DAG scene ----------
|
||||
var c=$('dag'),x=c.getContext('2d'),W,H,S,blocks=[],byHash={},lanesOf={},laneOrder=[],maxLanes=6,pxPerSec=40,targetPx=40;
|
||||
var live=false,serverNow=0,serverAt=0,frozenAt=null,note='',bps=0;
|
||||
function size(){W=c.clientWidth;H=c.clientHeight;c.width=W*dpr;c.height=H*dpr;x.setTransform(dpr,0,0,dpr,0,0);S=W<600?16:22;maxLanes=H<260?4:6;}
|
||||
size();window.addEventListener('resize',function(){size();draw(performance.now());});
|
||||
function laneCount(){return Math.max(1,Math.min(maxLanes,laneOrder.length));}
|
||||
function laneFor(m){m=m||'?';if(!(m in lanesOf)){lanesOf[m]=laneOrder.length;laneOrder.push(m);}return lanesOf[m]%laneCount();}
|
||||
function laneY(l){var n=laneCount();if(n===1)return H*0.5;return H*0.16+l*(H*0.62/(n-1));}
|
||||
function jitter(h){var v=0;for(var i=0;i<8;i++)v=(v*31+h.charCodeAt(i))%1000;return (v/1000-0.5)*S*0.5;}
|
||||
function vnow(){if(frozenAt!==null)return frozenAt;return serverNow+(performance.now()-serverAt);}
|
||||
function ingest(d){
|
||||
var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now;
|
||||
var seen={};
|
||||
d.blocks.forEach(function(b){seen[b.hash]=1;var e=byHash[b.hash];
|
||||
if(e){e.chain=b.chain;return;}
|
||||
laneFor(b.miner);
|
||||
e={hash:b.hash,ts:b.ts,rx:b.rx,blue_score:b.blue_score,parents:b.parents,chain:b.chain,miner:b.miner||'?',born:now,j:jitter(b.hash)};
|
||||
byHash[b.hash]=e;blocks.push(e);});
|
||||
// drop what fell off the left edge long ago
|
||||
var cut=vnow()-(W/pxPerSec)*1000-20000;
|
||||
blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;});
|
||||
blocks.sort(function(a,b){return a.ts-b.ts;});
|
||||
bps=d.state.blocks_per_second_60s||0;
|
||||
targetPx=Math.max(S*0.4,Math.min(420,bps*S*1.7));
|
||||
var chain=0;blocks.forEach(function(b){if(b.chain)chain++;});
|
||||
$('c-screen').textContent=blocks.length;$('c-chain').textContent=chain;$('c-side').textContent=blocks.length-chain;
|
||||
$('c-newest').textContent=blocks.length?compact(blocks[blocks.length-1].blue_score):'0';
|
||||
}
|
||||
function rrect(px,py,w,h,r){x.beginPath();x.moveTo(px+r,py);x.lineTo(px+w-r,py);x.quadraticCurveTo(px+w,py,px+w,py+r);x.lineTo(px+w,py+h-r);x.quadraticCurveTo(px+w,py+h,px+w-r,py+h);x.lineTo(px+r,py+h);x.quadraticCurveTo(px,py+h,px,py+h-r);x.lineTo(px,py+r);x.quadraticCurveTo(px,py,px+r,py);x.closePath();}
|
||||
function xOf(b,t){return Math.min(W-S,W-S*1.2-(t-b.ts)*pxPerSec/1000);}
|
||||
function draw(now){
|
||||
pxPerSec+=(targetPx-pxPerSec)*0.05;
|
||||
var t=vnow();
|
||||
x.clearRect(0,0,W,H);
|
||||
// lane rails and miner labels
|
||||
var n=laneCount();x.font='500 10px IBM Plex Mono, monospace';x.textAlign='left';
|
||||
for(var l=0;l<n;l++){var y=laneY(l);x.strokeStyle='rgba(42,42,48,0.7)';x.lineWidth=1;x.beginPath();x.moveTo(0,y);x.lineTo(W,y);x.stroke();
|
||||
var names=laneOrder.filter(function(m,i){return i%n===l;});if(names.length){x.fillStyle='rgba(154,154,158,0.75)';x.fillText(names.slice(0,2).join(' ')+(names.length>2?' +'+(names.length-2):''),8,y-S*0.75);}}
|
||||
var pos={};blocks.forEach(function(b){pos[b.hash]={x:xOf(b,t),y:laneY(laneFor(b.miner))+b.j};});
|
||||
// edges
|
||||
blocks.forEach(function(b){var p=pos[b.hash];b.parents.forEach(function(h){var q=pos[h];if(!q)return;var both=b.chain&&byHash[h]&&byHash[h].chain;
|
||||
x.strokeStyle=both?'rgba(242,84,27,0.75)':'rgba(201,199,194,0.45)';x.lineWidth=both?2:1.25;x.beginPath();x.moveTo(p.x-S/2,p.y);var mx=(p.x+q.x)/2;x.bezierCurveTo(mx,p.y,mx,q.y,q.x+S/2,q.y);x.stroke();});});
|
||||
// blocks
|
||||
blocks.forEach(function(b){var p=pos[b.hash],h=S/2,age=now-b.born,fl=Math.max(0,1-age/900);
|
||||
if(fl>0){var g=x.createRadialGradient(p.x,p.y,0,p.x,p.y,S*1.8);g.addColorStop(0,'rgba(255,179,92,'+(0.55*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(p.x,p.y,S*1.8,0,Math.PI*2);x.fill();}
|
||||
if(b.chain){x.fillStyle='#F2541B';rrect(p.x-h,p.y-h,S,S,S*0.24);x.fill();}
|
||||
else{x.fillStyle='#0C0C0E';rrect(p.x-h,p.y-h,S,S,S*0.24);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':'rgba(154,154,158,0.9)';x.lineWidth=2;rrect(p.x-h,p.y-h,S,S,S*0.24);x.stroke();}
|
||||
if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(p.x-h-2,p.y-h-2,S+4,S+4,S*0.3);x.stroke();}
|
||||
x.fillStyle=b.chain?'rgba(244,241,236,0.9)':'rgba(154,154,158,0.9)';x.font='500 '+(S<20?8:9)+'px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(b.miner,p.x,p.y+h+11);});
|
||||
// tip marker
|
||||
if(blocks.length){var tip=blocks[blocks.length-1],tp=pos[tip.hash];x.fillStyle='rgba(154,154,158,0.9)';x.font='500 10px IBM Plex Mono, monospace';x.textAlign='right';x.fillText('tip',Math.min(W-6,tp.x+S*0.5),Math.max(12,tp.y-S*0.75));}
|
||||
if(!blocks.length){x.fillStyle='rgba(154,154,158,0.9)';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(live?'waiting for the first block':'no observer data',W/2,H/2);}
|
||||
if(note){x.fillStyle='rgba(12,12,14,0.72)';x.fillRect(0,H/2-22,W,44);x.fillStyle='#F4F1EC';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(note,W/2,H/2+5);}
|
||||
}
|
||||
function frame(now){draw(now);if(!reduce)requestAnimationFrame(frame);}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// ---------- sparkline ----------
|
||||
var sc=$('spark'),sx=sc.getContext('2d');
|
||||
function spark(pairs){var w=sc.clientWidth,h=sc.clientHeight;sc.width=w*dpr;sc.height=h*dpr;sx.setTransform(dpr,0,0,dpr,0,0);sx.clearRect(0,0,w,h);
|
||||
if(!pairs||!pairs.length){sx.fillStyle='rgba(154,154,158,0.9)';sx.font='500 12px IBM Plex Mono, monospace';sx.textAlign='center';sx.fillText('no data yet',w/2,h/2);return;}
|
||||
var vals=pairs.map(function(p){return p[1];}),max=Math.max(1,Math.max.apply(null,vals)),pad=10,n=vals.length;
|
||||
function px(i){return pad+i*(w-2*pad)/Math.max(1,n-1);}function py(v){return h-pad-(v/max)*(h-2*pad-12);}
|
||||
sx.strokeStyle='rgba(42,42,48,0.9)';sx.lineWidth=1;sx.beginPath();sx.moveTo(pad,py(0));sx.lineTo(w-pad,py(0));sx.stroke();
|
||||
sx.beginPath();sx.moveTo(px(0),py(0));vals.forEach(function(v,i){sx.lineTo(px(i),py(v));});sx.lineTo(px(n-1),py(0));sx.closePath();sx.fillStyle='rgba(242,84,27,0.14)';sx.fill();
|
||||
sx.beginPath();vals.forEach(function(v,i){i?sx.lineTo(px(i),py(v)):sx.moveTo(px(i),py(v));});sx.strokeStyle='#F2541B';sx.lineWidth=2;sx.lineJoin='round';sx.stroke();
|
||||
var li=n-1;sx.fillStyle='#FFB35C';sx.beginPath();sx.arc(px(li),py(vals[li]),3,0,Math.PI*2);sx.fill();
|
||||
sx.fillStyle='rgba(154,154,158,0.9)';sx.font='500 10px IBM Plex Mono, monospace';sx.textAlign='left';sx.fillText('max '+max+' / min',pad,12);sx.textAlign='right';sx.fillText('now',w-pad,12);
|
||||
var total=vals.reduce(function(a,b){return a+b;},0);$('spark-note').textContent=total+' blocks in the last hour, '+(total/60).toFixed(1)+' per minute on average.';}
|
||||
|
||||
// ---------- polling ----------
|
||||
var minersEl=$('miners'),eventsEl=$('events'),failures=0;
|
||||
function setStatus(on,ageText){document.body.className=on?'on':'off';$('st-status').textContent=on?'LIVE':'OFFLINE';$('st-age').textContent=ageText;}
|
||||
function render(d){
|
||||
var s=d.state,on=!s.stale;
|
||||
setStatus(on,s.age_s===null?'no observer update yet':'observer updated '+rel(s.age_s*1000));
|
||||
$('st-network').textContent=s.network||'igneum-devnet';$('st-node').textContent=s.node_version?'node '+s.node_version:'node';
|
||||
$('st-bps').textContent=(s.blocks_per_second_60s||0).toFixed(2);
|
||||
$('st-blue').textContent=compact(s.blue_score);$('st-count').textContent=compact(s.block_count)+' blocks, '+compact(s.header_count)+' headers';
|
||||
$('st-diff').textContent=compact(s.difficulty);$('st-hps').textContent=hashrate(s.hashes_per_second_estimate);
|
||||
$('st-miners').textContent=s.miners_10m;$('st-peers').textContent=s.peers===null?'0':s.peers;$('st-mempool').textContent='mempool '+(s.mempool||0);
|
||||
var now=new Date(d.now).getTime();
|
||||
if(d.miners.length){minersEl.innerHTML=d.miners.map(function(m){return '<tr><td><span class="id">'+esc(m.id)+'</span></td><td class="n">'+m.blocks+'</td><td><div style="display:flex;align-items:center;gap:8px"><div class="bar" style="width:70px"><i style="width:'+m.share+'%"></i></div><span class="mono" style="font-size:12px">'+m.share+'%</span></div></td><td>'+rel(now-new Date(m.last_seen).getTime())+'</td><td>'+(m.engine?esc(m.engine):'<span style="color:var(--ash)">not reported</span>')+'</td></tr>';}).join('');}
|
||||
else minersEl.innerHTML='<tr><td colspan="5" class="empty">No blocks in the last 10 minutes.</td></tr>';
|
||||
if(d.events.length){eventsEl.innerHTML=d.events.map(function(e){return '<div><span><span class="k">'+esc(e.kind)+'</span> '+esc(e.text)+'</span><span class="t">'+rel(now-new Date(e.ts).getTime())+'</span></div>';}).join('');}
|
||||
else eventsEl.innerHTML='<div class="empty">No events yet.</div>';
|
||||
spark(s.blocks_per_minute);
|
||||
live=on;
|
||||
if(on){frozenAt=null;note='';ingest(d);}
|
||||
else{if(frozenAt===null)frozenAt=vnow()||now;note='observer offline, last update '+(s.age_s===null?'unknown':rel(s.age_s*1000));}
|
||||
}
|
||||
function poll(){fetch('/api/live',{cache:'no-store'}).then(function(r){if(!r.ok)throw new Error(r.status);return r.json();}).then(function(d){if(!d.ok)throw new Error(d.error);failures=0;render(d);}).catch(function(){failures++;if(failures>=3){setStatus(false,'api unreachable');if(frozenAt===null)frozenAt=vnow();note='api unreachable, scene frozen';}});}
|
||||
poll();setInterval(poll,2000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
47
tools/observer/README.md
Normal file
47
tools/observer/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Igneum devnet observer
|
||||
|
||||
Watches one Igneum node over wRPC JSON and writes what it sees to Neon, so `/api/live` and `/live` on the site can show the devnet as it runs. Node 22 or newer, no dependencies.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
node tools/observer/observer.mjs
|
||||
```
|
||||
|
||||
Environment, every value optional:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `IGNEUM_RPC` | `ws://127.0.0.1:28610` | The node's wRPC JSON url. 28610 is the igneum-devnet wRPC JSON port (`consensus/core/src/network.rs`). Start the node with `--rpclisten-json=127.0.0.1:28610` or the port of your choice. |
|
||||
| `DATABASE_URL` | read from `~/.config/igneum/env` | Neon connection string. Never commit it. |
|
||||
| `LIVE_RETAIN_HOURS` | `24` | Hours of blocks kept in `live_blocks`. Older rows are deleted once a minute. |
|
||||
|
||||
A node started by another tool may listen on gRPC only. Then run your own non-mining peer with a JSON listener, on ports that do not clash with the devnet's (gRPC 26610, P2P 26611):
|
||||
|
||||
```
|
||||
vendor/igneum-node/target/release/kaspad --devnet --nodnsseed --disable-upnp \
|
||||
--appdir=/tmp/igneum-obsnode --rpclisten=127.0.0.1:26640 --rpclisten-json=127.0.0.1:28640 \
|
||||
--listen=127.0.0.1:26641 --connect=127.0.0.1:26611 --nologfiles
|
||||
IGNEUM_RPC=ws://127.0.0.1:28640 node tools/observer/observer.mjs
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- Subscribes to `blockAdded` and `virtualChainChanged`; polls `getBlockDagInfo`, `getInfo`, `getConnectedPeerInfo`, `getSinkBlueScore` and `estimateNetworkHashesPerSecond` every 2 s.
|
||||
- Decodes the miner address from the coinbase payload script (same bech32 variant as `crypto/addresses`). The fork's `vote_key_hash` header field is stored per block; the first 8 hex characters are the miner's short id on the site.
|
||||
- `engine` is the miner's tag in the coinbase extra data after the node's version prefix. The node exposes no engine name over RPC, so this is null on devnet v0.
|
||||
- When the node refuses the hash-rate estimate (it needs a 1,000-block window) the observer reports blue work added per second over the last 10 minutes instead.
|
||||
|
||||
## Tables
|
||||
|
||||
Created on start if missing.
|
||||
|
||||
| Table | Rows | Columns |
|
||||
|---|---|---|
|
||||
| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`. Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`. |
|
||||
| `live_state` | one row, updated every 2 s | `block_count`, `header_count`, `blue_score`, `difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `node_version`, `network`, `blocks_60s`, `blocks_per_minute` (60 pairs of minute epoch ms and count), `observer_started_at`, `updated_at` |
|
||||
| `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%). Checkpoints will be added when the finality layer lands. |
|
||||
|
||||
## Reading it
|
||||
|
||||
`site/api/live.mjs` serves `/api/live` from these tables in three indexed queries. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s.
|
||||
382
tools/observer/observer.mjs
Normal file
382
tools/observer/observer.mjs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
#!/usr/bin/env node
|
||||
// Igneum devnet observer. Connects to a node's wRPC JSON endpoint, subscribes to new blocks and
|
||||
// writes what it sees to Neon so /api/live and /live can show it.
|
||||
//
|
||||
// node tools/observer/observer.mjs
|
||||
//
|
||||
// Environment (every value has a default):
|
||||
// IGNEUM_RPC wRPC JSON url of the node default ws://127.0.0.1:28610 (igneum-devnet wRPC JSON port)
|
||||
// DATABASE_URL Neon connection string default: read from ~/.config/igneum/env
|
||||
// LIVE_RETAIN_HOURS hours of blocks to keep default 24
|
||||
//
|
||||
// Tables (created on start if missing): live_blocks, live_state, live_events. See README.md.
|
||||
// Zero dependencies: Node 22 WebSocket and fetch, Neon's HTTP SQL endpoint.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610';
|
||||
const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24);
|
||||
const STATE_EVERY_MS = 2000;
|
||||
const FLUSH_EVERY_MS = 500;
|
||||
const PRUNE_EVERY_MS = 60_000;
|
||||
const QUIET_AFTER_MS = 5 * 60_000;
|
||||
const DIFFICULTY_STEP = 0.05;
|
||||
const STARTED_AT = new Date();
|
||||
|
||||
// ---------- Neon ----------
|
||||
function databaseUrl() {
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
let env = '';
|
||||
try { env = readFileSync(`${homedir()}/.config/igneum/env`, 'utf8'); } catch { }
|
||||
const m = /^DATABASE_URL=(.*)$/m.exec(env);
|
||||
if (!m) { console.error('DATABASE_URL is not set and ~/.config/igneum/env has none'); process.exit(1); }
|
||||
return m[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
const DB_URL = databaseUrl();
|
||||
const DB_HOST = new URL(DB_URL).hostname.replace('-pooler', '');
|
||||
|
||||
async function sql(query, params = []) {
|
||||
const r = await fetch(`https://${DB_HOST}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': DB_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 || [];
|
||||
}
|
||||
|
||||
async function setupSchema() {
|
||||
const stmts = [
|
||||
`CREATE TABLE IF NOT EXISTS live_blocks (
|
||||
hash text PRIMARY KEY,
|
||||
blue_score bigint NOT NULL,
|
||||
daa_score bigint NOT NULL,
|
||||
timestamp_ms bigint NOT NULL,
|
||||
parents int NOT NULL,
|
||||
parent_hashes text[] NOT NULL DEFAULT '{}',
|
||||
is_chain_block boolean NOT NULL DEFAULT false,
|
||||
vote_key_hash text,
|
||||
miner_address text,
|
||||
engine text,
|
||||
received_at timestamptz NOT NULL DEFAULT now())`,
|
||||
`CREATE INDEX IF NOT EXISTS live_blocks_received_at ON live_blocks (received_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS live_blocks_timestamp_ms ON live_blocks (timestamp_ms)`,
|
||||
`CREATE INDEX IF NOT EXISTS live_blocks_vote_key_received ON live_blocks (vote_key_hash, received_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS live_state (
|
||||
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
block_count bigint, header_count bigint, blue_score bigint, difficulty double precision,
|
||||
hashes_per_second_estimate bigint, peers int, mempool int,
|
||||
node_version text, network text,
|
||||
blocks_60s int, blocks_per_minute jsonb,
|
||||
observer_started_at timestamptz, updated_at timestamptz)`,
|
||||
`CREATE TABLE IF NOT EXISTS live_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
kind text NOT NULL,
|
||||
text text NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS live_events_ts ON live_events (ts)`,
|
||||
];
|
||||
for (const s of stmts) await sql(s);
|
||||
}
|
||||
|
||||
// ---------- Address decoding (crypto/addresses/src/bech32.rs, ported) ----------
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
function polymod(values) {
|
||||
let c = 1n;
|
||||
for (const d of values) {
|
||||
const c0 = c >> 35n;
|
||||
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
|
||||
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
|
||||
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
|
||||
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
|
||||
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
|
||||
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
|
||||
}
|
||||
return c ^ 1n;
|
||||
}
|
||||
function conv8to5(bytes) {
|
||||
const out = []; let buff = 0, bits = 0;
|
||||
for (const b of bytes) {
|
||||
buff = ((buff << 8) | b) & 0xffff; bits += 8;
|
||||
while (bits >= 5) { bits -= 5; out.push((buff >> bits) & 31); buff &= (1 << bits) - 1; }
|
||||
}
|
||||
if (bits > 0) out.push((buff << (5 - bits)) & 31);
|
||||
return out;
|
||||
}
|
||||
export function encodeAddress(prefix, version, payload) {
|
||||
const five = conv8to5([version, ...payload]);
|
||||
const pre = [...prefix].map(ch => ch.charCodeAt(0) & 0x1f);
|
||||
const sum = polymod([...pre, 0, ...five, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const sumBytes = []; for (let i = 4; i >= 0; i--) sumBytes.push(Number((sum >> BigInt(i * 8)) & 0xffn));
|
||||
return `${prefix}:${[...five, ...conv8to5(sumBytes)].map(v => CHARSET[v]).join('')}`;
|
||||
}
|
||||
function scriptToAddress(prefix, script) {
|
||||
if (script.length === 34 && script[0] === 0x20 && script[33] === 0xac) return encodeAddress(prefix, 0, script.slice(1, 33));
|
||||
if (script.length === 35 && script[0] === 0x21 && script[34] === 0xab) return encodeAddress(prefix, 1, script.slice(1, 34));
|
||||
if (script.length === 35 && script[0] === 0xaa && script[1] === 0x20 && script[34] === 0x87) return encodeAddress(prefix, 8, script.slice(2, 34));
|
||||
return null;
|
||||
}
|
||||
function payloadBytes(p) {
|
||||
if (Array.isArray(p)) return Uint8Array.from(p);
|
||||
if (typeof p === 'string') return Uint8Array.from(Buffer.from(p, 'hex'));
|
||||
return new Uint8Array();
|
||||
}
|
||||
// Coinbase payload: blue score u64 LE, subsidy u64 LE, script version u16 LE, script length u8, script, extra data.
|
||||
function minerFromCoinbase(block, prefix) {
|
||||
const tx = block.transactions && block.transactions[0];
|
||||
if (!tx) return { address: null, extra: null };
|
||||
const b = payloadBytes(tx.payload);
|
||||
if (b.length < 19) return { address: null, extra: null };
|
||||
const len = b[18];
|
||||
const script = b.slice(19, 19 + len);
|
||||
const extra = b.slice(19 + len);
|
||||
// The node prefixes the extra data with its own version tag ("2.1.0/"). Anything after that is the
|
||||
// miner's tag (engine or label). The node exposes no engine name over RPC, so this is null on devnet v0.
|
||||
const text = Buffer.from(extra).toString('utf8').replace(/[^\x20-\x7e]/g, '').replace(/^\d+\.\d+\.\d+(-[\w.]+)?\//, '').trim();
|
||||
return { address: scriptToAddress(prefix, script), extra: text ? text.slice(0, 64) : null };
|
||||
}
|
||||
|
||||
// ---------- RPC client ----------
|
||||
class Rpc {
|
||||
constructor(url) { this.url = url; this.id = 0; this.pending = new Map(); this.onNotification = () => { }; this.ws = null; this.open = false; }
|
||||
connect() {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(this.url); this.ws = ws;
|
||||
ws.onopen = () => { this.open = true; log('rpc connected', this.url); resolve(true); };
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (m.id !== undefined && m.id !== null && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id); this.pending.delete(m.id);
|
||||
m.error ? p.reject(new Error(m.error.message || JSON.stringify(m.error))) : p.resolve(m.params);
|
||||
} else if (m.method) this.onNotification(m.method, m.params);
|
||||
};
|
||||
ws.onerror = () => { };
|
||||
ws.onclose = () => {
|
||||
const wasOpen = this.open; this.open = false;
|
||||
for (const p of this.pending.values()) p.reject(new Error('rpc closed'));
|
||||
this.pending.clear();
|
||||
if (wasOpen) log('rpc closed'); else resolve(false);
|
||||
this.onClose && this.onClose();
|
||||
};
|
||||
});
|
||||
}
|
||||
call(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.open) return reject(new Error('rpc not connected'));
|
||||
const id = ++this.id; this.pending.set(id, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`${method} timed out`)); } }, 10_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Observer ----------
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
const short = h => (h || '').slice(0, 8);
|
||||
|
||||
const pendingBlocks = []; // rows waiting for the next flush
|
||||
const pendingChain = { add: new Set(), remove: new Set() };
|
||||
const miners = new Map(); // vote_key_hash -> { lastSeen ms, quiet bool }
|
||||
const minuteCounts = new Map(); // minute epoch -> count (last 60 minutes)
|
||||
const recentArrivals = []; // ms timestamps of arrivals in the last 60 s
|
||||
const workSamples = []; // { t: ms, w: BigInt blue work } of the heaviest header seen, last 10 min
|
||||
let lastDifficultyEvent = null;
|
||||
|
||||
// Fallback hash-rate estimate when the node refuses estimateNetworkHashesPerSecond (it needs a 1,000-block
|
||||
// window): blue work added per second over the last 10 minutes, the same quantity the node measures.
|
||||
function noteWork(now, blueWorkHex) {
|
||||
if (!blueWorkHex) return;
|
||||
let w; try { w = BigInt('0x' + blueWorkHex); } catch { return; }
|
||||
const last = workSamples[workSamples.length - 1];
|
||||
if (last && w <= last.w) return;
|
||||
workSamples.push({ t: now, w });
|
||||
while (workSamples.length && workSamples[0].t < now - 10 * 60_000) workSamples.shift();
|
||||
}
|
||||
function localHashesPerSecond() {
|
||||
if (workSamples.length < 2) return null;
|
||||
const a = workSamples[0], b = workSamples[workSamples.length - 1];
|
||||
const secs = (b.t - a.t) / 1000;
|
||||
if (secs < 20) return null;
|
||||
return Number((b.w - a.w) / BigInt(Math.round(secs)));
|
||||
}
|
||||
let knownPeers = null; // Set of peer ids from the previous tick
|
||||
let network = 'igneum-devnet';
|
||||
let addressPrefix = 'igneumdev';
|
||||
let nodeVersion = null;
|
||||
let sinkHash = null;
|
||||
|
||||
async function recordEvent(kind, text) {
|
||||
log('event', kind, text);
|
||||
try { await sql('INSERT INTO live_events (kind, text) VALUES ($1, $2)', [kind, text]); } catch (e) { log('event write failed', e.message); }
|
||||
}
|
||||
|
||||
function noteArrival(now) {
|
||||
recentArrivals.push(now);
|
||||
const minute = Math.floor(now / 60_000) * 60_000;
|
||||
minuteCounts.set(minute, (minuteCounts.get(minute) || 0) + 1);
|
||||
for (const k of minuteCounts.keys()) if (k < now - 61 * 60_000) minuteCounts.delete(k);
|
||||
}
|
||||
function blocks60s(now) {
|
||||
while (recentArrivals.length && recentArrivals[0] < now - 60_000) recentArrivals.shift();
|
||||
return recentArrivals.length;
|
||||
}
|
||||
function blocksPerMinute(now) {
|
||||
const out = []; const start = Math.floor(now / 60_000) * 60_000 - 59 * 60_000;
|
||||
for (let m = start; m <= now; m += 60_000) out.push([m, minuteCounts.get(m) || 0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function onBlock(block) {
|
||||
const h = block.header; if (!h || !h.hash) return;
|
||||
const now = Date.now();
|
||||
const parents = (h.parentsByLevel && h.parentsByLevel[0]) || [];
|
||||
const miner = minerFromCoinbase(block, addressPrefix);
|
||||
const vk = h.voteKeyHash || null;
|
||||
pendingBlocks.push({
|
||||
hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp,
|
||||
parents: parents.length, parent_hashes: parents,
|
||||
is_chain_block: !!(block.verboseData && block.verboseData.isChainBlock),
|
||||
vote_key_hash: vk, miner_address: miner.address, engine: miner.extra,
|
||||
});
|
||||
noteArrival(now);
|
||||
noteWork(now, h.blueWork);
|
||||
if (vk) {
|
||||
const m = miners.get(vk);
|
||||
if (!m) { miners.set(vk, { lastSeen: now, quiet: false }); recordEvent('miner_seen', `New miner ${short(vk)} seen${miner.address ? ` (${miner.address.slice(0, 24)}...)` : ''}`); }
|
||||
else { if (m.quiet) { m.quiet = false; recordEvent('miner_back', `Miner ${short(vk)} is back after a quiet spell`); } m.lastSeen = now; }
|
||||
}
|
||||
}
|
||||
|
||||
function pgArray(list) { return `{${list.map(s => `"${String(s).replace(/["\\]/g, '')}"`).join(',')}}`; }
|
||||
|
||||
async function flushBlocks() {
|
||||
if (!pendingBlocks.length) return;
|
||||
const rows = pendingBlocks.splice(0, 200);
|
||||
const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine'];
|
||||
const params = []; const values = [];
|
||||
for (const r of rows) {
|
||||
const ph = [];
|
||||
for (const c of cols) { params.push(c === 'parent_hashes' ? pgArray(r[c]) : r[c]); ph.push(`$${params.length}${c === 'parent_hashes' ? '::text[]' : ''}`); }
|
||||
values.push(`(${ph.join(',')})`);
|
||||
}
|
||||
try {
|
||||
await sql(`INSERT INTO live_blocks (${cols.join(',')}) VALUES ${values.join(',')} ON CONFLICT (hash) DO NOTHING`, params);
|
||||
} catch (e) { log('block insert failed', e.message); pendingBlocks.unshift(...rows.slice(0, 50)); }
|
||||
if (pendingBlocks.length) await flushBlocks();
|
||||
}
|
||||
|
||||
async function flushChain() {
|
||||
if (pendingChain.add.size) { const a = [...pendingChain.add]; pendingChain.add.clear(); try { await sql('UPDATE live_blocks SET is_chain_block = true WHERE hash = ANY($1::text[]) AND NOT is_chain_block', [pgArray(a)]); } catch (e) { log('chain update failed', e.message); } }
|
||||
if (pendingChain.remove.size) { const r = [...pendingChain.remove]; pendingChain.remove.clear(); try { await sql('UPDATE live_blocks SET is_chain_block = false WHERE hash = ANY($1::text[]) AND is_chain_block', [pgArray(r)]); } catch (e) { log('chain update failed', e.message); } }
|
||||
}
|
||||
|
||||
async function tick(rpc) {
|
||||
const now = Date.now();
|
||||
let dag, info, peersRes, hps;
|
||||
try {
|
||||
[dag, info, peersRes, hps] = await Promise.all([
|
||||
rpc.call('getBlockDagInfo', {}),
|
||||
rpc.call('getInfo', {}),
|
||||
rpc.call('getConnectedPeerInfo', {}),
|
||||
rpc.call('estimateNetworkHashesPerSecond', { windowSize: 1000, startHash: null }).catch(() => null),
|
||||
]);
|
||||
} catch (e) { log('tick failed', e.message); return; }
|
||||
if (dag.network) network = String(dag.network).startsWith('igneum') ? dag.network : `igneum-${dag.network}`;
|
||||
nodeVersion = info.serverVersion || nodeVersion;
|
||||
if (dag.sink && dag.sink !== sinkHash) { sinkHash = dag.sink; pendingChain.add.add(dag.sink); }
|
||||
|
||||
// peers joined or left
|
||||
const peers = peersRes.peerInfo || [];
|
||||
const ids = new Map(peers.map(p => [p.id, `${p.address && p.address.ip}:${p.address && p.address.port}`]));
|
||||
if (knownPeers) {
|
||||
for (const [id, addr] of ids) if (!knownPeers.has(id)) recordEvent('peer_joined', `Peer joined: ${addr}`);
|
||||
for (const [id, addr] of knownPeers) if (!ids.has(id)) recordEvent('peer_left', `Peer left: ${addr}`);
|
||||
}
|
||||
knownPeers = ids;
|
||||
|
||||
// difficulty step over 5%
|
||||
const diff = Number(dag.difficulty) || 0;
|
||||
if (lastDifficultyEvent === null) lastDifficultyEvent = diff;
|
||||
else if (lastDifficultyEvent > 0 && Math.abs(diff - lastDifficultyEvent) / lastDifficultyEvent > DIFFICULTY_STEP) {
|
||||
const pct = ((diff - lastDifficultyEvent) / lastDifficultyEvent * 100).toFixed(1);
|
||||
recordEvent('difficulty', `Difficulty ${diff > lastDifficultyEvent ? 'up' : 'down'} ${Math.abs(pct)}% to ${diff.toLocaleString('en-GB', { maximumFractionDigits: 0 })}`);
|
||||
lastDifficultyEvent = diff;
|
||||
}
|
||||
|
||||
// miners gone quiet
|
||||
for (const [vk, m] of miners) if (!m.quiet && now - m.lastSeen > QUIET_AFTER_MS) { m.quiet = true; recordEvent('miner_quiet', `Miner ${short(vk)} has gone quiet (no block for 5 min)`); }
|
||||
|
||||
try {
|
||||
await sql(`INSERT INTO live_state (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool,
|
||||
node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at)
|
||||
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
|
||||
ON CONFLICT (id) DO UPDATE SET block_count = EXCLUDED.block_count, header_count = EXCLUDED.header_count, blue_score = EXCLUDED.blue_score,
|
||||
difficulty = EXCLUDED.difficulty, hashes_per_second_estimate = EXCLUDED.hashes_per_second_estimate, peers = EXCLUDED.peers,
|
||||
mempool = EXCLUDED.mempool, node_version = EXCLUDED.node_version, network = EXCLUDED.network, blocks_60s = EXCLUDED.blocks_60s,
|
||||
blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now()`,
|
||||
[dag.blockCount, dag.headerCount, (await rpc.call('getSinkBlueScore', {}).catch(() => ({}))).blueScore ?? null, diff,
|
||||
hps ? hps.networkHashesPerSecond : localHashesPerSecond(), peers.length, info.mempoolSize ?? 0,
|
||||
nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString()]);
|
||||
} catch (e) { log('state write failed', e.message); }
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
try {
|
||||
await sql(`DELETE FROM live_blocks WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]);
|
||||
await sql(`DELETE FROM live_events WHERE ts < now() - interval '7 days'`);
|
||||
} catch (e) { log('prune failed', e.message); }
|
||||
}
|
||||
|
||||
async function seedFromDb() {
|
||||
try {
|
||||
const rows = await sql(`SELECT vote_key_hash, max(received_at) AS last FROM live_blocks WHERE vote_key_hash IS NOT NULL AND received_at > now() - interval '1 day' GROUP BY 1`);
|
||||
for (const r of rows) miners.set(r.vote_key_hash, { lastSeen: new Date(r.last).getTime(), quiet: Date.now() - new Date(r.last).getTime() > QUIET_AFTER_MS });
|
||||
const mins = await sql(`SELECT (floor(extract(epoch from received_at) / 60) * 60000)::bigint AS m, count(*)::int AS n FROM live_blocks WHERE received_at > now() - interval '61 minutes' GROUP BY 1`);
|
||||
for (const r of mins) minuteCounts.set(Number(r.m), r.n);
|
||||
const st = await sql(`SELECT difficulty FROM live_state WHERE id = 1`);
|
||||
if (st.length && st[0].difficulty) lastDifficultyEvent = Number(st[0].difficulty);
|
||||
} catch (e) { log('seed failed', e.message); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await setupSchema();
|
||||
await seedFromDb();
|
||||
log(`observer started, rpc ${RPC}, keeping ${RETAIN_HOURS} h of blocks, ${miners.size} miners known`);
|
||||
const rpc = new Rpc(RPC);
|
||||
rpc.onNotification = (method, params) => {
|
||||
const inner = params && (params.BlockAdded || params.VirtualChainChanged || params);
|
||||
if (method === 'blockAddedNotification' && inner && inner.block) onBlock(inner.block);
|
||||
else if (method === 'virtualChainChangedNotification' && inner) {
|
||||
for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); }
|
||||
for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); }
|
||||
}
|
||||
};
|
||||
let connected = false;
|
||||
const connect = async () => {
|
||||
while (!(await rpc.connect())) { log('rpc connect failed, retrying in 3 s'); await new Promise(r => setTimeout(r, 3000)); }
|
||||
try {
|
||||
const srv = await rpc.call('getServerInfo', {});
|
||||
nodeVersion = srv.serverVersion || null;
|
||||
const net = String(srv.networkId || 'devnet');
|
||||
network = net.startsWith('igneum') ? net : `igneum-${net}`;
|
||||
addressPrefix = network.includes('devnet') ? 'igneumdev' : network.includes('testnet') ? 'igneumtest' : network.includes('simnet') ? 'igneumsim' : 'igneum';
|
||||
await rpc.call('subscribe', { BlockAdded: {} });
|
||||
await rpc.call('subscribe', { VirtualChainChanged: { include_accepted_transaction_ids: false } });
|
||||
log(`subscribed on ${network}, node ${nodeVersion}`);
|
||||
if (connected) recordEvent('observer', 'Observer reconnected to the node'); else recordEvent('observer', `Observer started against ${network}`);
|
||||
connected = true;
|
||||
} catch (e) { log('subscribe failed', e.message); }
|
||||
};
|
||||
rpc.onClose = () => { setTimeout(connect, 2000); };
|
||||
await connect();
|
||||
|
||||
setInterval(async () => { await flushBlocks(); await flushChain(); }, FLUSH_EVERY_MS);
|
||||
setInterval(() => tick(rpc), STATE_EVERY_MS);
|
||||
setInterval(prune, PRUNE_EVERY_MS);
|
||||
tick(rpc); prune();
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', e => log('unhandled', e && e.message));
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
Loading…
Reference in a new issue