From 9a32bbaa5f23f00e9846c09fb7977f2eff7f4b07 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 2 Oct 2026 11:05:41 +0200 Subject: [PATCH] broadcaster: say "last mined block", and show the chain tip beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Last block: #970,851" was the newest block the POOL mined, but it read as the chain's height. With the network at 970,861 it looked like the pool's node had fallen ten blocks behind, or reorged. The line is now "Last mined block", and a "Chain tip" line above it gives the network's latest block as the pool's node sees it, by anyone, from /api/status's node.tip_height and tip_observed_at: Chain tip: #970,861 · 21m ago Last mined block: #970,851 · 3h 5m ago The tip line is left out while the dashboard has no node status yet. Both lines appear in the live message, the periodic update and /pool_status. Co-Authored-By: Claude Opus 5.5 (1M context) --- broadcaster/README.md | 2 +- broadcaster/lib/messages.js | 21 ++++++++++++++++----- broadcaster/test/broadcaster.test.js | 15 +++++++++++++-- broadcaster/test/commands.test.js | 3 ++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/broadcaster/README.md b/broadcaster/README.md index cc5cd16..ef15770 100644 --- a/broadcaster/README.md +++ b/broadcaster/README.md @@ -22,7 +22,7 @@ if Telegram or the service goes down, the pool is unaffected. | **Block lost** | a block it announced turns `orphaned` or `rejected` | | **Pool quiet / back** | no share accepted for `STALE_SHARES_SEC` (default 15m), and when shares resume | | **Health failing / recovered** | `/health` changes state, with the failing checks listed | -| **Pinned live message** (`BROADCASTER_LIVE=1`) | one message, pinned unless `BROADCASTER_LIVE_PIN=0`, edited every `BROADCASTER_LIVE_MS` (default 5m) with current stats and the last block | +| **Pinned live message** (`BROADCASTER_LIVE=1`) | one message, pinned unless `BROADCASTER_LIVE_PIN=0`, edited every `BROADCASTER_LIVE_MS` (default 5m) with current stats, the chain tip and the pool's last mined block | | **`/pool_status`** (`BROADCASTER_COMMANDS=1`) | when someone sends it in the chat: the current stats, as a reply in the same topic, at most once per `BROADCASTER_COMMAND_COOLDOWN_SEC` (default 30) | | **Periodic update** (`BROADCASTER_SUMMARY_HOURS=N`) | the same stats as a new message every N hours, on UTC boundaries (6 → 00, 06, 12, 18 UTC) | diff --git a/broadcaster/lib/messages.js b/broadcaster/lib/messages.js index 84c32a7..c144b3d 100644 --- a/broadcaster/lib/messages.js +++ b/broadcaster/lib/messages.js @@ -101,18 +101,28 @@ export function digest({ status, blocks, poolName, publicUrl, nowSec }) { ); } -/* The newest block that still counts: pending or confirmed. An orphaned or - * rejected one is not the pool's last block. `blocks` is newest first. */ +/* The newest block THIS POOL mined that still counts: pending or confirmed. + * An orphaned or rejected one is not the pool's last block. `blocks` is + * newest first. Labelled "mined" so it is never read as the chain's height, + * which chainTipLine gives. */ export function lastBlockLine(blocks, nowSec) { const b = (blocks ?? []).find((x) => x.status === 'pending' || x.status === 'confirmed'); - if (!b) return 'Last block: none yet'; + if (!b) return 'Last mined block: none yet'; const ago = b.ts != null ? ` · ${fmtAgo(nowSec - b.ts)} ago` : ''; - return `Last block: #${fmtN(b.height)}${ago}`; + return `Last mined block: #${fmtN(b.height)}${ago}`; +} + +/* The network's latest block, as the pool's node sees it, by anyone. Null + * when the dashboard has no node status yet. */ +export function chainTipLine(node, nowSec) { + if (node?.tip_height == null) return null; + const ago = node.tip_observed_at != null ? ` · ${fmtAgo(nowSec - node.tip_observed_at)} ago` : ''; + return `Chain tip: #${fmtN(node.tip_height)}${ago}`; } /* The pinned message, the periodic summary and the /pool_status reply show * the same numbers; only the heading and the time line differ. The last - * block line appears whenever the blocks list was read. */ + * mined block line appears whenever the blocks list was read. */ function snapshot({ status, blocks, poolName, publicUrl, nowSec, flowing, title, timeLabel }) { const p = status.pool; const lastShare = p.last_share_ts ? fmtAgo(nowSec - p.last_share_ts) + ' ago' : 'never'; @@ -121,6 +131,7 @@ function snapshot({ status, blocks, poolName, publicUrl, nowSec, flowing, title, '', ...statLines(p), `Last share: ${lastShare}`, + chainTipLine(status.node, nowSec), blocks !== undefined ? lastBlockLine(blocks, nowSec) : null, `Blocks (all time): ${fmtN(p.blocks_lifetime)}`, '', diff --git a/broadcaster/test/broadcaster.test.js b/broadcaster/test/broadcaster.test.js index 7624ee8..8099936 100644 --- a/broadcaster/test/broadcaster.test.js +++ b/broadcaster/test/broadcaster.test.js @@ -287,9 +287,20 @@ test('live message and summary show the last counted block', async () => { block('lost', 101, 'orphaned', T0 / 1000 - 600), ]; await b.tick(T0); - assert.match(telegram.posts[0], /Last block: #100<\/b> · 2h 0m ago/); + assert.match(telegram.posts[0], /Last mined block: #100<\/b> · 2h 0m ago/); dashboard.s = status({ lastShareTs: (T0 + 60 * MIN) / 1000 }); await b.tick(T0 + 60 * MIN); assert.match(telegram.posts[1], /— update/); - assert.match(telegram.posts[1], /Last block: #100<\/b>/); + assert.match(telegram.posts[1], /Last mined block: #100<\/b>/); +}); + +test('chain tip line appears with node status and is left out without it', async () => { + const { b, dashboard, telegram } = setup({ live: true, digestHour: null }); + dashboard.s.node = { tip_height: 105, tip_observed_at: T0 / 1000 - 90 }; + await b.tick(T0); + assert.match(telegram.posts[0], /Chain tip: #105 · 1m ago/); + + const other = setup({ live: true, digestHour: null }); + await other.b.tick(T0); + assert.doesNotMatch(other.telegram.posts[0], /Chain tip/); }); diff --git a/broadcaster/test/commands.test.js b/broadcaster/test/commands.test.js index dc4376b..be2c6d5 100644 --- a/broadcaster/test/commands.test.js +++ b/broadcaster/test/commands.test.js @@ -14,6 +14,7 @@ function status() { db_ready: true, hashrate: 1e12, hashrate_1h: 2e12, hashrate_5m: 3e12, workers_active: 4, accepted: 1000, rejected: 5, reject_rate_pct: 0.5, best_share_24h: 123456, blocks_lifetime: 7, last_share_ts: T0 / 1000 - 5, }, + node: { tip_height: 970861, tip_observed_at: T0 / 1000 - 1200 }, }; } @@ -69,7 +70,7 @@ test('/pool_status is answered in its topic, as a reply, with the last block', a assert.match(html, /avonpool_beta — status/); assert.match(html, /Hashrate now: 3\.00 TH\/s<\/b> \(5m\)/); assert.match(html, /1,000 accepted · 5 rejected \(0\.50%\)/); - assert.match(html, /Last block: #970,802<\/b> · 1h 0m ago/); + assert.match(html, /Chain tip: #970,861 · 20m ago\nLast mined block: #970,802<\/b> · 1h 0m ago/); assert.match(html, /^🟢/); });