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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- 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
|
+
}
|