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, /^🟢/);
});