blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,2545 @@
1
+ // Per-node monitor: polls the node politely, follows its log, and turns both
2
+ // into time series plus a live event feed.
3
+ //
4
+ // Three rules govern everything in this file.
5
+ //
6
+ // 1. Never fabricate. A number we did not get from the node is absent, not zero.
7
+ // This is the project's own documented ethos ("omitted rather than reported
8
+ // low", "a short count that looks real is worse than an absent one"), and a
9
+ // monitoring tool is exactly where a plausible fake does the most damage.
10
+ // 2. Never wedge the node. Its RPC server is single-connection/single-thread, so
11
+ // tiers are staggered, everything goes through the serialized lane, and a
12
+ // slow tier defers rather than overlapping.
13
+ // 3. Say which source a number came from. Two sources disagree here -- the log
14
+ // carries bandwidth and per-peer relay counts that RPC reports as zero or
15
+ // empty in this deployment -- so `health.quality` records every such gap
16
+ // rather than silently preferring one.
17
+ import { EventEmitter } from 'node:events';
18
+ import { RpcClient, RpcError } from '../rpc/client.js';
19
+ import { LogTail } from './logtail.js';
20
+ import { CounterRate } from '../store/ring.js';
21
+ import { computeSync, stripFacts } from './sync.js';
22
+ import { SHAPES, RULE_TO_SHAPE } from './logparse.js';
23
+ import { decodeCoinbase, minerRow, ledgerApply, ledgerRows, aliasFor, matchPool } from './mining.js';
24
+ import { NetworkStats } from './network.js';
25
+ import { summarizeTemplate, packagesFromTemplate, blockEconomy, templateCells } from './nextblock.js';
26
+ import { templateFromMempool, LOCAL_TEMPLATE_NOTE } from './gbt.js';
27
+ import fs from 'node:fs';
28
+
29
+ // The statistics getblockstats actually has. 'size', 'weight' and 'strippedsize' are
30
+ // getblock fields and have never been getblockstats statistics — asking for them by name
31
+ // is answered by omitting them, which is why the Chain page's Block size chart sat empty
32
+ // forever while every other figure on the same row filled in. The real names are
33
+ // total_size / total_weight (measured 2026-09-09, height 966253: asking for the three
34
+ // fictions returned 31 keys and none of them was size/weight/strippedsize; asking for
35
+ // total_size returned 1,579,815 bytes and total_weight 3,991,545 — a block just under the
36
+ // 4M weight cap, i.e. plausible). A field list is a claim about an endpoint, so it is a
37
+ // claim that can rot; the row that consumes it states its basis for that reason.
38
+ export const BLOCKSTATS_FIELDS = ['totalfee', 'txs', 'total_size', 'total_weight', 'avgfeerate', 'mediantxsize', 'avgtxsize',
39
+ 'swtotal_size', 'swtxs', 'subsidy', 'utxo_increase', 'ins', 'outs', 'avgfee', 'medianfee', 'maxfee',
40
+ 'feerate_percentiles', 'height', 'blockhash', 'time', 'mediantime'];
41
+
42
+ export class NodeMonitor extends EventEmitter {
43
+ constructor(nodeCfg, { rpc, poll, store, log, history, logCfg, miningCfg }) {
44
+ super();
45
+ this.cfg = nodeCfg;
46
+ this.poll = poll;
47
+ // Node-scoped view of the shared store: series rings are shared across nodes,
48
+ // so writes are stamped with this node's id and reads are filtered by it.
49
+ // Without this a two-node deployment draws the average of two daemons (see
50
+ // History.forNode for the measurement).
51
+ this.history = history?.__perNode ? history : (history?.forNode ? history.forNode(nodeCfg.id) : history);
52
+ this.log = log.child({ node: nodeCfg.id });
53
+ // A node entry may carry its own `rpc` block to override lane TIMING (spacing, rate ceiling,
54
+ // timeouts). It cannot buy concurrency: Lane runs one call at a time by construction and does
55
+ // not read maxInFlight -- measured 2026-09-13 at 1, 4 and 8, peak concurrency was 1 every
56
+ // time. An earlier version of this comment claimed the override fixed a starving node; it did
57
+ // not, because nothing read it.
58
+ this.rpc = new RpcClient(nodeCfg, { ...rpc, ...(nodeCfg.rpc ?? {}) }, { log: this.log });
59
+ // The log *config* has to be passed in separately. It used to be read as
60
+ // `log.tailBytes` off the logger function, which has no such property, so the
61
+ // configured value was silently ignored and LogTail's own 2 MB default applied
62
+ // -- the same number, so nothing looked wrong. A `staleMs` read that way would
63
+ // have been silently undefined too. Found 2026-09-08.
64
+ this.logCfg = logCfg || {};
65
+ this.tail = nodeCfg.logFile ? new LogTail(nodeCfg.logFile, { pollMs: 1000, tailBytes: this.logCfg.tailBytes, log: this.log }) : null;
66
+ // RPC-only mode (`log.enabled: false` / BLOCKYARD_LOG_SOURCE=0): no tail at all.
67
+ // Measured 2026-09-08 on the build then running the bench node: getnettotals'
68
+ // delta-rate was 11.56 MB/s against the node's own stated 11.2 MB/s over 90 s
69
+ // (3% apart), and getpeerinfo answered 21 rows naming up to 201,608,074 bytes
70
+ // per peer with a download-worker marker. On the deployed production
71
+ // build the SAME two calls answer 0 bytes and [] rows with getconnectioncount
72
+ // at 16 -- and both report the same non-Core subversion string. So RPC cannot
73
+ // tell you whether RPC is complete; only the log's build banner can. That is
74
+ // why turning the log off is a loud, per-node statement rather than a silence.
75
+ this.logEnabled = Boolean(nodeCfg.logFile);
76
+ // Who mined what: two cheap reads per block on the serialized lane, measured on
77
+ // 2026-09-09 at getblock(hash,1) = 259,891 B in 8 ms and getrawtransaction(coinbase,2)
78
+ // = 2,915 B in 63 ms. One block per tick, newest first, and nothing at all while the
79
+ // node is in initial download -- a catch-up of tens of thousands of heights must not
80
+ // spend the shared lane on attributions nobody is looking at (rule 1).
81
+ this.miningCfg = { backfill: 36, perTick: 1, enabled: true, aliasesFile: null, poolMapFile: null, ...(miningCfg ?? {}) };
82
+ this.mining = {
83
+ rows: new Map(), // height -> minerRow
84
+ pools: new Map(), // poolKey -> ledger entry
85
+ aliases: null, // loaded from aliasesFile, if a human wrote one
86
+ // Curated coinbase-tag -> pool-name map, written out by scripts/pool-map.js from
87
+ // mempool.space/mining-pools. Labels arrive with their provenance (source, content
88
+ // sha, fetchedAt) and are shown as theirs, never as ours; no match keeps the raw
89
+ // tag and an unknown fingerprint.
90
+ poolMap: null,
91
+ byPool: new Map(), // grouping by curated label where one matched, else raw key
92
+ fetched: 0, skippedIbd: 0, lastError: null, at: null,
93
+ retryAt: 0, failures: 0, // backoff after a failed attribution; cleared by the next success
94
+ };
95
+ this.miningQueue = [];
96
+ this.miningBusy = false;
97
+ // THE NETWORK OVER A WEEK AND A YEAR (2026-09-15; network.js): rewards, the difficulty
98
+ // period, hashrate samples, a week of pool shares -- refreshed from the mid tier
99
+ // (a thin handle on this.rpc -- the constructor's `rpc` argument is the lane TIMING block, not the client)
100
+ this.network = new NetworkStats({ rpc: { batch: (calls, opts) => this.rpc.batch(calls, opts) }, log: this.log, poolMap: () => this.mining.poolMap, aliases: () => this.mining.aliases });
101
+ // THE BLOCK BEING BUILT, assembled here from the mempool (2026-09-13; operator, on how
102
+ // mempool.space manages this against a base Core install: "do it"). It used to be one
103
+ // getblocktemplate call costing this node 1.3-1.5 s of its single RPC thread and 1.79 MB,
104
+ // fetched on demand so a page nobody was reading did not pay it every minute. It now costs
105
+ // the node NOTHING: the pool tier already reads getrawmempool(true) for the mempool view,
106
+ // and Core publishes depends, the ancestor sizes and fees.chunk/chunkweight in it, which is
107
+ // everything the selection needs. See gbt.js for the measured comparison against the node's
108
+ // own template (0.03% apart on fees).
109
+ this.nextBlock = null;
110
+ this.nextBlockAt = 0;
111
+ // The verbose mempool the pool tier last read, and when. The block being built is assembled
112
+ // from it (gbt.js) rather than asked for, so the template is exactly as fresh as this is.
113
+ this.mempoolRaw = null;
114
+ this.mempoolRawAt = 0;
115
+ this.nextBlockCfg = { freshMs: 15_000, enabled: this.miningCfg.template !== false };
116
+ this.logHealthMs = this.logCfg.healthMs ?? 30_000;
117
+ // Why 30 minutes and not 5: measured 2026-09-08, the synced production node's
118
+ // own log went 1,182 s (~20 min) between lines at its quietest across 1,840
119
+ // lines. A 15-minute gate cries wolf on a healthy idle node. A node doing IBD
120
+ // writes every ~10 s and can ask for a tighter gate with `logStaleMs`.
121
+ this.staleAfterMs = nodeCfg.logStaleMs ?? this.logCfg.staleMs ?? 1_800_000;
122
+ this.logLines = 0;
123
+ this.logParsed = 0;
124
+ this.logLastSize = null;
125
+ this.logGrowthAt = Date.now();
126
+ this.logGrowthTip = null;
127
+ this.logAdvancedWhileQuiet = null;
128
+ this.logHealthStats = { lastGrowthAt: this.logGrowthAt, checkedAt: null, lines: 0, parsed: 0, ratio: null, quietMs: null };
129
+ this.logHealthTimer = null;
130
+ // Per-shape liveness (see SHAPES in logparse.js). A shape is armed the first
131
+ // time one of its rules matches, which is how a build that never emits a given
132
+ // line is excused without keeping a table of builds.
133
+ this.shapeSeen = new Map();
134
+ this.shapeGates = this.logCfg.shapeGatesMs || {};
135
+ this.lastLineAt = null;
136
+ this.addrGossip = null;
137
+ this.dialFails = null;
138
+ // Restart census (see noteRestartStorm) and the log's unclaimed tags (see
139
+ // tagCensus). Both are "shape of the problem" figures: the individual events
140
+ // are already in the feed, what was missing was the sentence that sums them up.
141
+ this.restarts = [];
142
+ this.unseenTags = new Map(); // "[tag]" -> {lines, firstAt, lastAt, sample}
143
+ this.unseenLines = 0;
144
+ this.storeCfg = store || {};
145
+ this.blockMapCap = this.storeCfg.blockMapCap ?? 12000;
146
+ this.blockMapEvicted = 0;
147
+
148
+ this.state = {
149
+ id: nodeCfg.id,
150
+ label: nodeCfg.label || nodeCfg.id,
151
+ color: nodeCfg.color || '#f7931a',
152
+ startedAt: Date.now(),
153
+ chain: null,
154
+ chainInfo: null,
155
+ networkInfo: null,
156
+ mining: null,
157
+ mempool: { loaded: false, count: null, bytes: null, usage: null, maxmempool: null, totalFee: null, mempoolminfee: null, minrelaytxfee: null, unbroadcast: null },
158
+ mempoolDist: null,
159
+ peers: { connections: null, in: null, out: null, list: [], listSource: null, listUpdatedAt: null },
160
+ net: { totalRecv: null, totalSent: null, inBps: null, outBps: null, uploadtarget: null },
161
+ fees: {},
162
+ utxo: {},
163
+ indexes: null,
164
+ tips: [],
165
+ deployments: null,
166
+ rpcInfo: null,
167
+ blocks: new Map(), // height -> stats
168
+ logState: {},
169
+ lastError: null,
170
+ lastGoodAt: null,
171
+ tierRunAt: {},
172
+ };
173
+
174
+ this.rateIn = new CounterRate(180000);
175
+ this.rateOut = new CounterRate(180000);
176
+ this.txCounters = new CounterRate(120000);
177
+ // Height is itself a counter, so the same helper gives blocks/s for the sync
178
+ // ETA. A 10-minute window: shorter ones swing wildly on a 2-5 block/s catch-up.
179
+ this.blockRate = new CounterRate(600000);
180
+ // ...and a 2-minute window beside it, so a decelerating sync is visible as a
181
+ // trend rather than hidden inside a smooth average.
182
+ this.blockRateFast = new CounterRate(120000);
183
+ this.reorgAt = null;
184
+ this.perPeerRelay = new Map(); // host -> {accepted, blocks, lastSeen}
185
+ this.perPeerBlocks = new Map();
186
+ this.peerEvents = [];
187
+ this.lastTip = null;
188
+ this.prevChainSize = null;
189
+ this.reorgEvents = 0;
190
+ this.quality = [];
191
+ this.tierTimers = new Map();
192
+ this.tierIntervalMs = {};
193
+ this.lastTierMs = {};
194
+ this.inflightTiers = new Set();
195
+ this.stopped = false;
196
+ this.logBackfilled = false;
197
+ // Stated in the constructor, not in start(): whether the log is a source at all
198
+ // is a configuration fact, and a panel must not have to wait for a poll to find
199
+ // out it will never be filled.
200
+ if (!this.logEnabled) {
201
+ this.flagQuality('log-source-disabled', 'log tailing is off for this node, so these figures have no source and are shown as –: which peer served a block, per-peer relay legs, the mempool accept/reject breakdown, disk-write rate, the download worker\'s banned-peer count, the node\'s own ETA and stored/total, UTXO compaction and validation stalls, archive-layout holes, and sync_failing. Bandwidth and per-peer bytes work on builds that publish them (measured: 11.56 MB/s via getnettotals against 11.2 MB/s stated in the same node\'s log) and not on a build whose counters never move (measured 2026-09-08: 0 bytes with 16 connections, [] rows). Do not cache that answer either way: the same process that read 0/0 at 09:36 on 2026-09-09 had advanced to 23,955,131 bytes received by 17:36 with no restart, while getpeerinfo stayed [] -- so on this build bandwidth survives the switch and per-peer identity does not (MEASUREMENTS 23)', 'info');
202
+ }
203
+ }
204
+
205
+ get id() { return this.cfg.id; }
206
+ get label() { return this.state.label; }
207
+
208
+ async start() {
209
+ this.loadMiningAliases();
210
+ this.loadPoolMap();
211
+ if (this.tail) {
212
+ this.tail.on('events', (evs) => this.onLogEvents(evs));
213
+ this.tail.on('backfilled', () => { this.logBackfilled = true; this.emit('changed', 'log'); });
214
+ await this.tail.start().catch((err) => this.log({ level: 'error', msg: `log tail failed: ${err.message}` }));
215
+ this.logHealthTimer = setInterval(() => {
216
+ this.checkLogHealth().catch((err) => this.log({ level: 'warn', msg: `log health check failed: ${err.message}` }));
217
+ }, this.logHealthMs);
218
+ this.logHealthTimer.unref?.();
219
+ }
220
+ // Stagger the first runs so boot is not a thundering herd on a
221
+ // single-threaded server: fast, then mid, then slow, each offset.
222
+ this.runTier('fast').finally(() => this.scheduleTier('fast'));
223
+ setTimeout(() => this.runTier('mid').finally(() => this.scheduleTier('mid')), 700);
224
+ setTimeout(() => this.runTier('pool').finally(() => this.scheduleTier('pool')), 1200);
225
+ setTimeout(() => this.runTier('slow').finally(() => this.scheduleTier('slow')), 1600);
226
+ setTimeout(() => this.runTier('rare').finally(() => this.scheduleTier('rare')), 2600);
227
+ return this;
228
+ }
229
+
230
+ scheduleTier(name) {
231
+ if (this.stopped) return;
232
+ const ms = this.effectiveTierMs(name);
233
+ if (!ms) return;
234
+ this.tierIntervalMs[name] = ms;
235
+ const t = setTimeout(() => {
236
+ this.runTier(name).finally(() => this.scheduleTier(name));
237
+ }, ms);
238
+ t.unref?.();
239
+ this.tierTimers.set(name, t);
240
+ }
241
+
242
+ // Adaptive cadence, and the reason for it is a measurement, not a hunch: the
243
+ // bench node took 40.4s to answer a bare getblockcount while doing initial
244
+ // block download. Polling it on a 4s tier is not monitoring, it is a queue
245
+ // that grows forever (and, before the lane coalesced, load on a node that was
246
+ // already the bottleneck). So each tier stretches to at least twice the
247
+ // observed RPC latency, capped so it recovers automatically once the node
248
+ // speeds up -- which it does, the moment the sync finishes.
249
+ effectiveTierMs(name) {
250
+ // (a config written before the pool tier existed has no poolMs: the pool then
251
+ // keeps the slow tier's cadence, which is where it used to live)
252
+ const base = this.poll[`${name}Ms`] ?? (name === 'pool' ? this.poll.slowMs : undefined);
253
+ if (!base) return null;
254
+ const avg = this.rpc.lane.stats.avgLatencyMs ?? 0;
255
+ const lastMs = this.lastTierMs[name] ?? 0;
256
+ const stretched = Math.max(avg * 2, lastMs * 1.5);
257
+ // The cap matters: the lane already protects the node (one request in
258
+ // flight, superseded-or-dropped polls), so cadence stretching is about not
259
+ // asking questions nobody can answer twice -- not about node protection.
260
+ // Left uncapped (base x 20) the fast tier ran once a minute and the sync
261
+ // bar never accumulated the two height samples its rate needs.
262
+ // The cap must never fall BELOW the configured base, or "stretching" would
263
+ // poll MORE often than configured. Measured on the live node: the rare tier
264
+ // (15 min, and the most expensive of them) was being clamped to 3 minutes --
265
+ // five times the intended load on getpeerinfo/getdeploymentinfo/getrpcinfo.
266
+ const stretchCap = Math.min(Math.max(base * 8, 30_000), 180_000);
267
+ const cap = Math.max(base, stretchCap);
268
+ return Math.min(cap, Math.max(base, Math.round(stretched / 500) * 500));
269
+ }
270
+
271
+ async runTier(name) {
272
+ if (this.inflightTiers.has(name)) return { skipped: 'already running' };
273
+ // On a node whose RPC is genuinely slow (this one measured 69 s for a heavy
274
+ // batch), a slow-tier call occupies the single lane for more than a minute
275
+ // and starves the cheap poll that feeds the sync bar. Skip the expensive
276
+ // tiers while that is true, count the skips, and say so -- silently dropping
277
+ // them would look like a monitor bug.
278
+ const avg = this.rpc.lane.stats.avgLatencyMs ?? 0;
279
+ const heavyTiers = ['pool', 'slow', 'rare'];
280
+ const skipAbove = (this.rpc.cfg.slowLatencyMs ?? 5000) * 4;
281
+ // Never skip forever: every 5th attempt goes through regardless, so a node
282
+ // that is *permanently* busy still refreshes its mempool distribution and
283
+ // index state instead of freezing that panel at whatever was last seen.
284
+ this.heavyAttempts = this.heavyAttempts ?? {};
285
+ const attempts = (this.heavyAttempts[name] = (this.heavyAttempts[name] ?? 0) + 1);
286
+ if (heavyTiers.includes(name) && avg > skipAbove && attempts % 5 !== 0) {
287
+ this.skippedHeavy = (this.skippedHeavy ?? 0) + 1;
288
+ this.flagQuality('heavy-tiers-skipped', `the node's RPC is answering in ~${(avg / 1000).toFixed(0)}s, so the slow and rare tiers are being skipped to keep the sync bar and live counters fresh; mempool distribution, indexes and UTXO stats refresh less often as a result`, 'warn');
289
+ return { skipped: 'rpc too slow for heavy tiers' };
290
+ }
291
+ if (this.skippedHeavy && heavyTiers.includes(name) && avg <= skipAbove) this.clearQuality('heavy-tiers-skipped');
292
+ this.inflightTiers.add(name);
293
+ const t0 = performance.now();
294
+ try {
295
+ const out = await this[`tier_${name}`]();
296
+ this.state.tierRunAt[name] = Date.now();
297
+ this.state.lastError = null;
298
+ return out;
299
+ } catch (err) {
300
+ if (err.kind === 'stale') {
301
+ // Dropped by our own lane because a fresher poll superseded it or the
302
+ // answer could not arrive in time to be true. Not a node fault, so it is
303
+ // counted and not logged as an error.
304
+ this.staleDrops = (this.staleDrops ?? 0) + 1;
305
+ return { dropped: 'stale' };
306
+ }
307
+ this.state.lastError = { at: Date.now(), tier: name, message: err.message, kind: err.kind ?? 'internal' };
308
+ // A benchmark node that has been switched off is expected; logging its
309
+ // failure at ERROR every few seconds buries the real errors.
310
+ const level = this.cfg.optional ? 'warn' : (err.kind === 'timeout' ? 'warn' : 'error');
311
+ if (err.kind !== 'breaker') this.log({ level, msg: `${name} tier failed: ${err.message}` });
312
+ return { error: err.message };
313
+ } finally {
314
+ const dur = Math.round(performance.now() - t0);
315
+ this.lastTierMs[name] = dur;
316
+ this.tierStats = { name, ms: dur, at: Date.now() };
317
+ this.inflightTiers.delete(name);
318
+ this.recordRpc();
319
+ this.emit('changed', name);
320
+ }
321
+ }
322
+
323
+ async callList(calls, opts) {
324
+ const results = await this.rpc.batch(calls, opts);
325
+ const byMethod = new Map();
326
+ for (const r of results) {
327
+ if (r.ok) byMethod.set(r.method, r.result);
328
+ else byMethod.set(r.method, new RpcError(r.error?.message ?? `${r.method} failed`, { code: r.error?.code, kind: 'rpc' }));
329
+ }
330
+ return byMethod;
331
+ }
332
+
333
+ // ---------------------------------------------------------------- tiers
334
+
335
+ async tier_fast() {
336
+ const m = await this.callList([
337
+ { method: 'getblockchaininfo' },
338
+ { method: 'getmempoolinfo' },
339
+ { method: 'getconnectioncount' },
340
+ { method: 'getnettotals' },
341
+ { method: 'uptime' },
342
+ ], { key: `${this.id}:fast`, priority: 0 });
343
+ // A method can fail while the connection succeeds -- the node answers -28
344
+ // "Loading block index..." from getblockchaininfo while getmempoolinfo works
345
+ // fine. Swallowing that into a null is how the sync bar ends up showing a
346
+ // bare "unknown": true, but with no reason, which reads as a broken monitor.
347
+ const errors = {};
348
+ const ok = (k) => {
349
+ const v = m.get(k);
350
+ if (v instanceof RpcError) { errors[k] = `code ${v.code ?? '?'}: ${v.message}`; return null; }
351
+ if (v === undefined) { errors[k] = 'no reply for this method in the batch'; return null; }
352
+ return v;
353
+ };
354
+ const bc = ok('getblockchaininfo');
355
+ this.state.methodErrors = errors;
356
+ if (errors.getblockchaininfo) {
357
+ this.flagQuality('chaininfo-unavailable', `getblockchaininfo is refusing: ${errors.getblockchaininfo}. Height, headers and percentage stay unknown until the node answers -- shown as unknown rather than guessed from the log`, 'warn');
358
+ } else {
359
+ this.clearQuality('chaininfo-unavailable');
360
+ // A node answering getblockchaininfo is, by definition, not mid-restart. This
361
+ // closes node-restarting without waiting for a log line that may never come.
362
+ this.clearQuality('node-restarting');
363
+ }
364
+ const mi = ok('getmempoolinfo');
365
+ const cc = ok('getconnectioncount');
366
+ const nt = ok('getnettotals');
367
+ const up = ok('uptime');
368
+
369
+ if (bc) {
370
+ const prev = this.state.chainInfo;
371
+ this.state.chain = bc.chain;
372
+ this.state.chainInfo = bc;
373
+ this.state.lastGoodAt = Date.now();
374
+ if (bc.blocks != null) { this.blockRate.add(bc.blocks); this.blockRateFast.add(bc.blocks); }
375
+ if (prev && bc.blocks != null) {
376
+ if (this.lastTip == null) this.lastTip = bc.blocks;
377
+ if (bc.blocks > this.lastTip) await this.onNewTip(this.lastTip + 1, bc.blocks);
378
+ else if (bc.blocks < this.lastTip) {
379
+ // The active chain moved backwards: a reorg. Count it and re-point.
380
+ this.reorgEvents += 1;
381
+ this.reorgAt = Date.now();
382
+ this.blockRate.add(bc.blocks); // CounterRate treats the drop as a fresh baseline
383
+ this.addEvent({ kind: 'reorg', severity: 'warn', tag: 'chain', ts: Date.now(), text: `active chain reorged: tip ${this.lastTip} -> ${bc.blocks}` });
384
+ this.lastTip = bc.blocks;
385
+ }
386
+ } else if (bc.blocks != null) {
387
+ this.lastTip = bc.blocks;
388
+ if (this.state.blocks.size === 0) this.backfillBlocks(this.poll.blockBackfill).catch(() => {});
389
+ }
390
+ this.state.chainInfo = bc;
391
+ }
392
+
393
+ if (mi) {
394
+ this.state.mempool = { ...this.state.mempool, ...mi };
395
+ this.state.lastGoodAt = Date.now();
396
+ }
397
+ if (cc != null) {
398
+ this.state.peers.connections = cc;
399
+ if (this.state.networkInfo) {
400
+ this.state.peers.in = this.state.networkInfo.connections_in;
401
+ this.state.peers.out = this.state.networkInfo.connections_out;
402
+ }
403
+ }
404
+ if (nt) {
405
+ const prevRecv = this.state.net.totalRecv;
406
+ this.state.net.totalRecv = nt.totalbytesrecv;
407
+ this.state.net.totalSent = nt.totalbytessent;
408
+ this.state.net.uploadtarget = nt.uploadtarget ?? null;
409
+ this.state.net.timemillis = nt.timemillis;
410
+ // Whether these counters mean anything is build-dependent, and the two
411
+ // shapes need different handling. Measured 2026-09-08: the build deployed to
412
+ // production answered 0/0 for its first eight hours (the byte counters live
413
+ // in the download worker), then started counting mid-uptime with no restart:
414
+ // 23,955,131 bytes received by 17:36 the same day (MEASUREMENTS 23). So the
415
+ // question is 'what does it read now', never 'which build is this' -- and
416
+ // never 'what did it read this morning'. The 03:02 bench build answered real
417
+ // totals from the start -- 11.56 MB/s
418
+ // from this delta against 11.2 MB/s stated in that node's own log, 3% apart.
419
+ // When both read zero the rate is NOT recorded: a 0 B/s on a node that moved
420
+ // 11 MB/s is a fabrication, so the figure stays absent and the flag says why.
421
+ if (nt.totalbytesrecv === 0 && nt.totalbytessent === 0 && prevRecv === 0) {
422
+ this.state.net.inBps = null;
423
+ this.state.net.outBps = null;
424
+ this.flagQuality('nettotals-zero', `getnettotals reports 0 bytes sent and received, so this build does not count the download worker's traffic; bandwidth${this.logEnabled ? ' comes from the node log instead' : ' has no source at all in RPC-only mode and is shown as –'}`, 'warn');
425
+ } else {
426
+ this.clearQuality('nettotals-zero');
427
+ // One sample per poll. add() used to be called twice per value here, which
428
+ // pushed a duplicate {t, value} pair: harmless to the rate (same t, same
429
+ // value) but it doubled the sample buffer and made the first read depend on
430
+ // which of the two calls happened to return the number.
431
+ this.state.net.inBps = this.rateIn.add(nt.totalbytesrecv);
432
+ this.rateOut.add(nt.totalbytessent);
433
+ // The send half needs its own sanity check, and the arithmetic supplies it.
434
+ // Measured on the bench build in RPC-only mode: 12,896,531,244 bytes in and
435
+ // 1,129 bytes out over the same process lifetime, with 21 peers connected.
436
+ // No node receives 11 million times what it sends -- a node pulling 12.9 GB
437
+ // of blocks necessarily sent getdata for them -- so this build's sent counter
438
+ // does not cover the download worker either. Publishing `0 B/s up` from it
439
+ // would be the same sin as publishing a made-up number, so the rate is
440
+ // withheld and the reason is named.
441
+ const recvTotal = nt.totalbytesrecv ?? 0;
442
+ const sentTotal = nt.totalbytessent ?? 0;
443
+ const conns = this.state.peers.connections ?? 0;
444
+ const sentBlind = recvTotal > 100e6 && sentTotal < 1e6 && conns > 0;
445
+ this.state.net.outBps = sentBlind ? null : this.rateOut.rate();
446
+ if (sentBlind) {
447
+ this.flagQuality('upload-unmeasurable', `this build reports ${(recvTotal / 1e9).toFixed(1)} GB received against ${(sentTotal / 1e6).toFixed(3)} MB sent with ${conns} peer(s) connected -- an impossible ratio, so its sent counter misses the download worker and no upload rate can be derived; the figure is absent, not zero`, 'warn');
448
+ } else {
449
+ this.clearQuality('upload-unmeasurable');
450
+ }
451
+ }
452
+ }
453
+ if (up != null) this.state.uptimeSec = up;
454
+
455
+ this.history.record('node', {
456
+ blocks: bc?.blocks ?? null,
457
+ headers: bc?.headers ?? null,
458
+ progress: bc?.verificationprogress ?? null,
459
+ difficulty: bc?.difficulty ?? null,
460
+ sizeOnDisk: bc?.size_on_disk ?? null,
461
+ ibd: bc?.initialblockdownload ?? null,
462
+ connections: cc ?? null,
463
+ peersIn: this.state.networkInfo?.connections_in ?? null,
464
+ peersOut: this.state.networkInfo?.connections_out ?? null,
465
+ uptimeMs: up != null ? up * 1000 : null,
466
+ mempoolSize: mi?.size ?? null,
467
+ });
468
+ this.history.record('mempool', {
469
+ count: mi?.size ?? null,
470
+ bytes: mi?.bytes ?? null,
471
+ usage: mi?.usage ?? null,
472
+ maxUsage: mi?.maxmempool ?? null,
473
+ totalFee: mi?.total_fee ?? null,
474
+ minFee: mi?.mempoolminfee ?? null,
475
+ minRelayFee: mi?.minrelaytxfee ?? null,
476
+ unbroadcast: mi?.unbroadcastcount ?? null,
477
+ });
478
+ this.history.record('peers', {
479
+ connections: cc ?? null,
480
+ in: this.state.networkInfo?.connections_in ?? null,
481
+ out: this.state.networkInfo?.connections_out ?? null,
482
+ txRelayPeers: this.perPeerRelay.size,
483
+ servedBlocks: this.perPeerBlocks.size,
484
+ });
485
+ this.history.record('net', {
486
+ inBps: this.state.net.inBps, outBps: this.state.net.outBps,
487
+ inTotal: this.state.net.totalRecv, outTotal: this.state.net.totalSent,
488
+ diskWriteBps: this.state.logState.diskWriteBps ?? null,
489
+ diskTotal: this.state.logState.diskTotal ?? null,
490
+ avgRecvBps: this.state.logState.avgRecv ?? null,
491
+ avgWriteBps: this.state.logState.avgWrite ?? null,
492
+ floorBps: this.state.logState.floorBps ?? null,
493
+ poolMedianBps: this.state.logState.poolMedianBps ?? null,
494
+ });
495
+ return { ok: true };
496
+ }
497
+
498
+ async tier_mid() {
499
+ // Five estimatesmartfee targets in ONE batch. callList() keys by method name,
500
+ // which would collapse the five into one, so this tier reads the batch
501
+ // positionally instead.
502
+ const targets = [1, 2, 6, 24, 144];
503
+ const calls = [
504
+ { method: 'getnetworkinfo' },
505
+ { method: 'getmininginfo' },
506
+ { method: 'getchaintips' },
507
+ ...targets.map((t) => ({ method: 'estimatesmartfee', params: [t] })),
508
+ // RPC-only mode: the peer table becomes the only peer source there, so it
509
+ // moves up to the 15 s tier. Appended last, so the positional unwrapping
510
+ // above is unaffected.
511
+ ...(this.logEnabled ? [] : [{ method: 'getpeerinfo' }]),
512
+ ];
513
+ const res = await this.rpc.batch(calls, { key: `${this.id}:mid`, priority: 2 });
514
+ const unwrap = (r) => (r && r.ok ? r.result : null);
515
+ const ni = unwrap(res[0]);
516
+ if (ni) {
517
+ this.state.networkInfo = ni;
518
+ this.state.peers.in = ni.connections_in;
519
+ this.state.peers.out = ni.connections_out;
520
+ this.state.peers.connections = ni.connections;
521
+ }
522
+ this.state.mining = unwrap(res[1]);
523
+ // ...not in the first half minute: the boot's own backfills have the lane, and the network
524
+ // row's first gathering (144 block stats, 400 headers) can wait for the live polls to settle
525
+ if (this.miningCfg.enabled && this.state.chainInfo && Date.now() - this.state.startedAt > 30_000) this.network.refresh(this.state.chainInfo).catch(() => {});
526
+ const tips = unwrap(res[2]);
527
+ if (Array.isArray(tips)) {
528
+ this.state.tips = tips;
529
+ const side = tips.filter((t) => t.status !== 'active');
530
+ if (side.length) this.flagQuality('side-tips', `${side.length} non-active chain tip(s) known to the node (deepest branch ${Math.max(...side.map((t) => t.branchlen || 0))} blocks)`, 'info');
531
+ else this.clearQuality('side-tips');
532
+ }
533
+
534
+ const fees = {};
535
+ let haveAny = false;
536
+ for (let i = 0; i < targets.length; i++) {
537
+ const key = `f${targets[i]}`;
538
+ const v = res[3 + i];
539
+ const ok = v && v.ok && v.result && typeof v.result === 'object' && v.result.feerate != null;
540
+ fees[key] = ok ? v.result.feerate : null;
541
+ if (ok) haveAny = true;
542
+ // Core omits feerate and returns `errors` when the estimator has no data.
543
+ // Recording which targets are cold is the honest answer, not a zero.
544
+ if (v && !v.ok) fees[`${key}_error`] = v.error?.message ?? 'failed';
545
+ else if (v && v.ok && v.result?.errors) fees[`${key}_error`] = (v.result.errors || []).map((e) => e.reason ?? JSON.stringify(e)).join(',');
546
+ }
547
+ this.state.fees = fees;
548
+ if (!haveAny) this.flagQuality('fee-estimator-cold', 'estimatesmartfee returned no feerate for any target; a cold estimator answers "unset" and Core does the same, so nothing is shown rather than a made-up rate', 'info');
549
+ else this.clearQuality('fee-estimator-cold');
550
+
551
+ this.history.record('fees', {
552
+ f1: fees.f1, f2: fees.f2, f6: fees.f6, f24: fees.f24, f144: fees.f144,
553
+ mempoolmin: this.state.mempool.mempoolminfee ?? null,
554
+ priority: this.state.mempool.mempoolminfee ?? null,
555
+ estimatorOk: haveAny ? 1 : 0,
556
+ });
557
+ if (!this.logEnabled) this.absorbPeerInfo(unwrap(res[3 + targets.length]));
558
+ return { ok: true, feesOk: haveAny };
559
+ }
560
+
561
+ // THE POOL TIER (2026-09-11, operator: "Faster refresh"). The verbose mempool
562
+ // map (~0.7 MB at 4.7k tx, ~2.6 MB at the 19k this box has carried; 0.144 s to
563
+ // answer) used to ride the 60 s slow tier with the UTXO-set summary, so the
564
+ // block-space viewer could never be fresher than a minute. On its own tier it
565
+ // refreshes every poll.poolMs (20 s); the lane still serialises it, and it is
566
+ // skipped with the other heavy tiers when the node's RPC is struggling.
567
+ async tier_pool() {
568
+ const t0 = performance.now();
569
+ let raw = null;
570
+ try {
571
+ raw = await this.rpc.call('getrawmempool', [true], { heavy: true, key: `${this.id}:pool-verbose`, priority: 6 });
572
+ // A STREAK OF STALE DROPS IS ONE STORY, NOT A HUNDRED (2026-09-15: on a day the node answered
573
+ // slowly for thirteen hours, this poll -- lowest priority, so last to the lane -- was dropped
574
+ // as stale every four minutes and each drop was its own warn event: 188 of the feed's 200
575
+ // rows, everything else pushed out). A drop now opens a quality flag that counts, one event
576
+ // marks the streak's start, and one marks its end with the count and the span.
577
+ if (this.poolDrops?.n) {
578
+ const d = this.poolDrops;
579
+ this.addEvent({ kind: 'collector_recovered', severity: 'info', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose answers again: dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} over ${Math.round((Date.now() - d.since) / 60000)} min (longest wait ${Math.round(d.maxWait / 1000)}s) -- the node's RPC was too slow for the lowest-priority poll to get a turn` });
580
+ this.clearQuality('pool-poll-dropped');
581
+ this.poolDrops = null;
582
+ }
583
+ } catch (err) {
584
+ if (err?.kind === 'stale' && /dropped: waited/.test(err.message)) {
585
+ const waited = Number((/waited (\d+)ms/.exec(err.message) ?? [])[1] ?? 0);
586
+ const d = (this.poolDrops ??= { n: 0, since: Date.now(), maxWait: 0 });
587
+ d.n += 1; d.maxWait = Math.max(d.maxWait, waited);
588
+ if (d.n === 1) this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose dropped as stale (waited ${Math.round(waited / 1000)}s for the lane): the node's RPC is slow and this poll is the last in line; further drops are counted on the Node & RPC page until it answers again` });
589
+ this.flagQuality('pool-poll-dropped', `the full-pool poll (getrawmempool verbose) has been dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} since ${new Date(d.since).toISOString().slice(11, 16)} UTC (longest wait ${Math.round(d.maxWait / 1000)}s): the node's RPC is answering slowly and this lowest-priority poll waits behind the live ones; the mempool panels show their last reading meanwhile`, 'warn');
590
+ } else {
591
+ this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
592
+ }
593
+ }
594
+ if (raw && typeof raw === 'object') {
595
+ this.state.mempoolDist = summarizeMempool(raw);
596
+ this.mempoolDense = denseBlock(raw); // Viewer Mode 2; not part of the snapshot
597
+ // THE TEMPLATE'S INPUT (2026-09-13). The block being built is assembled from this reply
598
+ // rather than bought with a getblocktemplate call, so the verbose map is kept until the
599
+ // next pool tier replaces it. Held on the monitor, never on `state`: it is tens of
600
+ // thousands of entries and must not ride a snapshot frame to a browser.
601
+ this.mempoolRaw = raw;
602
+ this.mempoolRawAt = Date.now();
603
+ this.state.lastGoodAt = Date.now();
604
+ }
605
+ if (this.state.mempoolDist) {
606
+ this.history.record('mempool', {
607
+ count: this.state.mempoolDist.count,
608
+ bytes: this.state.mempool.bytes ?? null,
609
+ usage: this.state.mempool.usage ?? null,
610
+ maxUsage: this.state.mempool.maxmempool ?? null,
611
+ totalFee: this.state.mempool.total_fee ?? null,
612
+ minFee: this.state.mempool.mempoolminfee ?? null,
613
+ avgFee: this.state.mempoolDist.avgFeeSat,
614
+ avgVsize: this.state.mempoolDist.avgVsize,
615
+ ingestRate: this.state.logState.relayRate ?? null,
616
+ });
617
+ }
618
+ this.tierStats = { name: 'pool', ms: Math.round(performance.now() - t0), at: Date.now() };
619
+ return { ok: true, mempool: this.state.mempoolDist?.count ?? 0 };
620
+ }
621
+
622
+ // COINSTATS IS AN INDEX, AND WITHOUT IT THE SUMMARY IS A FULL SCAN (2026-09-11, pointing this
623
+ // app at Bitcoin Core v31.99 for the first time). `gettxoutsetinfo` with no argument means
624
+ // hash_type "hash_serialized_3", which walks the whole UTXO set: measured 41.47 s on Core with
625
+ // 165.2 M UTXOs, against 0.003 s for "muhash" and 0.002 s for the bare call on the production node.
626
+ // One 41 s call in a lane that holds one request at a time is not one slow poll: it poisoned the
627
+ // latency average, stretched every tier's cadence (60 s -> 75 s), dropped 43 polls as stale, and
628
+ // switched off coinbase attribution and the block template with it -- while the page told the
629
+ // operator the NODE was slow, which was false. So the hash type is always explicit.
630
+ //
631
+ // "muhash" is answered from the coinstats index on both node types and keeps the three fields
632
+ // this app records (txouts, total_amount, muhash). Where that index is absent the same call is a
633
+ // full scan again, so a node that says so in getindexinfo is not asked at all: the UTXO figures
634
+ // read as unknown (the panel already draws "–") and the reason is stated, which is cheaper and
635
+ // more honest than 41 s of someone else's node every minute.
636
+ // NEVER A BLIND SCAN (2026-09-14, the first Mac install): "not known yet: ask once" put
637
+ // gettxoutsetinfo in the SAME batch as the getindexinfo that would have said no, so a node with
638
+ // no coinstatsindex was sent a full UTXO-set walk on its first slow tier -- minutes on that
639
+ // machine, past the 90 s timeout, and Core kept walking after the client gave up, holding its
640
+ // chain lock: every other call answered in 18 s, the mempool read was dropped, the board stayed
641
+ // empty. And because the batch had failed, the indexes were still unknown and the next tier
642
+ // asked again, every minute. The UTXO figures are asked only of a node that has SAID it keeps
643
+ // the index; a node that has not answered yet, or does not report indexes, is not asked.
644
+ utxoStatsWanted() {
645
+ const ix = this.state.indexes;
646
+ if (!ix || typeof ix !== 'object') return false; // not known yet: getindexinfo first, alone
647
+ return !!ix.coinstatsindex?.synced;
648
+ }
649
+
650
+ async tier_slow() {
651
+ // The heavy reads: the UTXO-set summary, the indexes and the chain tx stats.
652
+ // (The verbose mempool map moved to tier_pool on 2026-09-11.)
653
+ const t0 = performance.now();
654
+ // the indexes first, on their own, the first time: what they say decides the expensive call
655
+ if (!this.state.indexes || typeof this.state.indexes !== 'object') {
656
+ const first = await this.callList([{ method: 'getindexinfo' }], { key: `${this.id}:slow:indexes`, priority: 5 });
657
+ const ix = first.get('getindexinfo');
658
+ if (ix && !(ix instanceof RpcError)) this.state.indexes = ix;
659
+ }
660
+ const wantUtxo = this.utxoStatsWanted();
661
+ const m = await this.callList([
662
+ { method: 'getindexinfo' },
663
+ ...(wantUtxo ? [{ method: 'gettxoutsetinfo', params: ['muhash'] }] : []),
664
+ { method: 'getchaintxstats', params: [120] },
665
+ ], { key: `${this.id}:slow`, priority: 5 });
666
+ const ok = (k) => { const v = m.get(k); return v instanceof RpcError || v === undefined ? null : v; };
667
+ this.state.indexes = ok('getindexinfo') ?? this.state.indexes;
668
+ if (this.utxoStatsWanted()) this.clearQuality('utxo-unindexed');
669
+ else {
670
+ this.flagQuality('utxo-unindexed', 'UTXO-set figures (coins, total amount, muhash) are not read from this node: it reports no synced coinstatsindex, and without that index gettxoutsetinfo walks the whole UTXO set -- measured at 41 s on a 165 M-output chain, on an RPC server that answers one request at a time. Start the node with -coinstatsindex to have them; until then they are shown as unknown rather than bought at that price', 'info');
671
+ this.state.utxo = null;
672
+ }
673
+ const txo = ok('gettxoutsetinfo');
674
+ if (txo) this.state.utxo = txo;
675
+ const stats = ok('getchaintxstats');
676
+ if (stats) {
677
+ this.state.chaintxstats = stats;
678
+ // txrate is Core's own per-second average over the window; prefer it, and
679
+ // only fall back to our own delta if the node omits the field.
680
+ this.state.txRate = stats.txrate ?? null;
681
+ }
682
+ this.history.record('node', {
683
+ txouts: txo?.txouts ?? null,
684
+ totalAmount: txo?.total_amount ?? null,
685
+ muhash: txo?.muhash ?? null,
686
+ chainTxCount: stats?.txcount ?? null,
687
+ txRate: stats?.txrate ?? null,
688
+ blocks: this.state.chainInfo?.blocks ?? null,
689
+ });
690
+ this.tierStats = { name: 'slow', ms: Math.round(performance.now() - t0), at: Date.now() };
691
+ return { ok: true };
692
+ }
693
+
694
+ async tier_rare() {
695
+ const m = await this.callList([
696
+ { method: 'getpeerinfo' },
697
+ { method: 'getdeploymentinfo' },
698
+ { method: 'getrpcinfo' },
699
+ // Both are cheap (11 ms / 3 ms measured) and both say something the log
700
+ // only hints at: getaddrmaninfo is the peer book by network (production:
701
+ // 52,877 tried, ipv4 36,482 / ipv6 9,046 / onion 6,304 / i2p 1,045), and
702
+ // listbanned is the node's own ban table.
703
+ { method: 'getaddrmaninfo' },
704
+ { method: 'listbanned' },
705
+ ], { key: `${this.id}:rare`, priority: 7 });
706
+ const ok = (k) => { const v = m.get(k); return v instanceof RpcError || v === undefined ? null : v; };
707
+ this.absorbPeerInfo(ok('getpeerinfo'));
708
+ const addrman = ok('getaddrmaninfo');
709
+ if (addrman && addrman.all_networks) {
710
+ this.state.peers.addrman = addrman;
711
+ this.state.peers.addrmanUpdatedAt = Date.now();
712
+ }
713
+ const bans = ok('listbanned');
714
+ if (Array.isArray(bans)) {
715
+ this.state.peers.banTable = bans.length;
716
+ // Measured on the bench node while its own log said `banned 8/114`:
717
+ // listbanned answered []. The download worker's bans are not in the node's
718
+ // ban table, so in RPC-only mode that count is simply unavailable -- say
719
+ // which table this is rather than letting it read as "no peers are banned".
720
+ this.state.peers.banTableNote = 'listbanned is the node\'s stored ban table; the download worker\'s per-run bans (its log\'s `banned N/M`) are not in it -- measured 8/114 in the log against [] here';
721
+ }
722
+ this.state.deployments = ok('getdeploymentinfo') ?? this.state.deployments;
723
+ this.state.rpcInfo = ok('getrpcinfo') ?? this.state.rpcInfo;
724
+ return { ok: true };
725
+ }
726
+
727
+ // Shared by the rare tier and, in RPC-only mode, the mid tier: with no log there
728
+ // is nothing else naming a peer, so a 15-minute-old peer table is not a table.
729
+ // Cost measured 2026-09-08: 3 ms empty, 4 ms with 21 rows including byte counts.
730
+ absorbPeerInfo(peers) {
731
+ if (!Array.isArray(peers)) return;
732
+ const rated = withPeerRates(peers, this.peerRatePrev, Date.now());
733
+ this.peerRatePrev = rated.prev;
734
+ this.state.peers.list = rated.rows;
735
+ this.state.peers.listSource = 'getpeerinfo';
736
+ this.state.peers.listUpdatedAt = Date.now();
737
+ const cc = this.state.peers.connections;
738
+ if (peers.length === 0 && cc != null && cc > 0) {
739
+ this.flagQuality('peerinfo-empty', `getpeerinfo returns no rows while getconnectioncount reports ${cc} connections; this build publishes no peer table, so peer identity and byte counts${this.logEnabled ? ' come from the node log' : ' are unavailable in RPC-only mode'}`, 'warn');
740
+ this.clearQuality('peerinfo-partial');
741
+ } else {
742
+ this.clearQuality('peerinfo-empty');
743
+ // The other failure shape: rows exist but do not account for the bytes the
744
+ // node says it moved. Measured on the bench build: 1,487,577,978 summed
745
+ // across 21 rows against getnettotals' 2,116,236,872 = 70.29%. The missing
746
+ // 30% is traffic from peers no longer in the table. Render the table, but
747
+ // do not let a sum of rows be mistaken for the total.
748
+ const sumRecv = peers.reduce((a, p) => a + (p.bytesrecv || 0), 0);
749
+ const total = this.state.net.totalRecv;
750
+ const coverage = total ? sumRecv / total : null;
751
+ this.state.peers.byteCoverage = coverage == null ? null : +coverage.toFixed(4);
752
+ if (coverage != null && coverage < 0.9) {
753
+ this.flagQuality('peerinfo-partial', `getpeerinfo names ${peers.length} peer(s) whose bytesrecv sums to ${(coverage * 100).toFixed(1)}% of what getnettotals reports received; the rest belongs to peers no longer in the table, so per-peer figures are a subset, not a breakdown`, 'info');
754
+ } else {
755
+ this.clearQuality('peerinfo-partial');
756
+ }
757
+ }
758
+ }
759
+
760
+ // ------------------------------------------------------------ blocks
761
+
762
+ async onNewTip(from, to) {
763
+ const span = to - from + 1;
764
+ if (span <= 0) return;
765
+ const cappedFrom = span > 24 ? to - 23 : from; // a catch-up burst: newest 24 only
766
+ await this.fetchBlockStats(range(cappedFrom, to)).catch(() => {});
767
+ this.enqueueMining(range(cappedFrom, to));
768
+ this.lastTip = to;
769
+ if (span > 1) {
770
+ this.addEvent({
771
+ kind: 'tip_jump', severity: 'info', tag: 'chain', ts: Date.now(),
772
+ text: `tip advanced ${span} block(s) to ${to} between polls (fetched newest ${to - cappedFrom + 1})`,
773
+ });
774
+ }
775
+ }
776
+
777
+ // ---------------------------------------------------- who mined the block
778
+ //
779
+ // Enqueue only heights we can resolve a hash for, deduped, newest first, bounded.
780
+ // The queue is deliberately small: an attribution lagging a block by a few polls is
781
+ // invisible, while a queue that outruns the lane delays the sync bar everyone watches.
782
+ enqueueMining(heights) {
783
+ if (!this.miningCfg.enabled) return;
784
+ if (this.state.chainInfo?.initialblockdownload === true) {
785
+ this.mining.skippedIbd += heights.length;
786
+ return;
787
+ }
788
+ for (let i = heights.length - 1; i >= 0; i--) {
789
+ const h = heights[i];
790
+ if (this.mining.rows.has(h) || this.miningQueue.includes(h)) continue;
791
+ this.miningQueue.push(h);
792
+ }
793
+ this.miningQueue.sort((a, b) => b - a);
794
+ if (this.miningQueue.length > 60) this.miningQueue.length = 60;
795
+ setImmediate(() => { this.pumpMining().catch(() => {}); });
796
+ }
797
+
798
+ async pumpMining() {
799
+ if (this.miningBusy || this.stopped || !this.miningQueue.length) return;
800
+ // A failed round sets mining.retryAt. Honour it here rather than dropping the work, so the
801
+ // queue survives a slow patch and drains when the node recovers.
802
+ if (this.mining.retryAt && Date.now() < this.mining.retryAt) {
803
+ const wait = this.mining.retryAt - Date.now();
804
+ setTimeout(() => { if (!this.stopped) this.pumpMining().catch(() => {}); }, wait).unref?.();
805
+ return;
806
+ }
807
+ this.miningBusy = true;
808
+ try {
809
+ for (let n = 0; n < this.miningCfg.perTick && this.miningQueue.length; n++) {
810
+ const height = this.miningQueue.shift();
811
+ if (this.mining.rows.has(height)) continue;
812
+ const hash = this.state.blocks.get(height)?.hash;
813
+ if (!hash) continue; // stats have not landed for it yet
814
+ const row = await this.fetchMining(height, hash).catch((err) => {
815
+ // A failed attribution is a gap, not a zero. Say so, stop asking THIS round -- and
816
+ // keep the work.
817
+ //
818
+ // This used to do `this.miningQueue.length = 0`, which threw the backlog away. Nothing
819
+ // ever put it back: enqueueMining is called only by onNewTip (the heights that just
820
+ // arrived) and by backfillBlocks (once, at boot), and line ~737 skips anything already
821
+ // in `rows` -- which these never reached. So one stale-dropped block discarded the
822
+ // whole 36-block boot window permanently, and with perTick:1 the page then refilled one
823
+ // block at a time as new ones were mined. Seen on an Umbrel 2026-09-13: a single
824
+ // "waited 18068ms for a lane free enough" left windowBlocks=0 with a frozen lastError,
825
+ // while the same code on a local node attributed 30 blocks across 9 pools.
826
+ //
827
+ // The height goes back to the front, the rest of the queue survives, and a backoff
828
+ // decides when to try again -- so a busy node is not hammered and a recovering one
829
+ // catches up by itself.
830
+ this.mining.lastError = `${err?.message ?? err}`;
831
+ this.mining.failures += 1;
832
+ this.mining.retryAt = Date.now() + Math.min(60_000, 2_000 * 2 ** Math.min(this.mining.failures - 1, 5));
833
+ this.miningQueue.unshift(height);
834
+ this.flagQuality('mining-unavailable', `coinbase attribution paused: getblock/getrawtransaction failed (${this.mining.lastError}); retrying in ${Math.round((this.mining.retryAt - Date.now()) / 1000)}s -- the block list keeps its sizes and fees, the miner column stays empty rather than guessed`, 'warn');
835
+ return null;
836
+ });
837
+ if (!row) break; // stop this round; the queue and the backoff hold the rest
838
+ this.mining.failures = 0;
839
+ this.mining.retryAt = 0;
840
+ this.clearQuality('mining-unavailable');
841
+ // Curated label, if the map knows this coinbase. The raw tag is never replaced.
842
+ const matched = matchPool(this.mining.poolMap, { tagText: row.tagText, rawHex: row.rawCoinbase });
843
+ if (matched) {
844
+ row.poolLabel = matched.name;
845
+ row.poolLabelKey = matched.key;
846
+ row.matchedTag = matched.matchedTag;
847
+ }
848
+ row.seenAt = Date.now(); // when WE read it, distinct from the block's own time
849
+ this.mining.rows.set(height, row);
850
+ ledgerApply(this.mining.pools, row);
851
+ // Grouped view: by curated label where one matched, by raw key otherwise, so a
852
+ // pool that writes three different tag strings is countable without pretending
853
+ // we proved they are the same organisation.
854
+ ledgerApply(this.mining.byPool, matched ? { ...row, poolKey: matched.key, poolLabel: matched.name } : row);
855
+ this.mining.fetched += 1;
856
+ this.mining.at = Date.now();
857
+ const stats = this.state.blocks.get(height);
858
+ this.history.record('blocks', {
859
+ t: row.at ?? Date.now(), height, weight: row.weight, size: row.size, txs: row.txs,
860
+ totalfee: row.totalfee, avgFeerate: row.avgFeerate, p0: stats?.p?.[0] ?? null,
861
+ p1: stats?.p?.[1] ?? null, p2: stats?.p?.[2] ?? null, p3: stats?.p?.[3] ?? null, p4: stats?.p?.[4] ?? null,
862
+ });
863
+ }
864
+ // Bounded, newest-first: the ledger is a window and is presented as one.
865
+ if (this.mining.rows.size > 400) {
866
+ const keep = [...this.mining.rows.keys()].sort((a, b) => b - a).slice(0, 360);
867
+ this.mining.rows = new Map(keep.map((k) => [k, this.mining.rows.get(k)]));
868
+ }
869
+ } finally {
870
+ this.miningBusy = false;
871
+ if (this.miningQueue.length && this.miningCfg.perTick) {
872
+ setTimeout(() => { if (!this.stopped) this.pumpMining().catch(() => {}); }, 2000).unref?.();
873
+ }
874
+ }
875
+ }
876
+
877
+ async fetchMining(height, hash) {
878
+ const [gb, rt] = await Promise.all([
879
+ // Step 1: the coinbase txid. verbosity 1 is the txid list plus the header, and it
880
+ // also carries the exact serialized size/weight -- 260 KB measured, 8 ms.
881
+ this.rpc.batch([{ method: 'getblock', params: [hash, 1] }], { priority: 3 }),
882
+ Promise.resolve(null),
883
+ ]);
884
+ const block = gb?.[0]?.ok ? gb[0].result : null;
885
+ if (!block) throw new Error(gb?.[0]?.error?.message ?? 'getblock unanswered');
886
+ const cbTxid = Array.isArray(block.tx) ? block.tx[0] : null;
887
+ if (!cbTxid) throw new Error('getblock returned no tx list');
888
+ const rtRes = await this.rpc.batch([{ method: 'getrawtransaction', params: [cbTxid, 2] }], { priority: 3 });
889
+ if (!rtRes?.[0]?.ok) throw new Error(rtRes?.[0]?.error?.message ?? 'getrawtransaction unanswered');
890
+ const vin0 = rtRes[0].result?.vin?.[0] ?? {};
891
+ const decoded = decodeCoinbase(vin0.coinbase ?? vin0.coinbaseHex ?? '');
892
+ const stats = this.state.blocks.get(height) ?? {};
893
+ const row = minerRow({
894
+ height, hash,
895
+ at: (stats.time ?? block.time ? (stats.time ?? block.time) * 1000 : null),
896
+ decoded,
897
+ stats: {
898
+ height, weight: block.weight ?? stats.weight, size: block.size ?? stats.size,
899
+ strippedSize: block.strippedsize ?? null, txs: stats.txs, totalfee: stats.totalfee,
900
+ avgFeerate: stats.avgFeerate, p1: stats.p?.[1], p2: stats.p?.[2], p4: stats.p?.[4],
901
+ },
902
+ });
903
+ if (decoded.height != null && block.height != null && decoded.height !== block.height) {
904
+ // BIP34 says the coinbase carries the block height. A mismatch means we are
905
+ // reading something other than this block's coinbase -- stop rather than
906
+ // attribute a block to the wrong pool.
907
+ this.flagQuality('mining-height-mismatch', `coinbase of ${height} declares ${decoded.height}; not attributed`, 'warn');
908
+ return null;
909
+ }
910
+ return row;
911
+ }
912
+
913
+ /**
914
+ * Ask the node what it would put in the next block, and what that says about the
915
+ * mempool's shape (feerate landscape, ancestor packages).
916
+ *
917
+ * `staleMs` lets a caller say "serve me the cached one"; the Mining page refreshes on a
918
+ * timer while it is visible, and a second viewer arriving inside the freshness window
919
+ * shares the first one's call rather than paying the node again.
920
+ */
921
+ async fetchTemplate({ staleMs = this.nextBlockCfg.freshMs, force = false } = {}) {
922
+ if (!this.nextBlockCfg.enabled) return { unavailable: 'disabled (BLOCKYARD_MINING_TEMPLATE=0)' };
923
+ if (this.state.chainInfo?.initialblockdownload === true) {
924
+ return { unavailable: 'node is in initial download; a block template would be built from a chain that is not there yet' };
925
+ }
926
+ if (!this.mempoolRaw) {
927
+ // The pool tier has not answered yet. Saying so is better than assembling an empty block
928
+ // and calling it the one being built.
929
+ return this.nextBlock ?? { unavailable: 'the verbose mempool has not been read yet; the block being built is assembled from it' };
930
+ }
931
+ // ASSEMBLED, NOT FETCHED, so there is no call to coalesce and no lane to wait for: the
932
+ // `nextBlockBusy` promise and the heavy/keyed batch this used to run are gone with the RPC.
933
+ // What freshness means now is the age of the pool tier's last read (20 s by default), so the
934
+ // cache is keyed on THAT rather than on wall-clock: re-assembling the same mempool would
935
+ // produce the same block, and 25,000 entries is ~50 ms of our own CPU, not the node's.
936
+ if (!force && this.nextBlock && this.nextBlockPoolAt === this.mempoolRawAt && Date.now() - this.nextBlockAt < staleMs) {
937
+ return this.nextBlock;
938
+ }
939
+
940
+ const t0 = Date.now();
941
+ try {
942
+ const chain = this.state.chainInfo ?? {};
943
+ const tipHeight = Number.isFinite(chain.blocks) ? chain.blocks : this.state.tip?.height ?? null;
944
+ const template = templateFromMempool(this.mempoolRaw, {
945
+ height: tipHeight == null ? null : tipHeight + 1,
946
+ previousblockhash: chain.bestblockhash ?? this.state.tip?.hash ?? null,
947
+ at: Date.now(),
948
+ });
949
+ const txs = template.transactions;
950
+ const summary = summarizeTemplate(template, {
951
+ at: Date.now(), previous: chain.bestblockhash ?? this.state.tip?.hash ?? null,
952
+ });
953
+ const packages = packagesFromTemplate(txs);
954
+ const avgWeightMined = (() => {
955
+ const rows = [...this.mining.rows.values()].filter((r) => Number.isFinite(r?.weight)).slice(0, 40);
956
+ return rows.length ? rows.reduce((n, r) => n + r.weight, 0) / rows.length : null;
957
+ })();
958
+ const economy = blockEconomy({ template: summary, mempool: this.state.mempool, avgWeightMined });
959
+ const visual = templateCells(txs);
960
+ this.nextBlock = {
961
+ ...summary,
962
+ packages,
963
+ economy,
964
+ visual,
965
+ ms: Date.now() - t0,
966
+ at: Date.now(),
967
+ note: LOCAL_TEMPLATE_NOTE,
968
+ // provenance, so the page can say where this came from and how old its input is
969
+ assembledLocally: true,
970
+ source: 'getrawmempool',
971
+ poolSize: template.poolSize,
972
+ poolAgeMs: Date.now() - this.mempoolRawAt,
973
+ };
974
+ this.nextBlockAt = Date.now();
975
+ this.nextBlockPoolAt = this.mempoolRawAt;
976
+ this.clearQuality('template-unavailable');
977
+ return this.nextBlock;
978
+ } catch (err) {
979
+ // Keep showing the last one, marked: a page that silently stops updating is the failure
980
+ // mode this project keeps being called for.
981
+ this.flagQuality('template-unavailable', `assembling the block being built failed (${err?.message ?? err}); the card keeps its last reading and says how old it is`, 'warn');
982
+ if (this.nextBlock) this.nextBlock.lastError = `${err?.message ?? err}`;
983
+ return this.nextBlock ?? { unavailable: `${err?.message ?? err}` };
984
+ }
985
+ }
986
+
987
+ miningView() {
988
+ const rows = [...this.mining.rows.values()].sort((a, b) => b.height - a.height);
989
+ const named = (k) => aliasFor(this.mining.aliases, k);
990
+ return {
991
+ recent: rows.slice(0, 40).map((r) => ({ ...r, poolName: named(r.poolKey) })),
992
+ pools: ledgerRows(this.mining.pools).map((p) => ({ ...p, name: named(p.poolKey) })),
993
+ byPool: ledgerRows(this.mining.byPool).map((p) => ({ ...p, name: p.label ?? named(p.poolKey) ?? p.poolKey, labelled: !!p.label })),
994
+ labelSource: this.mining.poolMap
995
+ ? { source: this.mining.poolMap.source, sha256: this.mining.poolMap.sourceSha256, fetchedAt: this.mining.poolMap.fetchedAt, attribution: this.mining.poolMap.attribution }
996
+ : null,
997
+ nextBlock: this.nextBlock,
998
+ nextBlockAgeMs: this.nextBlock ? Date.now() - this.nextBlockAt : null,
999
+ windowBlocks: rows.length,
1000
+ windowHeights: rows.length ? { from: rows[rows.length - 1].height, to: rows[0].height } : null,
1001
+ fetched: this.mining.fetched, at: this.mining.at, lastError: this.mining.lastError,
1002
+ skippedIbd: this.mining.skippedIbd,
1003
+ aliasesLoaded: !!this.mining.aliases,
1004
+ enabled: this.miningCfg.enabled,
1005
+ note: 'shares are of the observed window only, and the window is stated beside them; a pool name appears only if data/pool-aliases.json says so -- otherwise the coinbase text is shown as the pool wrote it',
1006
+ };
1007
+ }
1008
+
1009
+ loadPoolMap() {
1010
+ const f = this.miningCfg.poolMapFile;
1011
+ if (!f) return;
1012
+ try {
1013
+ const parsed = JSON.parse(fs.readFileSync(f, 'utf8'));
1014
+ if (Array.isArray(parsed?.matchers) && parsed.matchers.length) this.mining.poolMap = parsed;
1015
+ } catch (err) {
1016
+ // An absent map is the normal state (nothing has been fetched yet) and must not be
1017
+ // an error; a corrupt one is, because silent half-loading would mislabel blocks.
1018
+ if (err?.code !== 'ENOENT') this.mining.lastError = `pool-map: ${err?.message}`;
1019
+ }
1020
+ }
1021
+
1022
+ loadMiningAliases() {
1023
+ const f = this.miningCfg.aliasesFile;
1024
+ if (!f) return;
1025
+ try {
1026
+ const parsed = JSON.parse(fs.readFileSync(f, 'utf8'));
1027
+ if (parsed && typeof parsed === 'object') this.mining.aliases = parsed;
1028
+ } catch (err) {
1029
+ if (err?.code !== 'ENOENT') this.mining.lastError = `aliases: ${err?.message}`;
1030
+ }
1031
+ }
1032
+
1033
+ async backfillBlocks(n) {
1034
+ const tip = this.state.chainInfo?.blocks;
1035
+ if (tip == null) return;
1036
+ const from = Math.max(1, tip - n + 1);
1037
+ await this.fetchBlockStats(range(from, tip));
1038
+ this.enqueueMining(range(Math.max(from, tip - this.miningCfg.backfill + 1), tip));
1039
+ }
1040
+
1041
+ async fetchBlockStats(heights) {
1042
+ if (!heights.length) return [];
1043
+ const out = [];
1044
+ // Chunk the batch: a batch is one connection, but a 900-element batch is one
1045
+ // very long turn on a single-threaded server.
1046
+ for (const chunk of chunkBy(heights, 12)) {
1047
+ const calls = chunk.map((h) => ({ method: 'getblockstats', params: [h, BLOCKSTATS_FIELDS] }));
1048
+ const results = await this.rpc.batch(calls, { priority: 3 });
1049
+ for (let i = 0; i < results.length; i++) {
1050
+ const r = results[i];
1051
+ const h = chunk[i];
1052
+ if (!r.ok) {
1053
+ // A height we cannot read is a hole, and a hole is a fact -- the node's
1054
+ // own docs refuse rather than report low, and so do we.
1055
+ this.flagQuality('blockstats-unreadable', `getblockstats failed for height ${h}: ${r.error?.message}`, 'warn');
1056
+ continue;
1057
+ }
1058
+ const s = r.result;
1059
+ if (!s || s.height == null) continue;
1060
+ const prev = this.state.blocks.get(s.height - 1);
1061
+ const row = {
1062
+ t: (s.time ?? Math.floor(Date.now() / 1000)) * 1000,
1063
+ height: s.height,
1064
+ hash: s.blockhash ?? null,
1065
+ time: s.time ?? null,
1066
+ mediantime: s.mediantime ?? null,
1067
+ totalfee: s.totalfee ?? null,
1068
+ txs: s.txs ?? null,
1069
+ // total_size is the sum of transaction sizes, NOT the serialized block: the
1070
+ // 80-byte header and the txid-count varint are not in it. The basis travels in
1071
+ // the payload so no chart, export or later reader can quietly read it as "the
1072
+ // block size" — the name-trusted-instead-of-checked mistake is what left this
1073
+ // figure empty for a day.
1074
+ size: s.total_size ?? null,
1075
+ weight: s.total_weight ?? null,
1076
+ sizeBasis: s.total_size != null
1077
+ ? 'sum of transaction sizes (getblockstats total_size); excludes the 80-byte header and the txid-count varint'
1078
+ : null,
1079
+ sizeMissing: s.total_size == null
1080
+ ? 'getblockstats answered without total_size, so no size is claimed — a hole is reported, not drawn as zero'
1081
+ : null,
1082
+ medianTxSize: s.mediantxsize ?? null,
1083
+ avgTxSize: s.avgtxsize ?? null,
1084
+ // Fee preference per block, which is the whole basis of a goggles-style view:
1085
+ // what feerate a pool actually put in the blocks it mined.
1086
+ avgFeerate: s.avgfeerate ?? null,
1087
+ swtotalSize: s.swtotal_size ?? null,
1088
+ swtxs: s.swtxs ?? null,
1089
+ subsidy: s.subsidy ?? null,
1090
+ utxoIncrease: s.utxo_increase ?? null,
1091
+ ins: s.ins ?? null,
1092
+ outs: s.outs ?? null,
1093
+ avgfee: s.avgfee ?? null,
1094
+ medianfee: s.medianfee ?? null,
1095
+ maxfee: s.maxfee ?? null,
1096
+ p: Array.isArray(s.feerate_percentiles) ? s.feerate_percentiles : null,
1097
+ viaPeer: this.perPeerBlocks.get(s.height) ?? null,
1098
+ source: 'getblockstats',
1099
+ };
1100
+ if (prev?.hash && row.hash) {
1101
+ const known = this.state.blocks.get(s.height);
1102
+ if (known && known.hash !== row.hash) this.reorgEvents += 1;
1103
+ }
1104
+ row.gapSec = prev?.time && s.time ? s.time - prev.time : null;
1105
+ this.state.blocks.set(s.height, row);
1106
+ this.history.record('blocks', { ...row, p0: row.p?.[0] ?? null, p1: row.p?.[1] ?? null, p2: row.p?.[2] ?? null, p3: row.p?.[3] ?? null, p4: row.p?.[4] ?? null });
1107
+ out.push(row);
1108
+ }
1109
+ }
1110
+ // Keep the in-memory map bounded; history keeps the long view. Extracted as
1111
+ // trimBlockMap() so the policy is callable (and testable) without an RPC round
1112
+ // trip, and so `fetchBlockStats([])` cannot skip the cap by returning early.
1113
+ this.trimBlockMap();
1114
+ if (out.length) this.emit('blocks', out);
1115
+ return out;
1116
+ }
1117
+
1118
+ /**
1119
+ * Drop the oldest heights once the map exceeds store.blockMapCap.
1120
+ *
1121
+ * The cap is configured and measured, not a round number: 12,000 rows measured
1122
+ * 3.6 MB of heap on 2026-09-09 (test/monitor-shapes.test.js, ~310 B per row). The
1123
+ * previous 3,000-row cut threw away blocks the 72 h retention would have kept, to
1124
+ * save ~0.9 MB -- and `gapSec` for the oldest survivor silently described a block
1125
+ * whose predecessor had been dropped.
1126
+ */
1127
+ trimBlockMap() {
1128
+ const cap = this.blockMapCap ?? 12000;
1129
+ if (this.state.blocks.size <= cap) return { kept: this.state.blocks.size, dropped: 0, cap };
1130
+ const keep = new Set([...this.state.blocks.keys()].sort((a, b) => a - b).slice(-Math.floor(cap * 0.9)));
1131
+ let dropped = 0;
1132
+ for (const k of [...this.state.blocks.keys()]) if (!keep.has(k)) { this.state.blocks.delete(k); dropped += 1; }
1133
+ this.blockMapEvicted += dropped;
1134
+ // Say it once in a while rather than never: an eviction is the moment a chart
1135
+ // stops being able to reach back, and "the map is full" must not be invisible.
1136
+ this.flagQuality('block-map-trimmed', `the in-memory block map holds the newest ${keep.size} heights and has dropped ${this.blockMapEvicted} since start; older blocks come from the rings, and a gapSec computed against a dropped predecessor is not drawn`, 'info');
1137
+ return { kept: keep.size, dropped, cap };
1138
+ }
1139
+
1140
+ // --------------------------------------------------------------- log
1141
+
1142
+ onLogEvents(evs) {
1143
+ const now = Date.now();
1144
+ const rows = [];
1145
+ for (const ev of evs) {
1146
+ rows.push(...this.absorb(ev, now));
1147
+ this.logLines += 1;
1148
+ // `rule` is null only for a line no parser claimed, so this ratio is the
1149
+ // canary for the node changing its log grammar under us.
1150
+ if (ev.rule) this.logParsed += 1;
1151
+ else this.noteUnseenTag(ev, now);
1152
+ // Liveness bookkeeping per measurement, because a corpus ratio hides a dead
1153
+ // rule (rule 16). Event time, not wall clock: a backfill must not read as a
1154
+ // five-minute silence that just ended.
1155
+ if (ev.ts > (this.lastLineAt ?? 0)) this.lastLineAt = ev.ts;
1156
+ const shape = ev.rule ? RULE_TO_SHAPE.get(ev.rule) : null;
1157
+ if (shape) {
1158
+ const rec = this.shapeSeen.get(shape) ?? { lastAt: ev.ts, matches: 0 };
1159
+ rec.lastAt = Math.max(rec.lastAt, ev.ts);
1160
+ rec.matches += 1;
1161
+ this.shapeSeen.set(shape, rec);
1162
+ }
1163
+ }
1164
+ if (rows.length) {
1165
+ const logged = rows.map((r) => ({ ...r, source: 'log' }));
1166
+ this.history.addEvents(logged);
1167
+ this.emit('events', logged);
1168
+ }
1169
+ this.state.logState.lastEventAt = now;
1170
+ this.state.logState.eventCount = (this.state.logState.eventCount ?? 0) + evs.length;
1171
+ }
1172
+
1173
+ // Three ways a log-backed panel goes quietly wrong, all three observed on this
1174
+ // box within hours of each other:
1175
+ // * the file is not there (a benchmark datadir gets cleaned up);
1176
+ // * the file is there and stopped moving -- on 2026-09-08 the bench node was
1177
+ // configured as <datadir>/main/debug.log, a 144-byte stub written at boot,
1178
+ // while the real log went to console.log. The monitor held that stub open on
1179
+ // fd 22, read nothing, and said nothing: every log-derived panel simply
1180
+ // stopped changing and looked like a quiet node.
1181
+ // * the file moves and nothing matches any more -- the 2026-09-08 bench build
1182
+ // rewrote [dlc] and 1 of its 1,006 tick lines parsed.
1183
+ // None of the three is visible in the data itself, which is why the flag exists.
1184
+ async checkLogHealth() {
1185
+ if (!this.tail) return this.logHealthStats;
1186
+ const st = this.tail.status();
1187
+ const now = Date.now();
1188
+
1189
+ if (!st.exists) {
1190
+ this.flagQuality('log-missing', `no log file at ${st.file}; every panel that draws from the log (bandwidth, per-peer activity, ingest rate) has no source at all, not a zero value`, 'warn');
1191
+ } else {
1192
+ this.clearQuality('log-missing');
1193
+ }
1194
+
1195
+ if (this.logLastSize == null || st.size !== this.logLastSize) {
1196
+ this.logGrowthAt = now;
1197
+ // Remember the chain height as it stood when the log last moved. A silent
1198
+ // log is ambiguous on its own; a silent log while the chain raced ahead is
1199
+ // not, and the difference is what the message below says out loud.
1200
+ this.logGrowthTip = this.state.chainInfo?.blocks ?? null;
1201
+ }
1202
+ this.logLastSize = st.size;
1203
+ const quietMs = now - this.logGrowthAt;
1204
+ const tipNow = this.state.chainInfo?.blocks ?? null;
1205
+ const advanced = tipNow != null && this.logGrowthTip != null ? tipNow - this.logGrowthTip : null;
1206
+ this.logAdvancedWhileQuiet = quietMs > this.staleAfterMs ? advanced : null;
1207
+ if (st.exists && quietMs > this.staleAfterMs) {
1208
+ // Measured on the bench node at 04:50: its chain advanced ~15,000 blocks in
1209
+ // the 18 minutes its console.log sat frozen at 2,719 bytes -- the node's own
1210
+ // stdout is block-buffered when it is a file rather than a tty, so a frozen
1211
+ // tail does not automatically mean the wrong file. Saying which of the two
1212
+ // it looks like is the point; a bare "log is stale" would be a guess.
1213
+ const why = advanced == null
1214
+ ? ': cannot tell whether the node is idle or the tail is wrong, because no chain height has been read yet'
1215
+ : advanced > 0
1216
+ ? `: this node's chain advanced ${advanced} block(s) (${this.logGrowthTip} -> ${tipNow}) while the file sat still, so the node is writing somewhere else, or its stdout is block-buffered because it is a file and not a tty`
1217
+ : ' and the chain has not advanced either, so an idle node is the likelier reading';
1218
+ this.flagQuality('log-silent', `${st.file} has produced nothing for ${(quietMs / 60000).toFixed(1)} min (stuck at ${st.size} bytes, threshold ${(this.staleAfterMs / 60000).toFixed(0)} min)${why} -- the bandwidth and per-peer figures are frozen, not current`, 'warn');
1219
+ } else if (st.exists) {
1220
+ this.clearQuality('log-silent');
1221
+ }
1222
+
1223
+ // Ratio over the window just observed, then the counters restart: a cumulative
1224
+ // average from boot would hide a format change that arrived an hour in.
1225
+ const lines = this.logLines;
1226
+ const parsed = this.logParsed;
1227
+ this.logLines = 0;
1228
+ this.logParsed = 0;
1229
+ const ratio = lines ? parsed / lines : null;
1230
+ if (lines >= 200 && ratio < 0.05) {
1231
+ this.flagQuality('log-unparsed', `${st.file} is delivering lines but ${Math.round((1 - ratio) * 100)}% of them (${parsed}/${lines}) match no parser rule: the node's log format has changed and the log-derived panels are reading almost nothing`, 'warn');
1232
+ } else if (lines >= 200) {
1233
+ this.clearQuality('log-unparsed');
1234
+ }
1235
+
1236
+ // Per-shape liveness. The question is not "is the parse ratio low" -- a corpus
1237
+ // ratio stayed at 24-74% today while three rules died outright (rule 16). The
1238
+ // question is "this measurement was arriving every N seconds, has not arrived for
1239
+ // 8x that, and the node is still writing other lines".
1240
+ // Trust the node's own answer about IBD when it has one. The first version of
1241
+ // this inferred IBD from log shapes, and within a minute of going live it flagged
1242
+ // a fully synced node (initialblockdownload=false, verificationprogress=1,
1243
+ // blocks==headers) for "bandwidth rate stopped arriving" -- because the tailed
1244
+ // history contained `[utxo_live] catchup progress` from the post-boot catch-up,
1245
+ // which is not the same thing as being behind. Log shapes are the fallback for
1246
+ // when the RPC has not answered, and when even that is unknown we do not watch.
1247
+ const ci = this.state.chainInfo;
1248
+ const behind = ci?.blocks != null && ci?.headers != null ? ci.headers - ci.blocks : null;
1249
+ const ibdKnown = ci != null && (ci.initialblockdownload != null || behind != null);
1250
+ const ibdish = ci?.initialblockdownload === true || (behind != null && behind > 100);
1251
+ const shapes = [];
1252
+ const dead = [];
1253
+ for (const spec of SHAPES) {
1254
+ const rec = this.shapeSeen.get(spec.shape);
1255
+ if (!rec) continue; // never armed: this build does not emit that line
1256
+ const gate = this.shapeGates[spec.shape] ?? spec.gateMs;
1257
+ if (spec.ibdOnly && !ibdish) {
1258
+ shapes.push({
1259
+ shape: spec.shape, armed: true, watching: false, lastAt: rec.lastAt, matches: rec.matches, gateMs: gate,
1260
+ reason: ibdKnown ? 'node is not in IBD, so this line is expected to stop'
1261
+ : 'IBD state unknown (no chainInfo yet); not watched on a guess',
1262
+ });
1263
+ continue;
1264
+ }
1265
+ const ageMs = (this.lastLineAt ?? now) - rec.lastAt;
1266
+ const silent = ageMs > gate;
1267
+ shapes.push({ shape: spec.shape, armed: true, watching: true, lastAt: rec.lastAt, ageSec: Math.round(ageMs / 1000), matches: rec.matches, gateMs: gate, silent });
1268
+ if (silent) dead.push(`${spec.shape} (last parsed ${(ageMs / 60000).toFixed(1)} min ago, expected more often than every ${(gate / 60000).toFixed(0)} min)`);
1269
+ }
1270
+ if (dead.length) {
1271
+ this.flagQuality('log-shape-silent', `a measurement this node was producing has stopped arriving while its log keeps moving: ${dead.join('; ')}. Almost always the node reworded that line. The figure is missing, not zero -- the parser has to follow it`, 'warn');
1272
+ } else {
1273
+ this.clearQuality('log-shape-silent');
1274
+ }
1275
+
1276
+ this.logHealthStats = {
1277
+ checkedAt: now, lastGrowthAt: this.logGrowthAt, quietMs,
1278
+ staleAfterMs: this.staleAfterMs, lines, parsed, ratio: ratio == null ? null : +ratio.toFixed(4),
1279
+ advancedWhileQuiet: this.logAdvancedWhileQuiet ?? null,
1280
+ shapes,
1281
+ shapesWatched: shapes.filter((x) => x.watching).length,
1282
+ shapesSilent: dead.length,
1283
+ };
1284
+ return this.logHealthStats;
1285
+ }
1286
+
1287
+ // Per-peer record, created on demand and keyed by host. The peer table is
1288
+ // assembled from several log lines that each name the same peer (identity, relay
1289
+ // counts, download rate), so every writer shares this accessor.
1290
+ peerRecord(host, addr, ts) {
1291
+ const rec = this.perPeerRelay.get(host) ?? { accepted: 0, blocks: 0, first: ts };
1292
+ if (addr) rec.addr = addr;
1293
+ rec.lastSeen = ts;
1294
+ this.perPeerRelay.set(host, rec);
1295
+ return rec;
1296
+ }
1297
+
1298
+ absorb(ev, now) {
1299
+ const ls = this.state.logState;
1300
+ switch (ev.kind) {
1301
+ case 'bandwidth': {
1302
+ ls.inBps = ev.netRate ?? ls.inBps;
1303
+ ls.netTotal = ev.netTotal ?? ls.netTotal;
1304
+ ls.diskWriteBps = ev.diskRate ?? ls.diskWriteBps;
1305
+ ls.diskTotal = ev.diskTotal ?? ls.diskTotal;
1306
+ ls.lastBandwidthAt = ev.ts;
1307
+ // The 2026-09-08 bench build folds the extras into this one line: running
1308
+ // averages, the dead-weight floor, the ban counter, the worker's own
1309
+ // counters. Each is assigned only when the line actually carried it, so
1310
+ // the older production shape cannot null a figure the previous line gave.
1311
+ if (ev.avgNetRate != null) ls.avgRecv = ev.avgNetRate;
1312
+ if (ev.avgDiskRate != null) ls.avgWrite = ev.avgDiskRate;
1313
+ if (ev.floor != null) ls.floorBps = ev.floor;
1314
+ if (ev.poolMedianText != null) ls.poolMedianText = ev.poolMedianText;
1315
+ if (ev.worker) ls.workerCounters = ev.worker;
1316
+ // A field no rule knows is data, not noise: it is how the next format change
1317
+ // announces itself before it breaks something.
1318
+ if (Array.isArray(ev.extraFields) && ev.extraFields.length) {
1319
+ ls.tickExtraFields = { at: ev.ts, fields: ev.extraFields, values: ev.extraValues ?? null };
1320
+ }
1321
+ if (ev.banned != null) {
1322
+ ls.banned = ev.banned;
1323
+ ls.bannedOf = ev.bannedOf;
1324
+ this.history.record('peers', { banned: ev.banned, connections: this.state.peers.connections ?? null, in: this.state.peers.in ?? null, out: this.state.peers.out ?? null });
1325
+ }
1326
+ this.state.net.inBps = ev.netRate ?? this.state.net.inBps;
1327
+ this.state.net.logNetTotal = ev.netTotal;
1328
+ this.state.net.outBps = this.state.net.outBps ?? null;
1329
+ this.history.record('net', {
1330
+ inBps: ev.netRate ?? null, outBps: this.state.net.outBps ?? null,
1331
+ diskWriteBps: ev.diskRate ?? null, inTotal: ev.netTotal ?? null, diskTotal: ev.diskTotal ?? null,
1332
+ });
1333
+ return [];
1334
+ }
1335
+ case 'bw_average':
1336
+ ls.avgRecv = ev.avgRecv; ls.avgWrite = ev.avgWrite;
1337
+ return [];
1338
+ case 'deadweight':
1339
+ ls.floorBps = ev.floor; ls.poolMedianBps = ev.poolMedian;
1340
+ return [];
1341
+ case 'ban_count':
1342
+ ls.banned = ev.banned; ls.bannedOf = ev.of;
1343
+ this.history.record('peers', { banned: ev.banned, connections: this.state.peers.connections ?? null, in: this.state.peers.in ?? null, out: this.state.peers.out ?? null });
1344
+ return [];
1345
+ case 'peer_ranking':
1346
+ ls.ranking = { live: ev.live, answered: ev.answered, best: ev.best, median: ev.median, silent: ev.silent, at: ev.ts };
1347
+ this.history.record('peers', {
1348
+ rankingLive: ev.live, rankingAnswered: ev.answered, rankingMedianKbps: ev.median == null ? null : Math.round(ev.median / 1000),
1349
+ connections: this.state.peers.connections ?? null, in: this.state.peers.in ?? null, out: this.state.peers.out ?? null,
1350
+ txRelayPeers: this.perPeerRelay.size, servedBlocks: this.perPeerBlocks.size,
1351
+ });
1352
+ return [ev];
1353
+ case 'heartbeat': {
1354
+ ls.heartbeat = { tip: ev.tip, peersInUse: ev.peersInUse, peersWanted: ev.peersWanted, txouts: ev.txouts, uptime: ev.uptime, syncFailing: ev.syncFailing, at: ev.ts };
1355
+ ls.peersWanted = ev.peersWanted;
1356
+ if (ev.syncFailing != null && ev.syncFailing > 0) this.flagQuality('sync-failing', `node reports sync_failing=${ev.syncFailing} in its heartbeat`, 'warn');
1357
+ else this.clearQuality('sync-failing');
1358
+ this.history.record('peers', { wanted: ev.peersWanted, in: ev.peersInUse, out: null, connections: this.state.peers.connections ?? null });
1359
+ return [];
1360
+ }
1361
+ case 'conn_budget':
1362
+ ls.connBudget = { max: ev.max, outbound: ev.outbound, fullRelay: ev.fullRelay, blockRelay: ev.blockRelay, feeler: ev.feeler, inboundCap: ev.inboundCap };
1363
+ return [ev];
1364
+ case 'tx_relay': {
1365
+ ls.relayRate = ev.relayRate ?? 0;
1366
+ ls.relayWindow = ev.windowSec;
1367
+ let peers = 0;
1368
+ for (const leg of ev.legs) {
1369
+ const cur = this.perPeerRelay.get(leg.host) ?? { accepted: 0, blocks: 0, first: ev.ts };
1370
+ cur.accepted += leg.accepted;
1371
+ cur.window = leg.accepted;
1372
+ cur.lastSeen = ev.ts;
1373
+ cur.leg = leg.leg;
1374
+ cur.addr = leg.addr;
1375
+ this.perPeerRelay.set(leg.host, cur);
1376
+ peers += 1;
1377
+ }
1378
+ ls.relayPeers = peers;
1379
+ this.history.record('txflow', {
1380
+ relayAccepted: ev.accepted, windowSec: ev.windowSec, orphansHeld: ls.orphans?.held ?? null,
1381
+ orphansParked: ls.orphans?.parked ?? null, accepted: ls.lastTxAccept?.accepted ?? null,
1382
+ rejectMissing: ls.lastTxAccept?.rejectMissingInputs ?? null, rejectPolicy: ls.lastTxAccept?.rejectPolicy ?? null,
1383
+ rejectInvalid: ls.lastTxAccept?.rejectInvalid ?? null, inFlight: ls.orphanDetail?.inFlight ?? null,
1384
+ });
1385
+ return [ev];
1386
+ }
1387
+ case 'tx_accept': {
1388
+ ls.lastTxAccept = ev;
1389
+ ls.acceptRate = ev.acceptRate;
1390
+ this.history.record('txflow', {
1391
+ accepted: ev.accepted, windowSec: ev.windowSec,
1392
+ rejectMissing: ev.rejectMissingInputs, rejectPolicy: ev.rejectPolicy, rejectInvalid: ev.rejectInvalid,
1393
+ alreadyConfirmed: ev.alreadyConfirmed, relayAccepted: ls.relayAccepted ?? null,
1394
+ orphansHeld: ls.orphans?.held ?? null, orphansParked: ls.orphans?.parked ?? null,
1395
+ inFlight: ls.orphanDetail?.inFlight ?? null,
1396
+ });
1397
+ this.history.record('mempool', {
1398
+ count: ev.mempool ?? null,
1399
+ acceptedDelta: ev.accepted ?? null,
1400
+ ingestRate: ev.acceptRate ?? null,
1401
+ rejectMissing: ev.rejectMissingInputs, rejectPolicy: ev.rejectPolicy, rejectInvalid: ev.rejectInvalid,
1402
+ bytes: this.state.mempool.bytes ?? null, usage: this.state.mempool.usage ?? null,
1403
+ maxUsage: this.state.mempool.maxmempool ?? null, totalFee: this.state.mempool.total_fee ?? null,
1404
+ minFee: this.state.mempool.mempoolminfee ?? null,
1405
+ });
1406
+ return [ev];
1407
+ }
1408
+ case 'orphans':
1409
+ ls.orphans = { held: ev.held, parked: ev.parked, resolved: ev.resolved, dropped: ev.dropped, oneP1C: ev.oneP1C };
1410
+ this.history.record('txflow', {
1411
+ orphansHeld: ev.held, orphansParked: ev.parked, orphansResolved: ev.resolved, orphansDropped: ev.dropped,
1412
+ oneP1C: ev.oneP1C?.accepted ?? null, oneP1CFailed: ev.oneP1C?.failed ?? null,
1413
+ });
1414
+ return [ev];
1415
+ case 'orphan_detail':
1416
+ ls.orphanDetail = ev;
1417
+ this.history.record('txflow', { inFlight: ev.inFlight, orphansDropped: (ls.orphans?.dropped ?? null), windowSec: null });
1418
+ return [ev];
1419
+ case 'mempool_block_drain':
1420
+ ls.lastDrain = { height: ev.height, removed: ev.removed, at: ev.ts };
1421
+ this.history.record('mempool', {
1422
+ confirmedDrain: ev.removed, count: this.state.mempool.size ?? null,
1423
+ bytes: this.state.mempool.bytes ?? null, usage: this.state.mempool.usage ?? null,
1424
+ maxUsage: this.state.mempool.maxmempool ?? null, totalFee: this.state.mempool.total_fee ?? null,
1425
+ minFee: this.state.mempool.mempoolminfee ?? null,
1426
+ });
1427
+ return [ev];
1428
+ case 'block_stored': {
1429
+ // This line is the only place that names WHICH peer handed us the block.
1430
+ if (ev.viaHost) {
1431
+ this.perPeerBlocks.set(ev.height, ev.viaHost);
1432
+ this.perPeerHostBlocks(ev.viaHost, ev.height);
1433
+ }
1434
+ const known = this.state.blocks.get(ev.height);
1435
+ if (known) {
1436
+ known.viaPeer = ev.viaHost ?? known.viaPeer;
1437
+ if (ev.bytes != null && known.size == null) known.size = ev.bytes;
1438
+ if (ev.txs != null && known.txs == null) known.txs = ev.txs;
1439
+ }
1440
+ return [ev];
1441
+ }
1442
+ case 'new_block':
1443
+ ls.lastNewBlock = { height: ev.height, at: ev.ts, jump: ev.jump };
1444
+ return [ev];
1445
+ case 'archive_hole':
1446
+ this.flagQuality('archive-hole', `block data is not laid out monotonically (first break at height ${ev.height}); the node refuses truncation and pruning above it`, 'warn');
1447
+ return [ev];
1448
+ case 'peer_connect':
1449
+ case 'peer_drop':
1450
+ case 'peer_unreachable':
1451
+ case 'feeler_dead': {
1452
+ this.peerEvents.unshift({ ...ev });
1453
+ if (this.peerEvents.length > 400) this.peerEvents.length = 400;
1454
+ if (ev.kind === 'peer_connect') this.perPeerConnect(ev);
1455
+ return [ev];
1456
+ }
1457
+ case 'peer_reject': {
1458
+ // 323 inbound `v2 handshake failed` lines arrived in half an hour on the
1459
+ // production node, all from 127.0.0.1. Forwarding those one by one would
1460
+ // push every real event out of the feed and say less: the finding is the
1461
+ // rate and the source, not the individual dropped socket.
1462
+ if (/handshake failed/i.test(ev.reason ?? '')) {
1463
+ const w = (this.handshakeFails ??= { count: 0, hosts: new Map(), firstAt: ev.ts, lastAt: ev.ts });
1464
+ w.count += 1;
1465
+ w.hosts.set(ev.host ?? '?', (w.hosts.get(ev.host ?? '?') ?? 0) + 1);
1466
+ w.lastAt = ev.ts;
1467
+ const [topHost, topN] = [...w.hosts.entries()].sort((a, b) => b[1] - a[1])[0] ?? ['?', 0];
1468
+ this.flagQuality('inbound-handshake-failing', `${w.count} inbound connection(s) failed the ${ev.transport ?? 'BIP324'} handshake and were dropped, ${topN} of them from ${topHost}; these peers never reach the protocol layer, so getpeerinfo cannot show them`, 'warn');
1469
+ return [];
1470
+ }
1471
+ this.peerEvents.unshift({ ...ev });
1472
+ if (this.peerEvents.length > 400) this.peerEvents.length = 400;
1473
+ return [ev];
1474
+ }
1475
+ case 'dial_attempt':
1476
+ // 52 in the sampled window. A count is the useful form.
1477
+ this.dialAttempts = (this.dialAttempts ?? 0) + 1;
1478
+ this.dialAttemptLast = ev.ts;
1479
+ return [];
1480
+ case 'network_note':
1481
+ // A host capability, not a peer event: it explains an ipv6 peer count of
1482
+ // zero better than the count does, and it is worth one line per boot.
1483
+ this.flagQuality('ipv6-unreachable', `the node reports no global IPv6 route on this host, so ipv6 peers are unreachable${ev.caveat ? ` (${ev.caveat})` : ''}; the ipv6 peer count is a host capability, not a node fault`, 'info');
1484
+ return [ev];
1485
+ case 'addr_gossip': {
1486
+ // Median 22 s apart on production: aggregate, never per-line.
1487
+ const g = (this.addrGossip ??= { added: 0, updates: 0, firstAt: ev.ts, lastAt: ev.ts });
1488
+ g.added += ev.added ?? 0;
1489
+ g.updates += 1;
1490
+ g.lastAt = ev.ts;
1491
+ this.history.record('peers', { gossipAdded: ev.added ?? null, connections: this.state.peers.connections ?? null });
1492
+ return [];
1493
+ }
1494
+ case 'dial_failures': {
1495
+ const d = (this.dialFails ??= { failed: 0, events: 0, reasons: new Map(), firstAt: ev.ts, lastAt: ev.ts });
1496
+ d.failed += ev.failed ?? 0;
1497
+ d.events += 1;
1498
+ d.lastAt = ev.ts;
1499
+ if (ev.reason) d.reasons.set(ev.reason, (d.reasons.get(ev.reason) ?? 0) + (ev.failed ?? 1));
1500
+ // A node that cannot fill its outbound slots is a connectivity fact, but 130
1501
+ // lines an hour would bury everything else, so it is a flag with a reason
1502
+ // breakdown rather than a stream of events.
1503
+ const [topReason, topN] = [...d.reasons.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
1504
+ if (d.failed >= 20)
1505
+ this.flagQuality('outbound-dial-failing', `${d.failed} outbound dial attempt(s) failed across ${d.events} top-up round(s); most common reason "${topReason}" (${topN}) -- the outbound slots may stay short, which the peer count alone would not explain`, 'warn');
1506
+ else this.clearQuality('outbound-dial-failing');
1507
+ return [];
1508
+ }
1509
+ case 'utxo_apply':
1510
+ // Validation throughput. Stored as its own figure and never averaged with
1511
+ // the download rate or the catch-up rate (rule 9).
1512
+ ls.utxoApply = { at: ev.ts, blocks: ev.blocks, height: ev.height, utxoCount: ev.utxoCount, secs: ev.secs, blocksPerSec: ev.blocksPerSec };
1513
+ this.history.record('node', { applyBlkPerSec: ev.blocksPerSec, applyHeight: ev.height, applyUtxoCount: ev.utxoCount });
1514
+ return [];
1515
+ case 'header_mirror':
1516
+ ls.headerMirror = { at: ev.ts, added: ev.added, headersNow: ev.headersNow, archiveTip: ev.archiveTip, gap: ev.gap };
1517
+ return [];
1518
+ case 'node_shutdown':
1519
+ // The RPC vanishes moments after this line, so without it a restart reads as
1520
+ // a network fault. The tip it recorded is also the answer to "did it lose
1521
+ // blocks when it went down".
1522
+ //
1523
+ // Age-gated on purpose: the tail backfill replays hours of history, and a
1524
+ // shutdown from four hours ago re-raising "the node is restarting" on a node
1525
+ // that has been up since is exactly the kind of stale certainty this project
1526
+ // exists to avoid. The event still reaches the feed, where its timestamp is
1527
+ // visible; the flag only speaks for a shutdown that is happening now.
1528
+ if (now - ev.ts < 600_000) {
1529
+ this.flagQuality('node-restarting', `the node began shutting down (signal ${ev.signal}) with tip ${ev.tipAtShutdown}${ev.outboundLegs != null ? ` and ${ev.outboundLegs} outbound leg(s)` : ''}; anything that looks offline after this is the restart, not a network fault`, 'warn');
1530
+ }
1531
+ // Nine restarts before 10:39 on 2026-09-08, eighteen shutdown lines in one
1532
+ // log, from a concurrent deploying session: each one produced a correct
1533
+ // alarm, and nine correct alarms in an hour is indistinguishable from a
1534
+ // broken monitor -- to the operator it read as "the monitor is flapping",
1535
+ // which is the opposite of what a true-but-noisy signal should produce.
1536
+ // The individual events stay in the feed; this says the shape out loud.
1537
+ this.restarts.push({ at: now, ts: ev.ts, signal: ev.signal ?? null, tip: ev.tipAtShutdown ?? null });
1538
+ if (this.restarts.length > 50) this.restarts.shift();
1539
+ this.noteRestartStorm();
1540
+ return [ev];
1541
+ // The 2026-09-08 bench build (v0.0.1, built 03:02) rewrote these lines ----
1542
+ // Whether one of these reaches the event feed is a deliberate split. The IBD
1543
+ // shapes arrive every ~10 s (in the 90-minute run measured: 455 progress,
1544
+ // 455 tick, 478 compaction lines), so they update state and history and stay
1545
+ // out of the feed -- a feed full of them pushes everything else off screen.
1546
+ // Only the rare shapes and the bad ones are returned as events.
1547
+ case 'dlc_progress': {
1548
+ // The node's OWN progress and its OWN eta: stored beside the monitor's
1549
+ // measured rate, never merged with it (rules 4 and 9), always labelled.
1550
+ ls.dlcProgress = {
1551
+ at: ev.ts, elapsedMs: ev.elapsedMs, nodeEtaMs: ev.nodeEtaMs,
1552
+ stored: ev.stored, storedOf: ev.storedOf, storedPct: ev.storedPct,
1553
+ inFlight: ev.inFlight, windowSize: ev.windowSize, throughHeight: ev.throughHeight,
1554
+ oldestGapSec: ev.oldestGapSec, landedPct: ev.landedPct, applied: ev.applied, appliedLag: ev.appliedLag,
1555
+ };
1556
+ this.history.record('node', {
1557
+ dlcStored: ev.stored, dlcStoredOf: ev.storedOf, dlcApplied: ev.applied, dlcAppliedLag: ev.appliedLag,
1558
+ dlcInFlight: ev.inFlight, dlcNodeEtaMs: ev.nodeEtaMs,
1559
+ });
1560
+ return [];
1561
+ }
1562
+ case 'catchup_progress':
1563
+ // A third rate, from the thread applying state rather than the one
1564
+ // downloading blocks. Stored separately for the same reason as above.
1565
+ ls.catchup = { at: ev.ts, height: ev.height, of: ev.of, pct: ev.pct, blkPerSec: ev.blkPerSec, avgBlkPerSec: ev.avgBlkPerSec, nodeEtaMs: ev.nodeEtaMs, msPerBlk: ev.msPerBlk, phases: ev.phases };
1566
+ this.history.record('node', { catchupHeight: ev.height, catchupBlkPerSec: ev.blkPerSec, catchupEtaMs: ev.nodeEtaMs });
1567
+ return [];
1568
+ case 'ibd_behind':
1569
+ ls.ibdBehind = { at: ev.ts, archiveHeight: ev.archiveHeight, announcedTip: ev.announcedTip, behind: ev.behind, workers: ev.workers };
1570
+ return [ev];
1571
+ case 'relay_paused':
1572
+ // Kept because it is the *reason* the per-peer panels are empty during
1573
+ // IBD, and it came from the node rather than from us guessing.
1574
+ ls.relayPaused = { at: ev.ts, reason: ev.reason, resumes: ev.resumes };
1575
+ return [ev];
1576
+ case 'utxo_compaction':
1577
+ ls.lastCompaction = { at: ev.ts, secs: ev.secs, runsMerged: ev.runsMerged, manifestFrom: ev.manifestFrom, manifestTo: ev.manifestTo, applyWaited: ev.applyWaited };
1578
+ return ev.applyWaited ? [ev] : [];
1579
+ case 'checklevel':
1580
+ ls.lastCheck = { at: ev.ts, level: ev.level, examined: ev.examined, holes: ev.holes, problems: ev.problems };
1581
+ if (ev.problems > 0) this.flagQuality('check-problems', `checklevel=${ev.level} over ${ev.blocks} block(s) [${ev.from}..${ev.to}] found ${ev.problems} problem(s) and ${ev.holes} hole(s)`, 'warn');
1582
+ else this.clearQuality('check-problems');
1583
+ return ev.problems > 0 ? [ev] : [];
1584
+ case 'peer_identify': {
1585
+ // Identity that neither build's getpeerinfo gives in full: the peer's own
1586
+ // height, user agent and protocol, per peer.
1587
+ const rec = this.peerRecord(ev.host, ev.addr, ev.ts);
1588
+ rec.userAgent = ev.userAgent;
1589
+ rec.proto = ev.proto;
1590
+ rec.peerHeight = ev.peerHeight;
1591
+ rec.direction = ev.direction;
1592
+ rec.services = ev.services;
1593
+ return [ev];
1594
+ }
1595
+ case 'peer_throughput': {
1596
+ // The only per-peer download rate this node publishes anywhere (see
1597
+ // MEASUREMENTS 3: getpeerinfo's byte counters sum to getnettotals).
1598
+ const rec = this.peerRecord(ev.host, ev.addr, ev.ts);
1599
+ rec.downBps = ev.rate;
1600
+ rec.blkPerSec = ev.blkPerSec;
1601
+ rec.blocksDownloaded = ev.blocks;
1602
+ rec.chunks = ev.chunks;
1603
+ rec.worker = ev.worker;
1604
+ if (ev.note) rec.note = ev.note;
1605
+ if (ev.banned) rec.banned = true;
1606
+ this.history.record('peers', {
1607
+ downBpsMax: ev.rate, connections: this.state.peers.connections ?? null,
1608
+ in: this.state.peers.in ?? null, out: this.state.peers.out ?? null,
1609
+ });
1610
+ // 256 healthy ticks per run stay out of the feed; an early-killed or
1611
+ // banned worker does not.
1612
+ return ev.banned || /early-kill/i.test(ev.note ?? '') ? [ev] : [];
1613
+ }
1614
+ case 'peer_speed': {
1615
+ const rec = this.peerRecord(ev.host, ev.addr, ev.ts);
1616
+ rec.rank = ev.rank;
1617
+ rec.rankRate = ev.rate;
1618
+ return [];
1619
+ }
1620
+ case 'worker_status':
1621
+ ls.workerStatus = { at: ev.ts, active: ev.active, total: ev.total };
1622
+ return ev.active < ev.total ? [ev] : [];
1623
+ case 'peer_drop_count':
1624
+ ls.nWitnessDrops = { at: ev.ts, dropped: ev.dropped, redialsSkipped: ev.redialsSkipped };
1625
+ return ev.dropped > 0 ? [ev] : [];
1626
+ case 'dl_connected':
1627
+ ls.dlConnected = { at: ev.ts, connected: ev.connected, wanted: ev.wanted };
1628
+ return [ev];
1629
+ case 'peer_discovery':
1630
+ ls.peerBook = { at: ev.ts, book: ev.book };
1631
+ return [ev];
1632
+ case 'peer_candidates':
1633
+ ls.peerCandidates = { at: ev.ts, candidates: ev.candidates };
1634
+ return [ev];
1635
+ case 'peer_live_probe':
1636
+ ls.peerLive = { at: ev.ts, live: ev.live, probeRounds: ev.probeRounds };
1637
+ return [ev];
1638
+ case 'headers_from':
1639
+ ls.lastHeadersFrom = { at: ev.ts, host: ev.host, headers: ev.headers, total: ev.total };
1640
+ return [];
1641
+ default:
1642
+ return [ev];
1643
+ }
1644
+ }
1645
+
1646
+ perPeerHostBlocks(host, height) {
1647
+ if (!host) return;
1648
+ const rec = this.perPeerRelay.get(host) ?? { accepted: 0, blocks: 0, first: Date.now() };
1649
+ rec.blocks = (rec.blocks ?? 0) + 1;
1650
+ rec.lastBlockAt = Date.now();
1651
+ rec.addr = rec.addr ?? host;
1652
+ this.perPeerRelay.set(host, rec);
1653
+ }
1654
+
1655
+ perPeerConnect(ev) {
1656
+ const host = ev.host ?? ev.addr;
1657
+ if (!host) return;
1658
+ const rec = this.perPeerRelay.get(host) ?? { accepted: 0, blocks: 0, first: Date.now() };
1659
+ rec.connectedAt = Date.now();
1660
+ rec.transport = ev.transport ?? rec.transport;
1661
+ rec.addr = ev.addr ?? rec.addr;
1662
+ this.perPeerRelay.set(host, rec);
1663
+ }
1664
+
1665
+ addEvent(ev) {
1666
+ // Every panel that reads the feed is RPC-derived or monitor-derived, and the
1667
+ // node's log is not a source the UI is allowed to cite. So events carry where
1668
+ // they came from, and /api/events serves only ours unless asked otherwise.
1669
+ const row = this.history.addEvent({ source: 'monitor', ...ev });
1670
+ this.emit('events', [row]);
1671
+ return row;
1672
+ }
1673
+
1674
+ /**
1675
+ * Restarts counted in a window, and named when they cluster.
1676
+ *
1677
+ * The window runs on the LOG's timestamps, not on arrival. The tail replays hours
1678
+ * of history at startup, and a census keyed on arrival time would read eighteen
1679
+ * archived shutdowns as eighteen restarts happening now -- the exact stale
1680
+ * certainty the age-gated `node-restarting` flag was written to avoid.
1681
+ *
1682
+ * Threshold: 3 shutdowns in 60 minutes, taken from the one sample that exists
1683
+ * (nine in ~4 h 18 m on 2026-09-08, about one every 29 minutes) -- roughly 2x the
1684
+ * longest gap measured between those restarts, so a deploy storm trips it and a
1685
+ * node that reboots twice a day does not. It is a floor for "someone is deploying
1686
+ * this box", not a claim about healthy-node behaviour.
1687
+ */
1688
+ noteRestartStorm(windowMs = 3_600_000, threshold = 3) {
1689
+ const now = Date.now();
1690
+ this.restarts = this.restarts.filter((r) => now - r.ts <= windowMs);
1691
+ if (this.restarts.length >= threshold) {
1692
+ const when = this.restarts.map((r) => new Date(r.ts).toISOString().slice(11, 19)).join(', ');
1693
+ this.flagQuality('node-restart-storm', `${this.restarts.length} restarts in the last ${Math.round(windowMs / 60000)} min (${when}); each shutdown line is correct, so the reading is "something is deploying this node", not "this monitor is flapping"`, 'warn');
1694
+ } else {
1695
+ this.clearQuality('node-restart-storm');
1696
+ }
1697
+ return this.restarts.length;
1698
+ }
1699
+
1700
+ /**
1701
+ * Name the log lines nothing parses.
1702
+ *
1703
+ * `log.health.ratio` says coverage fell; it does not say what arrived, which is
1704
+ * the difference between "go read 5,000 lines" and "the node started printing
1705
+ * `[migratetx]`". The tag is the first `[...]` on the line, kept verbatim, plus
1706
+ * one sample so a new node feature is identifiable from the dashboard. Lines with
1707
+ * no tag at all are counted under '(untagged)' rather than dropped -- an
1708
+ * untagged format change is exactly as much news as a new tag.
1709
+ */
1710
+ noteUnseenTag(ev, now) {
1711
+ const text = String(ev.text ?? '');
1712
+ const m = text.match(/^\s*\[([^\]]{1,24})\]/);
1713
+ const tag = m ? `[${m[1]}]` : '(untagged)';
1714
+ const rec = this.unseenTags.get(tag) ?? { tag, lines: 0, firstAt: ev.ts ?? now, lastAt: ev.ts ?? now, sample: text.slice(0, 160) };
1715
+ rec.lines += 1;
1716
+ rec.lastAt = Math.max(rec.lastAt, ev.ts ?? now);
1717
+ this.unseenTags.set(tag, rec);
1718
+ this.unseenLines += 1;
1719
+ if (this.unseenTags.size > 40) {
1720
+ // A log we understand nothing of is `log-unparsed`'s message, not 40 flags.
1721
+ const [oldest] = [...this.unseenTags.keys()];
1722
+ this.unseenTags.delete(oldest);
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Top unclaimed tags, and the flag that fires when one is clearly a feature.
1728
+ *
1729
+ * Gate: >= 25 lines of one tag unmatched, while the parser is otherwise working
1730
+ * (ratio above 0.5). The second condition is what separates "the node added a
1731
+ * subsystem" from "the node rewrote everything", which log-unparsed already
1732
+ * covers and covers better.
1733
+ */
1734
+ tagCensus({ armAt = 25 } = {}) {
1735
+ const top = [...this.unseenTags.values()]
1736
+ .sort((a, b) => b.lines - a.lines)
1737
+ .slice(0, 8)
1738
+ .map((r) => ({ tag: r.tag, lines: r.lines, firstAt: r.firstAt, lastAt: r.lastAt, sample: r.sample }));
1739
+ const ratio = this.logHealthStats?.ratio ?? null;
1740
+ for (const t of top) {
1741
+ if (t.lines >= armAt && (ratio == null || ratio >= 0.5)) {
1742
+ this.flagQuality('log-new-tag', `${t.tag} has ${t.lines} line(s) no rule claims (sample: "${t.sample}"), while the rest of the log still parses (${ratio == null ? 'ratio not yet measured' : `${Math.round(ratio * 100)}% claimed`}) -- this looks like a node feature the monitor has not been taught to read`, 'warn');
1743
+ break; // one flag names the biggest; the table shows the rest
1744
+ }
1745
+ }
1746
+ if (!top.some((t) => t.lines >= armAt && (ratio == null || ratio >= 0.5))) this.clearQuality('log-new-tag');
1747
+ return { unclaimed: this.unseenLines, tags: top };
1748
+ }
1749
+
1750
+ flagQuality(key, text, severity = 'info') {
1751
+ const existing = this.quality.find((q) => q.key === key);
1752
+ const now = Date.now();
1753
+ // Update in place without re-logging: a heartbeat flag refires every minute,
1754
+ // and six identical WARN lines an hour makes the log useless for spotting the
1755
+ // one that is new.
1756
+ if (existing) { existing.at = now; existing.text = text; existing.severity = severity; return; }
1757
+ this.quality.push({ key, text, severity, at: now });
1758
+ this.log({ level: severity === 'warn' ? 'warn' : 'debug', msg: `[quality:${key}] ${text}` });
1759
+ }
1760
+
1761
+ clearQuality(key) {
1762
+ const i = this.quality.findIndex((q) => q.key === key);
1763
+ if (i >= 0) this.quality.splice(i, 1);
1764
+ }
1765
+
1766
+ recordRpc() {
1767
+ const t = this.rpc.telemetry();
1768
+ const slowAt = this.rpc.cfg.slowLatencyMs ?? 5000;
1769
+ if (t.failedCalls) {
1770
+ this.flagQuality('rpc-timeouts', `${t.failedCalls} RPC attempt(s) failed outright, most recently after ${t.lastLatencyMs ?? '?'}ms; ${this.indexBuild ? 'the address index build on this machine is competing for the disk (it pauses while the node is slow)' : 'the node is under load'}, so panels may lag or show no data`, 'warn');
1771
+ }
1772
+ if ((t.avgLatencyMs ?? 0) > slowAt) {
1773
+ this.flagQuality('rpc-slow', `the node's RPC is answering in ~${(t.avgLatencyMs / 1000).toFixed(1)}s (the lane this monitor gives it allows ${this.rpc.cfg.maxInFlight} call(s) in flight)${this.indexBuild ? ' -- the address index build on this machine is competing for the disk and pauses while this lasts' : ''}, so polling has slowed itself down rather than queueing up`, 'warn');
1774
+ } else {
1775
+ this.clearQuality('rpc-slow');
1776
+ }
1777
+ this.history.record('rpc', {
1778
+ latencyMs: t.lastLatencyMs, avgLatencyMs: t.avgLatencyMs, ratePerSec: t.ratePerSec,
1779
+ queued: t.queued, errors: t.errors, breakerTrips: t.breakerTrips, busyMsPerSec: t.busyMsPerSec,
1780
+ });
1781
+ return t;
1782
+ }
1783
+
1784
+ // ---------------------------------------------------------- read model
1785
+
1786
+ snapshot({ seriesRanges = null } = {}) {
1787
+ const s = this.state;
1788
+ const blocks = [...s.blocks.values()].sort((a, b) => a.height - b.height);
1789
+ const recent = blocks.slice(-120);
1790
+ const gaps = recent.map((b) => b.gapSec).filter((g) => g != null && g > 0 && g < 7200);
1791
+ const avgGap = gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : null;
1792
+ const diff = s.chainInfo?.difficulty ?? s.mining?.difficulty ?? null;
1793
+ // HASHES PER SECOND, not difficulty per second (2026-09-12, operator: "Why is difficulty and
1794
+ // work showing 0 EH/s"). A difficulty-1 target expects 2^32 hashes, so the network's rate is
1795
+ // difficulty * 2^32 / seconds-per-block. Dividing difficulty by the gap alone is out by that
1796
+ // factor of 4.29 billion: it put 1112 EH/s on screen as "0.0 EH/s", and the test that should
1797
+ // have caught it asserted the wrong magnitude was "of the right order".
1798
+ // Checked against the node's own getnetworkhashps at the time -- 1.0979e21 H/s reported
1799
+ // against 1.112e21 estimated here, agreeing to about one per cent, which is what says this
1800
+ // constant is the right one rather than merely a bigger one.
1801
+ const hashrateRaw = diff != null && avgGap ? (diff * 2 ** 32) / avgGap : null;
1802
+ // "Network hashrate" here is difficulty divided by the mean gap between blocks
1803
+ // WE have. Far behind the tip that is not an estimate of anything: during IBD the
1804
+ // node applies hundreds of blocks per second, so the gap is milliseconds and the
1805
+ // "hashrate" comes out at thousands of EH/s. The number is not wrong, it is
1806
+ // answering a different question, and there is no honest way to label it. So it
1807
+ // is withheld during IBD with the reason, rather than drawn large and orange.
1808
+ const behindHeaders = s.chainInfo?.headers != null && s.chainInfo?.blocks != null
1809
+ ? s.chainInfo.headers - s.chainInfo.blocks : null;
1810
+ const ibdFlag = s.chainInfo?.initialblockdownload ?? null;
1811
+ const hashrateSuppressed = hashrateRaw == null ? null
1812
+ : ibdFlag === true ? 'initial block download: the node is applying blocks faster than the network produced them, so difficulty / observed gap measures the apply rate, not the network'
1813
+ : (behindHeaders != null && behindHeaders > 6) ? `the node is ${behindHeaders} block(s) behind its own headers, so the observed gap is not the network's`
1814
+ : null;
1815
+ const hashrate = hashrateSuppressed ? null : hashrateRaw;
1816
+ const mi = s.mempool;
1817
+ const usagePct = mi?.usage != null && mi?.maxmempool ? (mi.usage / mi.maxmempool) * 100 : null;
1818
+
1819
+ const out = {
1820
+ id: s.id,
1821
+ label: s.label,
1822
+ color: s.color,
1823
+ online: this.rpc.telemetry().online,
1824
+ chain: s.chain,
1825
+ ibd: s.chainInfo?.initialblockdownload ?? null,
1826
+ tip: {
1827
+ height: s.chainInfo?.blocks ?? null,
1828
+ headers: s.chainInfo?.headers ?? null,
1829
+ hash: s.chainInfo?.bestblockhash ?? null,
1830
+ time: s.chainInfo?.time ?? null,
1831
+ mediantime: s.chainInfo?.mediantime ?? null,
1832
+ ageSec: s.chainInfo?.time ? Math.max(0, Math.floor(Date.now() / 1000) - s.chainInfo.time) : null,
1833
+ behindHeaders: s.chainInfo?.headers != null && s.chainInfo?.blocks != null ? s.chainInfo.headers - s.chainInfo.blocks : null,
1834
+ },
1835
+ // The sync bar's whole data contract. See collect/sync.js for why the two
1836
+ // percentages stay apart instead of being merged into one number.
1837
+ //
1838
+ // The node's own label and endpoint are carried INTO the sync object, not
1839
+ // left beside it: "Synced" without saying which node is how a monitoring
1840
+ // tool manages to look wrong while reporting the truth.
1841
+ sync: computeSync({
1842
+ blocks: s.chainInfo?.blocks ?? null,
1843
+ headers: s.chainInfo?.headers ?? null,
1844
+ ibd: s.chainInfo?.initialblockdownload ?? null,
1845
+ verificationProgress: s.chainInfo?.verificationprogress ?? null,
1846
+ tipTime: s.chainInfo?.time ?? null,
1847
+ bestHash: s.chainInfo?.bestblockhash ?? null,
1848
+ sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
1849
+ chain: s.chain,
1850
+ warnings: s.chainInfo?.warnings ?? [],
1851
+ blockRatePerSec: this.blockRateFast.rate(),
1852
+ blockRateFastSpanMs: this.blockRateFast.span,
1853
+ blockRateSlowPerSec: this.blockRate.rate(),
1854
+ blockRateSlowSpanMs: this.blockRate.span,
1855
+ avgBlockGapSec: avgGap,
1856
+ reorgEvents: this.reorgEvents,
1857
+ reorgAt: this.reorgAt,
1858
+ peers: s.peers.connections ?? null,
1859
+ // the highest tip any peer reports: what tells a long gap from a stalled node
1860
+ peerBestHeight: (() => { const hs = (s.peers.list ?? []).map((p) => (Number.isFinite(p.synced_headers) && p.synced_headers >= 0 ? p.synced_headers : Number.isFinite(p.startingheight) ? p.startingheight : -1)).filter((h) => h >= 0); return hs.length ? Math.max(...hs) : null; })(),
1861
+ txouts: s.utxo?.txouts ?? s.logState.heartbeat?.txouts ?? null,
1862
+ difficulty: diff,
1863
+ reason: s.methodErrors?.getblockchaininfo
1864
+ ?? s.lastError?.message
1865
+ ?? this.rpc.telemetry().lastError?.message
1866
+ ?? null,
1867
+ }),
1868
+ progress: s.chainInfo?.verificationprogress ?? null,
1869
+ warnings: s.chainInfo?.warnings ?? [],
1870
+ difficulty: diff,
1871
+ hashrateEstEh: hashrate == null ? null : hashrate / 1e18,
1872
+ // Why there is no hashrate, when there is none. The figure is withheld rather
1873
+ // than shown-but-wrong, and the reason is part of the payload so the UI cannot
1874
+ // render `–` and leave the question open (rule 3).
1875
+ hashrateNote: hashrateSuppressed ?? (hashrateRaw == null ? 'difficulty or a block-gap sample is missing' : null),
1876
+ avgBlockGapSec: avgGap,
1877
+ sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
1878
+ pruned: s.chainInfo?.pruned ?? null,
1879
+ chainwork: s.chainInfo?.chainwork ?? null,
1880
+ uptimeSec: s.uptimeSec ?? null,
1881
+ network: s.networkInfo ? {
1882
+ version: s.networkInfo.version,
1883
+ subversion: s.networkInfo.subversion,
1884
+ protocol: s.networkInfo.protocolversion,
1885
+ services: s.networkInfo.localservices,
1886
+ servicesNames: s.networkInfo.localservicesnames ?? [],
1887
+ networkactive: s.networkInfo.networkactive,
1888
+ relayfee: s.networkInfo.relayfee,
1889
+ incrementalfee: s.networkInfo.incrementalfee,
1890
+ networks: s.networkInfo.networks ?? [],
1891
+ localaddresses: s.networkInfo.localaddresses ?? [],
1892
+ } : null,
1893
+ mempool: {
1894
+ loaded: mi.loaded ?? null,
1895
+ count: mi.size ?? null,
1896
+ bytes: mi.bytes ?? null,
1897
+ usage: mi.usage ?? null,
1898
+ maxUsage: mi.maxmempool ?? null,
1899
+ usagePct,
1900
+ totalFee: mi.total_fee ?? null,
1901
+ minFee: mi.mempoolminfee ?? null,
1902
+ minRelayFee: mi.minrelaytxfee ?? null,
1903
+ incrementalRelayFee: mi.incrementalrelayfee ?? null,
1904
+ unbroadcast: mi.unbroadcastcount ?? null,
1905
+ maxDataCarrier: mi.maxdatacarriersize ?? null,
1906
+ permitBareMultisig: mi.permitbaremultisig ?? null,
1907
+ ingestRate: s.logState.relayRate ?? s.logState.acceptRate ?? null,
1908
+ acceptWindow: s.logState.lastTxAccept?.windowSec ?? null,
1909
+ rejects: s.logState.lastTxAccept ? {
1910
+ missingInputs: s.logState.lastTxAccept.rejectMissingInputs,
1911
+ policy: s.logState.lastTxAccept.rejectPolicy,
1912
+ invalid: s.logState.lastTxAccept.rejectInvalid,
1913
+ alreadyConfirmed: s.logState.lastTxAccept.alreadyConfirmed,
1914
+ windowSec: s.logState.lastTxAccept.windowSec,
1915
+ } : null,
1916
+ lastDrain: s.logState.lastDrain ?? null,
1917
+ // Aggregates only. The age/feerate point cloud is a chart dataset -- 1500
1918
+ // points does not belong in a frame pushed once a second, so it is served
1919
+ // by /api/mempool and refreshed on the mempool panel's own cadence.
1920
+ dist: s.mempoolDist ? {
1921
+ ...s.mempoolDist,
1922
+ scatter: undefined,
1923
+ scatterPoints: s.mempoolDist.scatter ? s.mempoolDist.scatter.length : 0,
1924
+ } : null,
1925
+ },
1926
+ peers: {
1927
+ connections: s.peers.connections ?? s.networkInfo?.connections ?? null,
1928
+ in: s.peers.in ?? null,
1929
+ out: s.peers.out ?? null,
1930
+ wanted: s.logState.peersWanted ?? s.logState.heartbeat?.peersWanted ?? null,
1931
+ budget: s.logState.connBudget ?? null,
1932
+ banned: s.logState.banned ?? null,
1933
+ bannedOf: s.logState.bannedOf ?? null,
1934
+ ranking: s.logState.ranking ?? null,
1935
+ rpcRows: s.peers.list.length,
1936
+ rpcRowsUpdatedAt: s.peers.listUpdatedAt,
1937
+ addrGossip: this.addrGossip ? { added: this.addrGossip.added, updates: this.addrGossip.updates, lastAt: this.addrGossip.lastAt } : null,
1938
+ dialFailures: this.dialFails
1939
+ ? {
1940
+ failed: this.dialFails.failed, rounds: this.dialFails.events, lastAt: this.dialFails.lastAt,
1941
+ reasons: [...this.dialFails.reasons.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
1942
+ .map(([reason, n]) => ({ reason, count: n })),
1943
+ }
1944
+ : null,
1945
+ dialAttempts: this.dialAttempts ?? 0,
1946
+ // The RPC's own account of the peer set, kept separate from the log's
1947
+ // because on the deployed build one of the two is empty and the other
1948
+ // does not exist. `byteCoverage` is how much of getnettotals' received
1949
+ // bytes the listed peers actually account for (70.29% measured).
1950
+ addrman: s.peers.addrman ?? null,
1951
+ addrmanUpdatedAt: s.peers.addrmanUpdatedAt ?? null,
1952
+ banTable: s.peers.banTable ?? null,
1953
+ banTableNote: s.peers.banTableNote ?? null,
1954
+ byteCoverage: s.peers.byteCoverage ?? null,
1955
+ // Inbound connections that died in the handshake never exist as far as RPC
1956
+ // is concerned -- getpeerinfo cannot show a peer it never negotiated with.
1957
+ // Aggregated because 323 of them arrived in 30 minutes.
1958
+ handshakeFailures: this.handshakeFails
1959
+ ? {
1960
+ count: this.handshakeFails.count,
1961
+ hosts: [...this.handshakeFails.hosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
1962
+ .map(([host, n]) => ({ host, count: n })),
1963
+ firstAt: this.handshakeFails.firstAt,
1964
+ lastAt: this.handshakeFails.lastAt,
1965
+ }
1966
+ : null,
1967
+ // Which source backs the peer table is a first-class fact here, not a
1968
+ // footnote: see the peerinfo-empty quality flag.
1969
+ identitySource: s.peers.list.length ? 'getpeerinfo' : 'node log (relay legs, block serving, connects/drops)',
1970
+ activity: [...this.perPeerRelay.entries()]
1971
+ .map(([host, r]) => ({ host, addr: r.addr ?? host, relayAccepted: r.accepted ?? 0, relayWindow: r.window ?? 0, blocksServed: r.blocks ?? 0, lastSeen: r.lastSeen ?? null, connectedAt: r.connectedAt ?? null, transport: r.transport ?? null, leg: r.leg ?? null,
1972
+ // Only the bench build prints these; absent means the node never
1973
+ // named a rate for this peer, which the UI renders as `–`.
1974
+ downBps: r.downBps ?? null, blkPerSec: r.blkPerSec ?? null, blocksDownloaded: r.blocksDownloaded ?? null,
1975
+ chunks: r.chunks ?? null, worker: r.worker ?? null, userAgent: r.userAgent ?? null,
1976
+ peerHeight: r.peerHeight ?? null, rank: r.rank ?? null, note: r.note ?? null, banned: r.banned ?? false }))
1977
+ .sort((a, b) => (b.relayAccepted + b.blocksServed) - (a.relayAccepted + a.blocksServed)),
1978
+ // Throughput, when the build prints it. On the production build there is no
1979
+ // per-peer rate line at all, so this stays an empty list rather than a
1980
+ // table of zeros.
1981
+ // Stated per mode. With the tail off, printing this string is a provenance
1982
+ // lie: it names a source the process refused to open, and there is no RPC
1983
+ // source to fall back to — neither build publishes a per-peer download rate
1984
+ // in getpeerinfo or getnettotals (measured 2026-09-08: [] rows / 0 bytes).
1985
+ throughputSource: this.logEnabled
1986
+ ? 'node log [dlc] worker lines (per-peer download rate; not in getpeerinfo or getnettotals)'
1987
+ : 'none — this figure exists only in the node log, and the log source is off',
1988
+ identity: [...this.perPeerRelay.values()].some((r) => r.userAgent)
1989
+ ? [...this.perPeerRelay.entries()].filter(([, r]) => r.userAgent).map(([host, r]) => ({ host, addr: r.addr ?? host, userAgent: r.userAgent, proto: r.proto ?? null, peerHeight: r.peerHeight ?? null, direction: r.direction ?? null }))
1990
+ : null,
1991
+ recentEvents: this.peerEvents.slice(0, 40),
1992
+ },
1993
+ net: {
1994
+ totalRecvRpc: s.net.totalRecv,
1995
+ totalSentRpc: s.net.totalSent,
1996
+ // A counter that has read 0 for the whole uptime is not a measurement of zero
1997
+ // traffic — it is the download worker's counters living in another process
1998
+ // (measured on the deployed build: totalbytesrecv 0 while the same node's log
1999
+ // moved ~47 GB). 0 B/s on a chart reads as "an idle node", which is a claim
2000
+ // about the network, not an absence. Symmetric with uploadMeasured below, and
2001
+ // the log rate still wins when the tail is on, because it is a real number.
2002
+ inBps: s.logState.inBps ?? ((s.net.totalRecv ?? 0) > 0 ? s.net.inBps : null),
2003
+ downloadMeasured: s.logState.inBps != null
2004
+ || (s.net.totalRecv != null && s.net.totalRecv > 0 && s.net.inBps != null),
2005
+ outBps: s.net.outBps ?? null,
2006
+ netTotalLog: s.net.logNetTotal ?? null,
2007
+ diskWriteBps: s.logState.diskWriteBps ?? null,
2008
+ diskTotal: s.logState.diskTotal ?? null,
2009
+ avgRecv: s.logState.avgRecv ?? null,
2010
+ avgWrite: s.logState.avgWrite ?? null,
2011
+ floor: s.logState.floorBps ?? null,
2012
+ poolMedian: s.logState.poolMedianBps ?? null,
2013
+ // The bench build prints `(median 499.6)` with no unit. Kept as the text
2014
+ // the node printed and never converted: if it is KB/s it is 1000x the
2015
+ // alternative reading, and the log does not say which. Rule 3.
2016
+ poolMedianText: s.logState.poolMedianText ?? null,
2017
+ workerCounters: s.logState.workerCounters ?? null,
2018
+ uploadtarget: s.net.uploadtarget ?? null,
2019
+ // Explicit: the node reports no usable sent-byte counter in this build (see
2020
+ // upload-unmeasurable), so the UI must not imply an upload figure it does not
2021
+ // have. `outBps === null` is the honest state, and it is now reachable both
2022
+ // when the counter is zero and when it is implausibly small.
2023
+ uploadMeasured: s.net.totalSent != null && s.net.totalSent > 0 && s.net.outBps != null,
2024
+ },
2025
+ attribution: this.miningView(),
2026
+ network: this.network.view(),
2027
+ blocks: {
2028
+ count: blocks.length,
2029
+ // 40 in the live frame; /api/blocks?limit= serves up to 400 for the chart.
2030
+ recent: recent.slice(-40).reverse(),
2031
+ backfilled: blocks.length > 0,
2032
+ reorgs: this.reorgEvents,
2033
+ },
2034
+ fees: s.fees,
2035
+ mining: s.mining,
2036
+ utxo: s.utxo,
2037
+ chaintxstats: s.chaintxstats ?? null,
2038
+ indexes: s.indexes,
2039
+ tips: s.tips,
2040
+ deployments: s.deployments,
2041
+ rpcInfo: s.rpcInfo,
2042
+ log: {
2043
+ ...(this.tail ? this.tail.status() : { exists: false, file: null }),
2044
+ // 'disabled' is a configuration decision; 'missing' would be a fault. The
2045
+ // UI must not render the two the same way.
2046
+ source: this.logEnabled ? 'file' : 'disabled',
2047
+ lastEventAt: s.logState.lastEventAt ?? null,
2048
+ heartbeat: s.logState.heartbeat ?? null,
2049
+ orphans: s.logState.orphans ?? null,
2050
+ orphanDetail: s.logState.orphanDetail ?? null,
2051
+ connBudget: s.logState.connBudget ?? null,
2052
+ backfilled: this.logBackfilled,
2053
+ // What the log-health timer concluded, not what we hope. `ratio` is the
2054
+ // share of lines a parser actually claimed over the last window, and
2055
+ // `quietMs` is how long the file has produced nothing against
2056
+ // `staleAfterMs` -- the two numbers that separate an idle node from a
2057
+ // misconfigured tail.
2058
+ health: this.logHealthStats,
2059
+ // Which lines nothing claims, by tag, with a sample. A ratio says coverage
2060
+ // fell; this says what turned up instead.
2061
+ unclaimed: this.tagCensus(),
2062
+ blockMap: {
2063
+ size: s.blocks.size,
2064
+ cap: this.blockMapCap,
2065
+ evicted: this.blockMapEvicted,
2066
+ oldestHeight: blocks.length ? blocks[0].height : null,
2067
+ },
2068
+ // Fields the node started printing that no rule claims yet, kept verbatim.
2069
+ // `staged` and `commit` arrived here on 2026-09-08.
2070
+ unrecognisedTickFields: s.logState.tickExtraFields ?? null,
2071
+ ibd: {
2072
+ // Three independent progress figures, deliberately not merged: ours
2073
+ // (blocks/headers + measured rate), the download worker's own
2074
+ // (dlcProgress), the applying thread's own (catchup). Rules 4 and 9.
2075
+ nodeProgress: s.logState.dlcProgress ?? null,
2076
+ nodeCatchup: s.logState.catchup ?? null,
2077
+ behind: s.logState.ibdBehind ?? null,
2078
+ relayPaused: s.logState.relayPaused ?? null,
2079
+ workerStatus: s.logState.workerStatus ?? null,
2080
+ lastCompaction: s.logState.lastCompaction ?? null,
2081
+ lastCheck: s.logState.lastCheck ?? null,
2082
+ // Validation throughput: a third rate, kept apart from the download rate and
2083
+ // the catch-up rate above (rule 9).
2084
+ applyRate: s.logState.utxoApply ?? null,
2085
+ headerMirror: s.logState.headerMirror ?? null,
2086
+ },
2087
+ },
2088
+ health: {
2089
+ rpc: this.rpc.telemetry(),
2090
+ // Which method opened the breaker, and what it is blocking. The policy is
2091
+ // deliberately still all-or-nothing per node (blocking everything for 30 s
2092
+ // after 3 consecutive failures is what protects a single-threaded node from
2093
+ // a client that will not stop asking); what was missing was the ability to
2094
+ // answer "who did this" when it flaps, which is what turned a 2026-09-08
2095
+ // investigation into guesswork. Per-tier breakers stay unwritten until the
2096
+ // data says the shared one hurts -- see docs/DEFECTS.md.
2097
+ // Configured vs actual, with the reason -- a stretched cadence must never
2098
+ // look like a broken poller.
2099
+ cadence: Object.fromEntries(Object.keys(this.poll)
2100
+ .filter((k) => k.endsWith('Ms'))
2101
+ .map((k) => {
2102
+ // The tier name is the key WITHOUT the "Ms" suffix -- tierIntervalMs is
2103
+ // written as ['fast'], so looking it up under 'fastMs' silently returned
2104
+ // the configured value and the panel reported an unstretched cadence
2105
+ // while the poller was in fact stretching. Found against the live node.
2106
+ const tier = k.slice(0, -2);
2107
+ const effective = this.tierIntervalMs[tier] ?? this.poll[k];
2108
+ return [tier, {
2109
+ configuredMs: this.poll[k],
2110
+ effectiveMs: effective,
2111
+ stretched: effective > this.poll[k],
2112
+ lastRunMs: this.lastTierMs[tier] ?? null,
2113
+ }];
2114
+ })),
2115
+ cadenceStretched: Object.keys(this.poll).some((k) => k.endsWith('Ms') && (this.tierIntervalMs[k.slice(0, -2)] ?? 0) > this.poll[k]),
2116
+ staleDrops: this.staleDrops ?? 0,
2117
+ heavyTierSkips: this.skippedHeavy ?? 0,
2118
+ lastError: s.lastError,
2119
+ lastGoodAt: s.lastGoodAt,
2120
+ tiers: s.tierRunAt,
2121
+ lastTier: this.tierStats ?? null,
2122
+ inFlightTiers: [...this.inflightTiers],
2123
+ quality: this.quality.slice(),
2124
+ },
2125
+ series: this.seriesView(seriesRanges),
2126
+ };
2127
+ // Identity lives inside `sync` rather than beside it, so no renderer can draw
2128
+ // the bar without drawing which node it belongs to.
2129
+ out.sync.node = s.id;
2130
+ out.sync.nodeLabel = s.label;
2131
+ out.sync.endpoint = this.cfg.rpcUrl;
2132
+ // One dense row for every state; `strip` is the ordered facts to draw in it.
2133
+ out.sync.strip = stripFacts(out.sync);
2134
+ return out;
2135
+ }
2136
+
2137
+ // An explicitly empty `ranges` means "no series please". The SSE snapshot uses
2138
+ // that: chart history changes slowly, and re-sending every series on every
2139
+ // 1-second snapshot was most of the payload for almost no benefit.
2140
+ seriesView(ranges) {
2141
+ // Three cases, and the null one is the common one: undefined/null means
2142
+ // "use the standard windows", an empty object means "give me none" (the SSE
2143
+ // snapshot path), and a populated object overrides specific ranges.
2144
+ if (ranges && typeof ranges === 'object' && !Object.keys(ranges).length) return {};
2145
+ const R = { hour: 3600_000, hours6: 6 * 3600_000, day: 86400_000, ...(ranges ?? {}) };
2146
+ const now = Date.now();
2147
+ const out = {};
2148
+ const want = (name, spec) => {
2149
+ const r = this.history.ring(name);
2150
+ out[name] = Object.fromEntries(Object.entries(spec).map(([key, { field, agg, range, points }]) => [
2151
+ key,
2152
+ r.series(field, { since: now - (range ?? 3600_000), bucketMs: bucketFor(range ?? 3600_000, points), agg: agg ?? 'last' }),
2153
+ ]));
2154
+ };
2155
+ want('mempool', {
2156
+ hour: { field: 'count', range: R.hour },
2157
+ hours6: { field: 'count', range: R.hours6 },
2158
+ day: { field: 'count', range: R.day },
2159
+ bytesHour: { field: 'bytes', range: 3600_000 },
2160
+ usageHour: { field: 'usage', range: 3600_000 },
2161
+ feeHour: { field: 'totalFee', range: 3600_000, agg: 'last' },
2162
+ ingestHour: { field: 'ingestRate', range: 3600_000 },
2163
+ });
2164
+ want('net', {
2165
+ inHour: { field: 'inBps', range: 3600_000 },
2166
+ outHour: { field: 'outBps', range: 3600_000 },
2167
+ diskHour: { field: 'diskWriteBps', range: 3600_000 },
2168
+ inDay: { field: 'inBps', range: 86400_000 },
2169
+ });
2170
+ want('fees', {
2171
+ f1: { field: 'f1', range: 86400_000 },
2172
+ f2: { field: 'f2', range: 86400_000 },
2173
+ f6: { field: 'f6', range: 86400_000 },
2174
+ f24: { field: 'f24', range: 86400_000 },
2175
+ f144: { field: 'f144', range: 86400_000 },
2176
+ min: { field: 'mempoolmin', range: 86400_000 },
2177
+ });
2178
+ want('blocks', {
2179
+ fee: { field: 'totalfee', range: 86400_000, agg: 'last', points: 300 },
2180
+ size: { field: 'size', range: 86400_000, agg: 'last', points: 300 },
2181
+ txs: { field: 'txs', range: 86400_000, agg: 'last', points: 300 },
2182
+ gap: { field: 'gapSec', range: 86400_000, agg: 'last', points: 300 },
2183
+ p1: { field: 'p1', range: 86400_000, agg: 'last', points: 300 },
2184
+ p2: { field: 'p2', range: 86400_000, agg: 'last', points: 300 },
2185
+ p3: { field: 'p3', range: 86400_000, agg: 'last', points: 300 },
2186
+ });
2187
+ want('peers', {
2188
+ connections: { field: 'connections', range: 86400_000 },
2189
+ in: { field: 'in', range: 86400_000 },
2190
+ out: { field: 'out', range: 86400_000 },
2191
+ relay: { field: 'txRelayPeers', range: 86400_000 },
2192
+ });
2193
+ want('node', {
2194
+ tip: { field: 'blocks', range: 86400_000, agg: 'last' },
2195
+ difficulty: { field: 'difficulty', range: 86400_000 * 7, agg: 'avg', points: 200 },
2196
+ txRate: { field: 'txRate', range: 86400_000, agg: 'avg' },
2197
+ txouts: { field: 'txouts', range: 86400_000 * 7, agg: 'avg', points: 200 },
2198
+ disk: { field: 'sizeOnDisk', range: 86400_000, agg: 'last' },
2199
+ });
2200
+ want('txflow', {
2201
+ accepted: { field: 'accepted', range: 3600_000, agg: 'avg' },
2202
+ rejectPolicy: { field: 'rejectPolicy', range: 3600_000, agg: 'avg' },
2203
+ orphansHeld: { field: 'orphansHeld', range: 86400_000, agg: 'last' },
2204
+ orphansParked: { field: 'orphansParked', range: 86400_000, agg: 'last' },
2205
+ inFlight: { field: 'inFlight', range: 86400_000, agg: 'last' },
2206
+ });
2207
+ want('rpc', {
2208
+ latency: { field: 'latencyMs', range: 3600_000 },
2209
+ rate: { field: 'ratePerSec', range: 3600_000 },
2210
+ });
2211
+ return out;
2212
+ }
2213
+
2214
+ async stop() {
2215
+ this.stopped = true;
2216
+ this.network?.stop();
2217
+ for (const t of this.tierTimers.values()) clearTimeout(t);
2218
+ if (this.logHealthTimer) clearInterval(this.logHealthTimer);
2219
+ if (this.tail) await this.tail.stop();
2220
+ }
2221
+ }
2222
+
2223
+ function bucketFor(rangeMs, points = 240) {
2224
+ return Math.max(1000, Math.round(rangeMs / points / 1000) * 1000);
2225
+ }
2226
+
2227
+ function range(from, to) {
2228
+ const out = [];
2229
+ for (let h = from; h <= to; h++) out.push(h);
2230
+ return out;
2231
+ }
2232
+
2233
+ function chunkBy(arr, n) {
2234
+ const out = [];
2235
+ for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
2236
+ return out;
2237
+ }
2238
+
2239
+ // Turn the verbose mempool map into aggregates. Everything the feerate panel
2240
+ // shows is derived here so the browser never receives 4,700 transactions.
2241
+ //
2242
+ // Field note (verified against the live node): this node's entries carry
2243
+ // vsize, weight, time and fees.base only -- no depends, no ancestorcount, no
2244
+ // modifiedfees, no prioritisefee. Anything needing those is reported absent
2245
+ // rather than inferred.
2246
+ // THE DENSE BLOCK (Viewer Mode 2; operator, 2026-09-11, with mempool.space's Goggles beside ours:
2247
+ // "The official mempool goggles seem to have so much more detail ... improve information density").
2248
+ // Mode 1 is fed the richest 400 transactions and one aggregate for the rest, which it cuts into
2249
+ // ~100 equal squares -- the uniform field across the top of our board. This is the next block's
2250
+ // worth of the pool, richest first, EVERY transaction: sizes, feerates and the full txid (for the hover, and a click into the explorer)
2251
+ // (for the hover), about 3-5k entries and ~100 KB, served on its own endpoint and kept off the
2252
+ // 1 Hz snapshot. Transactions with no fee reported cannot be ranked and are left out, counted.
2253
+ export function denseBlock(raw, { blockVsize = 1_000_000 } = {}) {
2254
+ const list = [];
2255
+ let unranked = 0;
2256
+ for (const [txid, e] of Object.entries(raw ?? {})) {
2257
+ const vsize = typeof e?.vsize === 'number' ? e.vsize : (typeof e?.weight === 'number' ? Math.ceil(e.weight / 4) : 0);
2258
+ const fee = e?.fees && typeof e.fees.base === 'number' ? e.fees.base * 1e8 : null;
2259
+ if (!(vsize > 0) || fee == null) { unranked++; continue; }
2260
+ list.push([vsize, fee / vsize, txid]);
2261
+ }
2262
+ list.sort((a, b) => b[1] - a[1]);
2263
+ const v = [], r = [], id = [];
2264
+ let used = 0;
2265
+ for (const [vsize, rate, txid] of list) {
2266
+ if (used + vsize > blockVsize && used > 0) break;
2267
+ v.push(Math.round(vsize)); r.push(Math.round(rate * 100) / 100); id.push(String(txid));
2268
+ used += vsize;
2269
+ }
2270
+ return { at: Date.now(), blockVsize, n: v.length, vsize: used, poolCount: list.length + unranked, unranked, v, r, id };
2271
+ }
2272
+
2273
+ export function summarizeMempool(raw) {
2274
+ const txs = Object.entries(raw);
2275
+ const n = txs.length;
2276
+ // Empty is a state with a shape of its own: `cells` must exist here too, or an empty
2277
+ // pool is indistinguishable from a pool that has not been polled yet -- and the client
2278
+ // would say "no detail yet" about a mempool that is genuinely, provably empty.
2279
+ if (!n) return { count: 0, hist: [], ageHist: [], scatter: [], cells: [], totalVsize: 0, totalFeeSat: 0, maxFeerate: 0, p50Feerate: 0, p90Feerate: 0, avgFeerate: 0, oldestSec: 0, pendingAncestors: null, replaceable: null };
2280
+ const nowSec = Date.now() / 1000;
2281
+ const feerates = new Float64Array(n);
2282
+ const ages = new Float64Array(n);
2283
+ const vsizes = new Float64Array(n);
2284
+ const fees = new Float64Array(n);
2285
+ let totalVsize = 0;
2286
+ let totalFeeSat = 0;
2287
+ let oldest = 0;
2288
+ let withFee = 0;
2289
+ let ageUnknown = 0;
2290
+
2291
+ for (let i = 0; i < n; i++) {
2292
+ const e = txs[i][1] ?? {};
2293
+ const vsize = typeof e.vsize === 'number' ? e.vsize : (typeof e.weight === 'number' ? Math.ceil(e.weight / 4) : 0);
2294
+ const feeSat = e.fees && typeof e.fees.base === 'number' ? e.fees.base * 1e8 : null;
2295
+ vsizes[i] = vsize;
2296
+ totalVsize += vsize;
2297
+ if (feeSat != null) {
2298
+ fees[i] = feeSat;
2299
+ totalFeeSat += feeSat;
2300
+ feerates[i] = vsize > 0 ? feeSat / vsize : 0;
2301
+ withFee += 1;
2302
+ } else feerates[i] = NaN;
2303
+ // A time of 0 is NOT a timestamp. Measured 2026-09-11 on deploy-20260910ag:
2304
+ // 94 of 19,014 entries carried time = 0, which read as an age of ~20,707
2305
+ // days (since 1970), stretched the scatter's age axis to 56 years and
2306
+ // crushed every real transaction into one vertical line at its left edge.
2307
+ // Anything that cannot be a unix time is an UNKNOWN age: left out of the
2308
+ // age figures and counted, never charted as ancient.
2309
+ if (typeof e.time === 'number' && e.time > 1e9) {
2310
+ const age = Math.max(0, nowSec - e.time);
2311
+ ages[i] = age;
2312
+ if (age > oldest) oldest = age;
2313
+ } else {
2314
+ ages[i] = NaN;
2315
+ ageUnknown++;
2316
+ }
2317
+ }
2318
+
2319
+ // Feerate histogram on a log axis: a linear one puts 95% of the mass in the
2320
+ // first bar and tells you nothing.
2321
+ const hist = histogramLog(feerates, { lo: 0.5, hi: 2000, buckets: 40 });
2322
+ const ageHist = histogramLinear(ages.filter((a) => !Number.isNaN(a)), { lo: 0, hi: Math.max(600, oldest), buckets: 30 });
2323
+
2324
+ // Scatter, capped so a 17k-tx pool does not turn into a 17k-point canvas job.
2325
+ const cap = 1500;
2326
+ const stride = Math.max(1, Math.ceil(n / cap));
2327
+ const scatter = [];
2328
+ for (let i = 0; i < n; i += stride) {
2329
+ if (Number.isNaN(feerates[i]) || Number.isNaN(ages[i])) continue;
2330
+ scatter.push([Math.round(ages[i]), +feerates[i].toFixed(3), Math.round(vsizes[i])]);
2331
+ }
2332
+
2333
+ const sorted = Float64Array.from(feerates.subarray(0, withFee)).sort();
2334
+ const sortedFeerates = sorted.length ? sorted : null;
2335
+
2336
+ return {
2337
+ count: n,
2338
+ totalVsize,
2339
+ totalFeeSat: Math.round(totalFeeSat),
2340
+ avgFeerate: sortedFeerates ? round(sortedFeerates.reduce((a, b) => a + b, 0) / sortedFeerates.length, 3) : null,
2341
+ p50Feerate: sortedFeerates ? round(q(sortedFeerates, 0.5), 3) : null,
2342
+ p90Feerate: sortedFeerates ? round(q(sortedFeerates, 0.9), 3) : null,
2343
+ maxFeerate: sortedFeerates ? round(sortedFeerates[sortedFeerates.length - 1], 2) : null,
2344
+ avgVsize: Math.round(totalVsize / n),
2345
+ oldestSec: Math.round(oldest),
2346
+ ageUnknown,
2347
+ hist,
2348
+ ageHist,
2349
+ scatter,
2350
+ // Cells for the mempool treemap -- the same bounded, aggregate-tailed rule as the
2351
+ // block's own cells, so the two pictures are comparable rather than one of them
2352
+ // quietly hiding its long tail. Richest first, because the question this answers is
2353
+ // "who would make the next block, and who does not".
2354
+ cells: poolCells(vsizes, feerates, txs),
2355
+ // How many transactions the cells describe. getrawmempool is polled on its own tier,
2356
+ // so the live header count and the picture differ by whatever arrived in between;
2357
+ // naming that is a detail, hiding it makes 400 + 8,838 read as a bug next to a header
2358
+ // saying 6,359.
2359
+ cellCount: n,
2360
+ pendingAncestors: null, // not reported by this node's getrawmempool
2361
+ replaceable: null, // no "replaceable" flag in this node's entries
2362
+ // the blocks after the one being assembled, as the mempool stands (projectBlocks)
2363
+ projected: projectBlocks(vsizes, feerates, fees),
2364
+ };
2365
+ }
2366
+
2367
+ // PROJECTED BLOCKS (operator, 2026-09-11: "Why can't we forecast at least one block ahead of
2368
+ // current work, like mempool space app does?"). The whole mempool is read on the 20 s pool
2369
+ // tier, so the blocks after the one being assembled can be projected from it: every
2370
+ // transaction with a known fee, richest feerate first, cut into blocks of `blockVsize`
2371
+ // (1,000,000 vB -- 4M weight units). The first block's worth is skipped: the node itself is
2372
+ // assembling it (getblocktemplate, the "being built" card), and this sort is only an
2373
+ // approximation of that. Then `count` blocks, each with its transaction count, size, fees,
2374
+ // feerate range and median; everything beyond them is summed as `rest`. An inference, and
2375
+ // labelled as one on the page: this node's getrawmempool carries no ancestor data, so a child
2376
+ // paying for its parent is placed by its own feerate and can be projected a block late.
2377
+ export function projectBlocks(vsizes, feerates, fees, { blockVsize = 1_000_000, skip = 1, count = 6 } = {}) {
2378
+ const idx = [];
2379
+ for (let i = 0; i < feerates.length; i++) if (Number.isFinite(feerates[i]) && vsizes[i] > 0) idx.push(i);
2380
+ idx.sort((a, b) => feerates[b] - feerates[a]);
2381
+ const done = [];
2382
+ let cur = null;
2383
+ const open = () => { cur = { n: 0, vsize: 0, feeSat: 0, rates: [] }; };
2384
+ open();
2385
+ let rest = null;
2386
+ for (const i of idx) {
2387
+ if (done.length >= skip + count) {
2388
+ rest = rest ?? { n: 0, vsize: 0, feeSat: 0, maxRate: feerates[i], minRate: feerates[i] };
2389
+ rest.n++; rest.vsize += vsizes[i]; rest.feeSat += fees[i]; rest.minRate = feerates[i];
2390
+ continue;
2391
+ }
2392
+ if (cur.n > 0 && cur.vsize + vsizes[i] > blockVsize) { done.push(cur); open(); if (done.length >= skip + count) { rest = { n: 1, vsize: vsizes[i], feeSat: fees[i], maxRate: feerates[i], minRate: feerates[i] }; continue; } }
2393
+ cur.n++; cur.vsize += vsizes[i]; cur.feeSat += fees[i]; cur.rates.push(feerates[i]);
2394
+ }
2395
+ if (cur.n && done.length < skip + count) done.push(cur);
2396
+ const r = (v) => (v == null ? null : Math.round(v * 100) / 100);
2397
+ const shape = (b) => ({
2398
+ n: b.n, vsize: b.vsize, feeSat: Math.round(b.feeSat),
2399
+ maxRate: r(b.rates[0]), minRate: r(b.rates[b.rates.length - 1]),
2400
+ medianRate: r(b.rates[Math.floor((b.rates.length - 1) / 2)]),
2401
+ });
2402
+ return {
2403
+ blockVsize,
2404
+ skipped: done.length ? shape(done[0]) : null,
2405
+ blocks: done.slice(skip).map(shape),
2406
+ rest: rest ? { n: rest.n, vsize: rest.vsize, feeSat: Math.round(rest.feeSat), maxRate: r(rest.maxRate), minRate: r(rest.minRate), blocks: Math.ceil(rest.vsize / blockVsize) } : null,
2407
+ };
2408
+ }
2409
+
2410
+ /**
2411
+ * Bounded cells for the mempool view, mirroring templateCells so the block picture and
2412
+ * the pool picture obey one rule: draw the richest `maxCells`, collapse everything else
2413
+ * into ONE aggregate cell that carries their weight and their weighted-mean feerate.
2414
+ * Dropping the tail instead would make a crowded pool look like the transactions that
2415
+ * matter, which is the opposite of what the view is for.
2416
+ */
2417
+ function poolCells(vsizes, feerates, entries, { maxCells = 400, coverPct = 0.97 } = {}) {
2418
+ const list = [];
2419
+ for (let i = 0; i < vsizes.length; i++) {
2420
+ const vbytes = vsizes[i];
2421
+ const rate = feerates[i];
2422
+ if (!Number.isFinite(vbytes) || vbytes <= 0 || !Number.isFinite(rate)) continue;
2423
+ list.push({ vbytes: Math.round(vbytes), rate: round(rate, 2), txid: entries[i]?.[0] ?? null });
2424
+ }
2425
+ list.sort((a, b) => b.rate - a.rate);
2426
+ const total = list.reduce((n, c) => n + c.vbytes, 0);
2427
+ const want = total * coverPct;
2428
+ const cells = [];
2429
+ let acc = 0;
2430
+ for (const c of list) {
2431
+ if (cells.length >= maxCells || acc >= want) break;
2432
+ cells.push(c);
2433
+ acc += c.vbytes;
2434
+ }
2435
+ const tail = list.slice(cells.length);
2436
+ poolCells.tailCount = tail.length;
2437
+ if (tail.length) {
2438
+ const w = tail.reduce((n, c) => n + c.vbytes, 0);
2439
+ const r = tail.reduce((n, c) => n + c.rate * c.vbytes, 0) / Math.max(1, w);
2440
+ cells.push({ vbytes: w, rate: round(r, 2), aggregate: tail.length, strata: tailStrata(tail) });
2441
+ }
2442
+ return cells;
2443
+ }
2444
+
2445
+ // THE TAIL IN COLOUR (operator, 2026-09-11: "why can't we do 128 colors in simple mode as well?").
2446
+ // Measured that day: the aggregate held 17,118 transactions and 96% of the Simple board's space
2447
+ // under ONE weighted-mean feerate (0.49 sat/vB), so however many colours the palette had, almost
2448
+ // the whole board was one of them. The cell stays ONE aggregate -- the 2D maps and everything
2449
+ // that counts cells are unchanged -- and carries its make-up: the tail (richest first, as sorted
2450
+ // above) grouped in geometric feerate steps as wide as the 3D palette's bands (from 0.1 sat/vB,
2451
+ // ~8% a step; public/js/feepalette.js), merged down to STRATA_MAX by joining the lightest
2452
+ // neighbouring pair. The 3D board colours its equal pieces from these; nothing is claimed about
2453
+ // any individual transaction.
2454
+ const STRATA_MAX = 32;
2455
+ const STRATUM_STEP = Math.log(2000 / 0.1) / 126;
2456
+ function tailStrata(tail) {
2457
+ const key = (rate) => (rate < 0.1 ? -1 : Math.floor(Math.log(rate / 0.1) / STRATUM_STEP + 1e-9));
2458
+ const out = [];
2459
+ for (const c of tail) {
2460
+ const k = key(c.rate);
2461
+ const last = out.at(-1);
2462
+ if (last && last.k === k) { last.vbytes += c.vbytes; last.fee += c.rate * c.vbytes; last.n++; }
2463
+ else out.push({ k, vbytes: c.vbytes, fee: c.rate * c.vbytes, n: 1 });
2464
+ }
2465
+ while (out.length > STRATA_MAX) {
2466
+ let best = 0;
2467
+ for (let i = 1; i < out.length - 1; i++) if (out[i].vbytes + out[i + 1].vbytes < out[best].vbytes + out[best + 1].vbytes) best = i;
2468
+ const a = out[best], b = out[best + 1];
2469
+ out.splice(best, 2, { k: a.k, vbytes: a.vbytes + b.vbytes, fee: a.fee + b.fee, n: a.n + b.n });
2470
+ }
2471
+ return out.map((s) => ({ vbytes: s.vbytes, rate: round(s.fee / Math.max(1, s.vbytes), 2), n: s.n }));
2472
+ }
2473
+
2474
+ function q(sorted, p) {
2475
+ if (!sorted.length) return null;
2476
+ const i = Math.min(sorted.length - 1, Math.floor(p * sorted.length));
2477
+ return sorted[i];
2478
+ }
2479
+
2480
+ function round(v, dp = 2) {
2481
+ const f = 10 ** dp;
2482
+ return v == null ? null : Math.round(v * f) / f;
2483
+ }
2484
+
2485
+ function histogramLog(values, { lo, hi, buckets }) {
2486
+ const l0 = Math.log10(lo);
2487
+ const l1 = Math.log10(hi);
2488
+ const step = (l1 - l0) / buckets;
2489
+ const counts = new Array(buckets).fill(0);
2490
+ let under = 0;
2491
+ let over = 0;
2492
+ for (const v of values) {
2493
+ if (Number.isNaN(v) || v <= 0) { under += 1; continue; }
2494
+ const b = Math.floor((Math.log10(Math.max(lo, v)) - l0) / step);
2495
+ if (b < 0) under += 1;
2496
+ else if (b >= buckets) over += 1;
2497
+ else counts[b] += 1;
2498
+ }
2499
+ return {
2500
+ kind: 'log',
2501
+ lo, hi, buckets,
2502
+ edges: counts.map((_, i) => round(10 ** (l0 + step * i), 3)),
2503
+ counts,
2504
+ under, over,
2505
+ };
2506
+ }
2507
+
2508
+ function histogramLinear(values, { lo, hi, buckets }) {
2509
+ const step = (hi - lo) / buckets;
2510
+ const counts = new Array(buckets).fill(0);
2511
+ for (const v of values) {
2512
+ const b = Math.floor((v - lo) / step);
2513
+ if (b < 0 || b >= buckets) continue;
2514
+ counts[b] += 1;
2515
+ }
2516
+ return { kind: 'linear', lo, hi, buckets, step, edges: counts.map((_, i) => Math.round(lo + step * i)), counts };
2517
+ }
2518
+
2519
+ // PER-PEER RATES (2026-09-11, operator: "Doesn't the node's rpc pull more info for peers
2520
+ // now?"). getpeerinfo publishes cumulative bytessent / bytesrecv per connection -- summing
2521
+ // to 99.99% of getnettotals on this build (MEASUREMENTS 27) -- so a rate is the change in
2522
+ // one peer's counters between two samples over the time between them. A peer seen for the
2523
+ // first time, or whose counter went backwards (an id reused by a new connection), has no
2524
+ // rate yet: null, not zero. A second read within a second (the mid and rare tiers can land
2525
+ // together) keeps the last rate rather than dividing by almost nothing.
2526
+ export function withPeerRates(rows, prev = new Map(), now = Date.now()) {
2527
+ const next = new Map();
2528
+ const out = rows.map((p) => {
2529
+ const key = String(p.id);
2530
+ const was = prev.get(key);
2531
+ if (was && now - was.t < 1000) {
2532
+ next.set(key, was);
2533
+ return { ...p, recvRate: was.recvRate ?? null, sentRate: was.sentRate ?? null };
2534
+ }
2535
+ let recvRate = null, sentRate = null;
2536
+ if (was) {
2537
+ const dt = (now - was.t) / 1000;
2538
+ if (Number.isFinite(p.bytesrecv) && Number.isFinite(was.recv) && p.bytesrecv >= was.recv) recvRate = (p.bytesrecv - was.recv) / dt;
2539
+ if (Number.isFinite(p.bytessent) && Number.isFinite(was.sent) && p.bytessent >= was.sent) sentRate = (p.bytessent - was.sent) / dt;
2540
+ }
2541
+ next.set(key, { t: now, recv: p.bytesrecv, sent: p.bytessent, recvRate, sentRate });
2542
+ return { ...p, recvRate, sentRate };
2543
+ });
2544
+ return { rows: out, prev: next };
2545
+ }