blockyard 0.0.1 → 0.0.9
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 +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -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 +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -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 +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -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 +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -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 +20 -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 +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -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 +193 -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 +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -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/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The block being built right now, and the packages inside it.
|
|
2
|
+
//
|
|
3
|
+
// This module is pure: it takes the reply of `getblocktemplate` and turns it into the
|
|
4
|
+
// numbers the block-flow's "next block" card and the Goggles charts need. No I/O, so the
|
|
5
|
+
// cluster math is testable against a frozen real reply.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS REPLACED, and a claim I got wrong. The Mining page used to say Goggles-style
|
|
8
|
+
// cluster analysis was impossible on this node, because `getrawmempool` verbose on this
|
|
9
|
+
// build returns `vsize` and `fees.base` with no `depends` and no `ancestorcount`. That
|
|
10
|
+
// half was measured; the conclusion drawn from it was not. The dependency graph IS
|
|
11
|
+
// published -- by `getblocktemplate`, where every selected transaction carries
|
|
12
|
+
// `depends: [indices into this same array]`. Measured 2026-09-09 on height 966265:
|
|
13
|
+
// 1,496 transactions selected, 1,475 ancestor packages, 19 of them multi-transaction,
|
|
14
|
+
// 40 transactions (2.7%) inside a package, largest package 3 transactions.
|
|
15
|
+
// One package: child at 38.1 sat/vB, parent at 0.5 sat/vB, package feerate 10.93
|
|
16
|
+
// sat/vB -- a textbook child-pays-for-parent, visible rather than inferred.
|
|
17
|
+
//
|
|
18
|
+
// COST, which is why it is polled once a minute and never during initial download:
|
|
19
|
+
// getblocktemplate(rules:[segwit]) = 1,790,010 bytes and 1.29-1.48 s per call on this
|
|
20
|
+
// node, whose RPC server serves one connection at a time on one thread. That is the
|
|
21
|
+
// node's time, not ours, so the caller owns the cadence and the coalescing; and the
|
|
22
|
+
// `data` field (full transaction hex, the reason for the 1.79 MB) is dropped at the
|
|
23
|
+
// door -- nothing downstream reads it, and keeping it would put megabytes in every
|
|
24
|
+
// snapshot frame.
|
|
25
|
+
|
|
26
|
+
const VBYTES = 4; // weight units per virtual byte
|
|
27
|
+
|
|
28
|
+
/** sat/vB of one selected transaction, or null when it cannot be computed. */
|
|
29
|
+
function feeRate(tx) {
|
|
30
|
+
const w = tx?.weight;
|
|
31
|
+
if (!Number.isFinite(w) || w <= 0 || !Number.isFinite(tx?.fee)) return null;
|
|
32
|
+
return +(tx.fee / (w / VBYTES)).toFixed(2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pctOf = (sorted, p) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] : null);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The header numbers for the next-block card, plus a fixed-bucket feerate histogram so
|
|
39
|
+
* 1,496 transactions never have to travel to the browser.
|
|
40
|
+
*/
|
|
41
|
+
export function summarizeTemplate(t, { at = Date.now(), previous = null } = {}) {
|
|
42
|
+
const txs = Array.isArray(t?.transactions) ? t.transactions : [];
|
|
43
|
+
const rates = txs.map(feeRate).filter((v) => v != null).sort((a, b) => a - b);
|
|
44
|
+
const weight = txs.reduce((n, x) => n + (Number.isFinite(x?.weight) ? x.weight : 0), 0);
|
|
45
|
+
const limit = Number.isFinite(t?.weightlimit) && t.weightlimit > 0 ? t.weightlimit : 4_000_000;
|
|
46
|
+
const fees = txs.reduce((n, x) => n + (Number.isFinite(x?.fee) ? x.fee : 0), 0);
|
|
47
|
+
return {
|
|
48
|
+
height: t?.height ?? null,
|
|
49
|
+
previous: t?.previousblockhash ?? previous,
|
|
50
|
+
txCount: txs.length,
|
|
51
|
+
weight,
|
|
52
|
+
weightLimit: limit,
|
|
53
|
+
weightPct: +((100 * weight) / limit).toFixed(1),
|
|
54
|
+
totalFeesSat: fees,
|
|
55
|
+
coinbaseSat: Number.isFinite(t?.coinbasevalue) ? t.coinbasevalue : null,
|
|
56
|
+
feeRate: {
|
|
57
|
+
min: rates[0] ?? null,
|
|
58
|
+
p50: pctOf(rates, 0.5),
|
|
59
|
+
p75: pctOf(rates, 0.75),
|
|
60
|
+
p90: pctOf(rates, 0.9),
|
|
61
|
+
max: rates[rates.length - 1] ?? null,
|
|
62
|
+
median: rates.length % 2 ? rates[(rates.length - 1) / 2] : (rates[rates.length / 2 - 1] + rates[rates.length / 2]) / 2,
|
|
63
|
+
},
|
|
64
|
+
// Log buckets from 0.1 to 1000 sat/vB. They carry the WEIGHT in each bucket, not
|
|
65
|
+
// just the count, because the useful question -- "what feerate still gets into the
|
|
66
|
+
// next block?" -- is about weight, and a histogram of counts cannot answer it.
|
|
67
|
+
feeRateHistogram: bucketize(txs.map((t) => ({ rate: feeRate(t), weight: Number.isFinite(t?.weight) ? t.weight : 0 }))),
|
|
68
|
+
mintime: t?.mintime ?? null,
|
|
69
|
+
bits: t?.bits ?? null,
|
|
70
|
+
sigopLimit: t?.sigoplimit ?? null,
|
|
71
|
+
sizeLimit: t?.sizelimit ?? null,
|
|
72
|
+
version: t?.version ?? null,
|
|
73
|
+
signal: t?.vbavailable && Object.keys(t.vbavailable).length
|
|
74
|
+
? Object.entries(t.vbavailable).map(([k, v]) => ({ bit: k, available: v, required: (t.vbrequired ?? 0) & (1 << Number(k)) ? 1 : 0 }))
|
|
75
|
+
: null,
|
|
76
|
+
at,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const BUCKET_EDGES = [0.1, 0.5, 1, 2, 3, 4, 6, 8, 12, 20, 30, 50, 100, 250, 1000];
|
|
81
|
+
|
|
82
|
+
function bucketize(txs) {
|
|
83
|
+
const out = BUCKET_EDGES.map((hi, i) => ({ hi, lo: i ? BUCKET_EDGES[i - 1] : 0, n: 0, weight: 0 }));
|
|
84
|
+
for (const t of txs) {
|
|
85
|
+
if (t.rate == null) continue;
|
|
86
|
+
const i = BUCKET_EDGES.findIndex((hi) => t.rate <= hi);
|
|
87
|
+
const b = out[i < 0 ? out.length - 1 : i];
|
|
88
|
+
b.n++;
|
|
89
|
+
b.weight += t.weight;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the next block can still take, and what spills past it.
|
|
96
|
+
*
|
|
97
|
+
* Walk the selected transactions from the highest feerate down until the remaining
|
|
98
|
+
* weight is gone; the bucket it runs out in is the marginal rate -- the thing a wallet
|
|
99
|
+
* actually needs to know. Anything below that is the spill, and the spill has to fit in
|
|
100
|
+
* a later block, which is where the ghost cards get their content.
|
|
101
|
+
*
|
|
102
|
+
* Everything here is arithmetic on what the node reported. `estimate: true` marks the
|
|
103
|
+
* forward-looking numbers, because "the backlog is about 1.3 blocks deep" is a shape of
|
|
104
|
+
* the queue, not a claim that anyone is building block 3 from here.
|
|
105
|
+
*/
|
|
106
|
+
export function blockEconomy({ template, mempool, avgWeightMined = null, maxAhead = 3 } = {}) {
|
|
107
|
+
const limit = Number.isFinite(template?.weightLimit) && template.weightLimit > 0 ? template.weightLimit : 4_000_000;
|
|
108
|
+
const used = Number.isFinite(template?.weight) ? template.weight : 0;
|
|
109
|
+
const remaining = Math.max(0, limit - used);
|
|
110
|
+
const buckets = (template?.feeRateHistogram ?? []).slice().reverse(); // richest first
|
|
111
|
+
|
|
112
|
+
let marginal = null;
|
|
113
|
+
let spillWeight = 0;
|
|
114
|
+
let spillCount = 0;
|
|
115
|
+
let acc = 0;
|
|
116
|
+
for (const b of buckets) {
|
|
117
|
+
if (!b?.n) continue;
|
|
118
|
+
if (acc + b.weight <= remaining) { acc += b.weight; continue; }
|
|
119
|
+
marginal = { rate: b.hi, lo: b.lo, bandUnresolved: true };
|
|
120
|
+
spillWeight += (b.weight - Math.max(0, remaining - acc)) + 0;
|
|
121
|
+
spillCount += b.n;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
125
|
+
const b = buckets[i];
|
|
126
|
+
if (!b?.n) continue;
|
|
127
|
+
if (marginal && b.hi >= marginal.rate) continue;
|
|
128
|
+
spillWeight += b.weight;
|
|
129
|
+
spillCount += b.n;
|
|
130
|
+
}
|
|
131
|
+
if (marginal == null && remaining > 0) marginal = { rate: null, bandUnresolved: false, note: 'the template did not fill the block' };
|
|
132
|
+
|
|
133
|
+
// The queue, in blocks. getmempoolinfo.bytes is serialized bytes, and a mined block
|
|
134
|
+
// is ~1M vB, so the ratio is honest as an order of magnitude and no more precise than
|
|
135
|
+
// that -- which the card says.
|
|
136
|
+
const poolBytes = Number.isFinite(mempool?.bytes) ? mempool.bytes : null;
|
|
137
|
+
const blockBytes = avgWeightMined != null ? avgWeightMined / 4 : 1_000_000;
|
|
138
|
+
const fitsNext = poolBytes != null ? Math.min(1, (remaining / 4) / Math.max(1, poolBytes)) : null;
|
|
139
|
+
const depth = poolBytes != null ? poolBytes / blockBytes : null;
|
|
140
|
+
|
|
141
|
+
const ahead = [];
|
|
142
|
+
for (let k = 2; k <= maxAhead + 1; k++) {
|
|
143
|
+
const leftAfterNext = poolBytes != null ? Math.max(0, poolBytes - remaining / 4) : null;
|
|
144
|
+
const bytesHere = leftAfterNext == null ? null : Math.min(blockBytes, leftAfterNext - (k - 2) * blockBytes > 0 ? leftAfterNext - (k - 2) * blockBytes : 0);
|
|
145
|
+
ahead.push({
|
|
146
|
+
offset: k,
|
|
147
|
+
estimate: true,
|
|
148
|
+
assembled: false,
|
|
149
|
+
bytes: bytesHere && bytesHere > 0 ? Math.round(bytesHere) : null,
|
|
150
|
+
spillRate: marginal?.rate ?? null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
remainingWeight: Math.round(remaining),
|
|
156
|
+
remainingPct: +((100 * remaining) / limit).toFixed(1),
|
|
157
|
+
marginal,
|
|
158
|
+
spillCount,
|
|
159
|
+
spillWeight: Math.round(spillWeight),
|
|
160
|
+
poolBytes,
|
|
161
|
+
poolFitsNextPct: fitsNext != null ? +(100 * fitsNext).toFixed(1) : null,
|
|
162
|
+
backlogBlocks: depth != null ? +depth.toFixed(2) : null,
|
|
163
|
+
blockBytesEstimate: Math.round(blockBytes),
|
|
164
|
+
ahead,
|
|
165
|
+
note: 'marginal and spill come from the node\'s own template selection; the queue depth is getmempoolinfo.bytes against ~1M vB per block, which is an order of magnitude, not a schedule.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Cells for the block visualiser: one entry per transaction, sized by vbytes and
|
|
171
|
+
* coloured by its own feerate, richest first.
|
|
172
|
+
*
|
|
173
|
+
* Bounded, because a template holds ~1,500 transactions and a browser cannot draw that
|
|
174
|
+
* usefully: cells are kept until they cover `coverPct` of the selected weight and the
|
|
175
|
+
* rest becomes ONE aggregate cell. The aggregate is labelled as an aggregate rather than
|
|
176
|
+
* dropped -- a picture that quietly omits a fifth of the weight is a picture of
|
|
177
|
+
* something else. Richest-first is not styling: it is the rule the miner used, and the
|
|
178
|
+
* cut where the block runs out is only visible in that order.
|
|
179
|
+
*/
|
|
180
|
+
export function templateCells(txs, { maxCells = 400, coverPct = 0.97 } = {}) {
|
|
181
|
+
const list = (txs ?? [])
|
|
182
|
+
.map((t) => ({ vbytes: Math.max(1, Math.round((Number(t.weight) || 0) / 4)), rate: feeRate(t), txid: t.txid ?? t.hash ?? null }))
|
|
183
|
+
.filter((c) => Number.isFinite(c.vbytes) && c.rate != null)
|
|
184
|
+
.sort((a, b) => b.rate - a.rate);
|
|
185
|
+
|
|
186
|
+
const total = list.reduce((n, c) => n + c.vbytes, 0);
|
|
187
|
+
const want = total * coverPct;
|
|
188
|
+
const cells = [];
|
|
189
|
+
let acc = 0;
|
|
190
|
+
for (const c of list) {
|
|
191
|
+
if (cells.length >= maxCells || acc >= want) break;
|
|
192
|
+
cells.push(c);
|
|
193
|
+
acc += c.vbytes;
|
|
194
|
+
}
|
|
195
|
+
const tail = list.slice(cells.length);
|
|
196
|
+
if (tail.length) {
|
|
197
|
+
const w = tail.reduce((n, c) => n + c.vbytes, 0);
|
|
198
|
+
const r = tail.reduce((n, c) => n + c.rate * c.vbytes, 0) / Math.max(1, w);
|
|
199
|
+
cells.push({ vbytes: w, rate: +r.toFixed(2), aggregate: tail.length });
|
|
200
|
+
}
|
|
201
|
+
return { cells: cells.sort((a, b) => b.rate - a.rate), totalVbytes: total, tailCount: tail.length };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Ancestor packages -- what Goggles calls clusters -- from `depends`.
|
|
206
|
+
*
|
|
207
|
+
* `depends` entries are indices into the same `transactions` array, measured
|
|
208
|
+
* ('depends': [6] on a transaction whose own position is higher). A package is a root and
|
|
209
|
+
* everything reachable from it through children. Package feerate is the whole package's
|
|
210
|
+
* fees over the whole package's weight, which is the only number that means anything for
|
|
211
|
+
* a child paying for a parent: the child's own 38 sat/vB says nothing about the chain
|
|
212
|
+
* until the parent's 0.5 is folded in and the answer becomes 10.93.
|
|
213
|
+
*/
|
|
214
|
+
export function packagesFromTemplate(txs, { keepTop = 20 } = {}) {
|
|
215
|
+
const list = Array.isArray(txs) ? txs : [];
|
|
216
|
+
const n = list.length;
|
|
217
|
+
const children = new Map();
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
for (const d of list[i]?.depends ?? []) {
|
|
220
|
+
if (Number.isInteger(d) && d >= 0 && d < n) {
|
|
221
|
+
if (!children.has(d)) children.set(d, []);
|
|
222
|
+
children.get(d).push(i);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
const packs = [];
|
|
228
|
+
for (let i = 0; i < n; i++) {
|
|
229
|
+
if (seen.has(i)) continue;
|
|
230
|
+
const stack = [i];
|
|
231
|
+
const members = [];
|
|
232
|
+
seen.add(i);
|
|
233
|
+
while (stack.length) {
|
|
234
|
+
const cur = stack.pop();
|
|
235
|
+
members.push(cur);
|
|
236
|
+
for (const c of children.get(cur) ?? []) if (!seen.has(c)) { seen.add(c); stack.push(c); }
|
|
237
|
+
}
|
|
238
|
+
packs.push(members);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const described = packs.map((members) => {
|
|
242
|
+
const txs2 = members.map((i) => list[i]).filter(Boolean);
|
|
243
|
+
const fee = txs2.reduce((x, t) => x + (Number.isFinite(t.fee) ? t.fee : 0), 0);
|
|
244
|
+
const weight = txs2.reduce((x, t) => x + (Number.isFinite(t.weight) ? t.weight : 0), 0);
|
|
245
|
+
const rates = txs2.map(feeRate).filter((v) => v != null);
|
|
246
|
+
return {
|
|
247
|
+
size: members.length,
|
|
248
|
+
feesSat: fee,
|
|
249
|
+
weight,
|
|
250
|
+
packageFeeRate: weight > 0 ? +(fee / (weight / VBYTES)).toFixed(2) : null,
|
|
251
|
+
childRate: rates.length ? Math.max(...rates) : null,
|
|
252
|
+
parentRate: rates.length ? Math.min(...rates) : null,
|
|
253
|
+
// The thing a human looks at first: does the child carry the parent?
|
|
254
|
+
cpfp: members.length > 1 && rates.length > 1 && Math.max(...rates) > Math.min(...rates) * 2,
|
|
255
|
+
txids: txs2.slice(0, 6).map((t) => t.txid ?? t.hash ?? null).filter(Boolean),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const sizeHistogram = {};
|
|
260
|
+
for (const p of described) sizeHistogram[p.size] = (sizeHistogram[p.size] ?? 0) + 1;
|
|
261
|
+
const multi = described.filter((p) => p.size > 1).sort((a, b) => b.size - a.size || (b.packageFeeRate ?? 0) - (a.packageFeeRate ?? 0));
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
total: described.length,
|
|
265
|
+
multiTx: multi.length,
|
|
266
|
+
txsInPackages: multi.reduce((n2, p) => n2 + p.size, 0),
|
|
267
|
+
largest: multi[0]?.size ?? 1,
|
|
268
|
+
cpfpCandidates: described.filter((p) => p.cpfp).length,
|
|
269
|
+
sizeHistogram,
|
|
270
|
+
top: (keepTop ? multi.slice(0, keepTop) : multi).map((p) => ({ ...p, txids: p.txids.slice(0, 4) })),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The honest note that travels with this data, so cost and cadence are never invisible. */
|
|
275
|
+
export const TEMPLATE_NOTE = 'getblocktemplate(rules:[segwit]) costs this node roughly 1.3-1.5 s of its single RPC thread and 1.79 MB per call; it is polled once a minute, skipped during initial download, and its transaction hex is dropped before the snapshot is built.';
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Sync state: the single place that decides "how far along is this node", and
|
|
2
|
+
// how far it may reach in confidence.
|
|
3
|
+
//
|
|
4
|
+
// Two progress numbers exist and they are NOT the same measurement, so this
|
|
5
|
+
// module keeps them apart and labels each:
|
|
6
|
+
//
|
|
7
|
+
// heightRatio = blocks / headers
|
|
8
|
+
// The share of already-ANNOUNCED headers whose blocks we hold. This node is
|
|
9
|
+
// headers-first (docs/RPC_LIVE_NODE.md, README "Initial block download"), so
|
|
10
|
+
// headers race to the chain tip almost immediately and this ratio runs low
|
|
11
|
+
// for most of a sync and reaches 1 only at the end. That is exactly the
|
|
12
|
+
// 0%->100% shape wanted for the bar, and it is a real quantity: we do not
|
|
13
|
+
// have a block for a header we have not been given.
|
|
14
|
+
//
|
|
15
|
+
// verificationProgress = getblockchaininfo.verificationprogress
|
|
16
|
+
// Core's difficulty-weighted estimate, which this node reproduces. It is a
|
|
17
|
+
// better measure of "how much work is done" mid-sync, but it is an estimate
|
|
18
|
+
// and it saturates towards 1 well before the last block lands.
|
|
19
|
+
//
|
|
20
|
+
// They are never averaged, maxed or otherwise merged into one number. The bar
|
|
21
|
+
// draws heightRatio; verificationProgress is drawn as a second, separately
|
|
22
|
+
// labelled marker on the same track.
|
|
23
|
+
import { formatEta } from '../util/fmt.js';
|
|
24
|
+
|
|
25
|
+
const RATE_WINDOW_RECENT = '2 minutes';
|
|
26
|
+
|
|
27
|
+
export const STATE = {
|
|
28
|
+
UNKNOWN: 'unknown',
|
|
29
|
+
IBD: 'ibd',
|
|
30
|
+
CATCHING_UP: 'catching_up',
|
|
31
|
+
SYNCED: 'synced',
|
|
32
|
+
STALLED: 'stalled',
|
|
33
|
+
REORG: 'reorg',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// No block after this many seconds while not in IBD is a stall. Two expected
|
|
37
|
+
// intervals plus slack: a node on a quiet chain legitimately waits ~20 min for
|
|
38
|
+
// the next block, so anything under that must not raise a stall flag.
|
|
39
|
+
const STALL_SEC = 2400;
|
|
40
|
+
// A LONG GAP IS NOT A STALL (2026-09-14: two independent nodes at the same height, no block for
|
|
41
|
+
// 42 minutes, and the header said STALLED in red -- "Production is fucked now"). The network finds
|
|
42
|
+
// no block for 40 minutes about once in fifty; a node is stalled only when its PEERS know a higher
|
|
43
|
+
// tip than it holds. With no peer heights to ask, the old age alone must be well past what a gap
|
|
44
|
+
// can be before the word is used.
|
|
45
|
+
const STALL_ALONE_SEC = 7200;
|
|
46
|
+
// "Synced" also requires the tip to be this close to now, to avoid calling a
|
|
47
|
+
// node that lost all its peers "synced".
|
|
48
|
+
const FRESH_SEC = 3600;
|
|
49
|
+
|
|
50
|
+
export function computeSync({
|
|
51
|
+
now = Date.now(),
|
|
52
|
+
blocks = null,
|
|
53
|
+
headers = null,
|
|
54
|
+
ibd = null,
|
|
55
|
+
verificationProgress = null,
|
|
56
|
+
tipTime = null,
|
|
57
|
+
bestHash = null,
|
|
58
|
+
sizeOnDisk = null,
|
|
59
|
+
chain = null,
|
|
60
|
+
warnings = [],
|
|
61
|
+
blockRatePerSec = null,
|
|
62
|
+
blockRateFastSpanMs = null,
|
|
63
|
+
blockRateSlowPerSec = null,
|
|
64
|
+
blockRateSlowSpanMs = null,
|
|
65
|
+
avgBlockGapSec = null,
|
|
66
|
+
reorgEvents = 0,
|
|
67
|
+
reorgAt = null,
|
|
68
|
+
peers = null,
|
|
69
|
+
txouts = null,
|
|
70
|
+
difficulty = null,
|
|
71
|
+
reason = null,
|
|
72
|
+
targetHeight = null,
|
|
73
|
+
peerBestHeight = null, // the highest tip any connected peer reports (getpeerinfo synced_headers)
|
|
74
|
+
} = {}) {
|
|
75
|
+
const hasCounts = Number.isFinite(blocks);
|
|
76
|
+
const headersKnown = Number.isFinite(headers) && headers > 0;
|
|
77
|
+
const behind = headersKnown && hasCounts ? Math.max(0, headers - blocks) : null;
|
|
78
|
+
|
|
79
|
+
const heightRatio = hasCounts && headersKnown ? clamp(blocks / headers) : null;
|
|
80
|
+
const vp = Number.isFinite(verificationProgress) ? clamp(verificationProgress) : null;
|
|
81
|
+
|
|
82
|
+
const tipAgeSec = Number.isFinite(tipTime) ? Math.max(0, Math.floor(now / 1000) - tipTime) : null;
|
|
83
|
+
|
|
84
|
+
const state = decideState({ blocks, peerBestHeight, hasCounts, headersKnown, behind, ibd, tipAgeSec, reorgAt, now, reorgEvents });
|
|
85
|
+
|
|
86
|
+
// Rate windows, and when they may be believed.
|
|
87
|
+
//
|
|
88
|
+
// Observed live on the bench node on 2026-09-08, during initial block download:
|
|
89
|
+
// the tip advances in BURSTS, not steadily -- +955 blocks in 92 s (10.4 blk/s),
|
|
90
|
+
// then +4 per 25 s (0.16 blk/s) minutes later. A throughput sample taken inside
|
|
91
|
+
// a trough, from a process only 40 seconds old, produced an ETA of 23 days when
|
|
92
|
+
// the same node finished the same stretch in about 8 hours.
|
|
93
|
+
//
|
|
94
|
+
// So a rate is only used once its window has actually accumulated span, the
|
|
95
|
+
// longest usable window drives the ETA, and when the windows disagree the answer
|
|
96
|
+
// is a RANGE with both rates named -- not a single confident number.
|
|
97
|
+
const windows = [
|
|
98
|
+
{ name: '10 minutes', perSec: blockRateSlowPerSec, spanMs: blockRateSlowSpanMs },
|
|
99
|
+
{ name: '2 minutes', perSec: blockRatePerSec, spanMs: blockRateFastSpanMs },
|
|
100
|
+
];
|
|
101
|
+
// A window younger than this has not seen enough of a bursty process to
|
|
102
|
+
// characterize it, however exact its arithmetic looks.
|
|
103
|
+
const MIN_SPAN_MS = 60_000;
|
|
104
|
+
// A caller that names a window but not its span has already vouched for it;
|
|
105
|
+
// the guard exists for the monitor, which always reports how long it has been
|
|
106
|
+
// watching. Absent span therefore means "trusted", not "rejected" -- otherwise
|
|
107
|
+
// this pure function would silently refuse rates in every other context.
|
|
108
|
+
const trusted = (w) => w.spanMs == null || w.spanMs >= MIN_SPAN_MS;
|
|
109
|
+
const usable = windows.filter((w) => Number.isFinite(w.perSec) && w.perSec > 0 && trusted(w));
|
|
110
|
+
// A sample that existed but was too young to use must still be EXPLAINED, or
|
|
111
|
+
// the bar silently shows no ETA and the user cannot tell "just started" from
|
|
112
|
+
// "broken" from "the node is not making progress".
|
|
113
|
+
const seenTooYoung = windows.some((w) => Number.isFinite(w.perSec) && Number.isFinite(w.spanMs)
|
|
114
|
+
&& w.spanMs < MIN_SPAN_MS);
|
|
115
|
+
// Zero in the freshest window outranks an older average: "nothing arrived in
|
|
116
|
+
// the last two minutes" is exactly the fact an ETA computed from a
|
|
117
|
+
// flattering earlier window would hide.
|
|
118
|
+
const freshWindow = windows[windows.length - 1];
|
|
119
|
+
const stalledNow = Number.isFinite(freshWindow.perSec) && freshWindow.perSec === 0 && trusted(freshWindow);
|
|
120
|
+
const fast = usable[0]?.perSec ?? null;
|
|
121
|
+
const slow = usable.length > 1 ? usable[usable.length - 1].perSec : null;
|
|
122
|
+
// A stall pre-empts every window. Checking this after picking a basis let a
|
|
123
|
+
// flattering ten-minute average answer "8 hours" while nothing had arrived for
|
|
124
|
+
// two minutes -- the exact situation an ETA is supposed to reveal, not mask.
|
|
125
|
+
const usableForEta = stalledNow ? [] : usable;
|
|
126
|
+
const basis = usableForEta[0] ?? null;
|
|
127
|
+
|
|
128
|
+
let rateTrend = null;
|
|
129
|
+
if (stalledNow) rateTrend = 'stalled';
|
|
130
|
+
if (rateTrend === null && usable.length >= 2) {
|
|
131
|
+
// `usable` is ordered long-window-first, so the short one is the last entry.
|
|
132
|
+
const ratio = usable[usable.length - 1].perSec / usable[0].perSec;
|
|
133
|
+
rateTrend = ratio < 0.5 ? 'decelerating' : ratio > 2 ? 'accelerating' : 'steady';
|
|
134
|
+
} else if (basis) {
|
|
135
|
+
rateTrend = 'unknown';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let etaSec = null;
|
|
139
|
+
let etaBasis = null;
|
|
140
|
+
let etaBestSec = null;
|
|
141
|
+
let etaWorstSec = null;
|
|
142
|
+
if (behind != null && behind > 0 && basis) {
|
|
143
|
+
etaSec = Math.round(behind / basis.perSec);
|
|
144
|
+
etaBasis = `measured ${basis.perSec.toFixed(2)} blocks/s over ${basis.name}`;
|
|
145
|
+
if (usableForEta.length >= 2) {
|
|
146
|
+
const rates = usableForEta.map((w) => w.perSec);
|
|
147
|
+
const fastest = Math.max(...rates);
|
|
148
|
+
const slowest = Math.min(...rates);
|
|
149
|
+
if ((fastest - slowest) / slowest > 0.25) {
|
|
150
|
+
etaBestSec = Math.round(behind / fastest);
|
|
151
|
+
etaWorstSec = Math.round(behind / slowest);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else if (behind != null && behind > 0 && stalledNow) {
|
|
155
|
+
etaBasis = 'no blocks arrived in the most recent window, so no ETA is given; an older average would hide the stall';
|
|
156
|
+
} else if (behind != null && behind > 0 && ibd !== true && Number.isFinite(avgBlockGapSec) && avgBlockGapSec > 0) {
|
|
157
|
+
// Near the tip, blocks really do arrive at roughly the network cadence, so
|
|
158
|
+
// that cadence is a legitimate predictor for a short catch-up.
|
|
159
|
+
etaSec = Math.round(behind * avgBlockGapSec);
|
|
160
|
+
etaBasis = 'assumes the 10-minute target cadence (no measured download rate yet)';
|
|
161
|
+
}
|
|
162
|
+
// During IBD the cadence is deliberately NOT used as a fallback. Observed
|
|
163
|
+
// 2026-09-08 against the bench node: 296,325 blocks behind with no rate sample
|
|
164
|
+
// yet, the cadence assumption produced an ETA of 1,432 DAYS -- ~100x larger than
|
|
165
|
+
// what actually happened, because a syncing node applies blocks far faster than
|
|
166
|
+
// the network produces them.
|
|
167
|
+
|
|
168
|
+
const blocksPerMin = fast != null ? +(fast * 60).toFixed(2) : null;
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
state,
|
|
173
|
+
chain: chain ?? null,
|
|
174
|
+
height: hasCounts ? blocks : null,
|
|
175
|
+
headers: headersKnown ? headers : null,
|
|
176
|
+
behind,
|
|
177
|
+
// What the bar draws, and what the UI must label it as.
|
|
178
|
+
pct: heightRatio == null ? null : +(heightRatio * 100).toFixed(4),
|
|
179
|
+
verificationProgress: vp == null ? null : +(vp * 100).toFixed(4),
|
|
180
|
+
// The gap in the node's own announced headers: headers may also lag the real
|
|
181
|
+
// network tip, which heightRatio cannot see. Reported, not hidden.
|
|
182
|
+
headersMayLag: headersKnown && tipAgeSec != null && tipAgeSec > STALL_SEC && behind === 0,
|
|
183
|
+
bestHash: bestHash ?? null,
|
|
184
|
+
targetHeight: Number.isFinite(targetHeight) ? targetHeight : (headersKnown ? headers : null),
|
|
185
|
+
tipAgeSec,
|
|
186
|
+
etaSec,
|
|
187
|
+
eta: etaSec == null ? null : formatEta(etaSec),
|
|
188
|
+
etaBasis,
|
|
189
|
+
blocksPerMin,
|
|
190
|
+
blockRatePerSec: fast,
|
|
191
|
+
blockRateSlowPerSec: slow,
|
|
192
|
+
rateWindows: usableForEta.map((w) => ({ name: w.name, blocksPerSec: +w.perSec.toFixed(3), spanSec: w.spanMs == null ? null : Math.round(w.spanMs / 1000) })),
|
|
193
|
+
rateTrend,
|
|
194
|
+
// The bursty-process honesty pair: the same 293k blocks take this long at the
|
|
195
|
+
// fastest observed rate and this long at the slowest.
|
|
196
|
+
etaBestSec,
|
|
197
|
+
etaWorstSec,
|
|
198
|
+
etaBest: etaBestSec == null ? null : formatEta(etaBestSec),
|
|
199
|
+
etaWorst: etaWorstSec == null ? null : formatEta(etaWorstSec),
|
|
200
|
+
avgBlockGapSec,
|
|
201
|
+
sizeOnDisk: Number.isFinite(sizeOnDisk) ? sizeOnDisk : null,
|
|
202
|
+
ibd: typeof ibd === 'boolean' ? ibd : null,
|
|
203
|
+
txouts: Number.isFinite(txouts) ? txouts : null,
|
|
204
|
+
difficulty: Number.isFinite(difficulty) ? difficulty : null,
|
|
205
|
+
peers: Number.isFinite(peers) ? peers : null,
|
|
206
|
+
reorgs: Number.isFinite(reorgEvents) ? reorgEvents : 0,
|
|
207
|
+
warnings: Array.isArray(warnings) ? warnings : [],
|
|
208
|
+
// Why we do not know, whenever we do not know. "unknown" on its own reads as
|
|
209
|
+
// a broken monitor; the node usually told us exactly what it is doing.
|
|
210
|
+
reason: state === STATE.UNKNOWN ? (reason ?? null) : null,
|
|
211
|
+
// Honest summary of what we cannot know, so the UI can say it out loud.
|
|
212
|
+
caveats: caveatsOf({ blocks, peerBestHeight,
|
|
213
|
+
headersKnown, hasCounts, vp, heightRatio, behind, tipAgeSec, state, rateTrend,
|
|
214
|
+
etaBestSec, etaWorstSec,
|
|
215
|
+
// pre-vetting values, so the wording can distinguish "never sampled"
|
|
216
|
+
// from "sampled too recently to trust"
|
|
217
|
+
rawRates: { recent: windows[1].perSec, slow: windows[0].perSec },
|
|
218
|
+
seenTooYoung, stalledNow, hasUsableRate: !!basis,
|
|
219
|
+
// Destructured parameters are not in scope automatically; this was the
|
|
220
|
+
// third missing-threading bug in this file (after rateTrend and
|
|
221
|
+
// etaBestSec), so the call site now passes every value the body reads.
|
|
222
|
+
reason,
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function decideState({ hasCounts, headersKnown, behind, ibd, tipAgeSec, reorgAt, now, reorgEvents, blocks = null, peerBestHeight = null }) {
|
|
228
|
+
if (!hasCounts) return STATE.UNKNOWN;
|
|
229
|
+
// A reorg in the last 3 minutes outranks everything: the bar is about to move
|
|
230
|
+
// backwards, and calling that "synced" would be wrong twice over.
|
|
231
|
+
if (reorgEvents > 0 && reorgAt != null && now - reorgAt < 180_000) return STATE.REORG;
|
|
232
|
+
if (ibd === true) return STATE.IBD;
|
|
233
|
+
if (headersKnown && behind > 0) return STATE.CATCHING_UP;
|
|
234
|
+
if (tipAgeSec != null && tipAgeSec > STALL_SEC) {
|
|
235
|
+
const peersAhead = Number.isFinite(peerBestHeight) && Number.isFinite(blocks) && peerBestHeight > blocks;
|
|
236
|
+
const peersAgree = Number.isFinite(peerBestHeight) && Number.isFinite(blocks) && peerBestHeight <= blocks;
|
|
237
|
+
if (peersAhead) return STATE.STALLED;
|
|
238
|
+
if (!peersAgree && tipAgeSec > STALL_ALONE_SEC) return STATE.STALLED;
|
|
239
|
+
// peers agree on this tip, or nobody can say otherwise yet: a long gap, and synced
|
|
240
|
+
}
|
|
241
|
+
if (tipAgeSec != null && tipAgeSec <= FRESH_SEC) return STATE.SYNCED;
|
|
242
|
+
if (behind === 0 && headersKnown) return STATE.SYNCED;
|
|
243
|
+
return STATE.UNKNOWN;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function caveatsOf({ headersKnown, hasCounts, vp, heightRatio, behind, tipAgeSec, state, rateTrend, etaBestSec, etaWorstSec, rawRates = {}, seenTooYoung = false, stalledNow = false, hasUsableRate = false, reason = null, blocks = null, peerBestHeight = null }) {
|
|
247
|
+
const out = [];
|
|
248
|
+
// Whether any window saw a rate at all, before vetting turned a too-young
|
|
249
|
+
// sample into null. "No rate measured" and "a sample existed but was too young
|
|
250
|
+
// to trust" are different statements to the user and get different wording.
|
|
251
|
+
const sawRate = Number.isFinite(rawRates.recent) || Number.isFinite(rawRates.slow);
|
|
252
|
+
|
|
253
|
+
if (!hasCounts) {
|
|
254
|
+
out.push(reason
|
|
255
|
+
? `no height, headers or percentage yet: the node answered ${reason}`
|
|
256
|
+
: 'the node has not answered getblockchaininfo yet, so no height is known');
|
|
257
|
+
}
|
|
258
|
+
else if (!headersKnown) out.push('no header count was reported, so percentage complete cannot be derived from heights');
|
|
259
|
+
|
|
260
|
+
if (vp != null && heightRatio != null && Math.abs(vp - heightRatio) > 0.02) {
|
|
261
|
+
out.push(`height ratio ${(heightRatio * 100).toFixed(2)}% and the node's own estimate ${(vp * 100).toFixed(2)}% differ: the first is blocks we hold over announced headers, the second is difficulty-weighted work. Both are shown`);
|
|
262
|
+
}
|
|
263
|
+
if (behind === 0 && tipAgeSec != null && tipAgeSec > 2400) {
|
|
264
|
+
if (Number.isFinite(peerBestHeight) && Number.isFinite(blocks) && peerBestHeight > blocks) out.push(`connected peers report a tip ${peerBestHeight - blocks} block(s) above this node's: it is behind the network, not waiting for it`);
|
|
265
|
+
else if (Number.isFinite(peerBestHeight)) out.push(`no block for ${Math.round(tipAgeSec / 60)} minutes, and the connected peers agree on this tip: a long gap on the network, not a fault of this node`);
|
|
266
|
+
else out.push('blocks and headers agree, but the tip is old and no peer height is known, so a long gap and a node cut off from its peers cannot be told apart yet');
|
|
267
|
+
}
|
|
268
|
+
if (behind != null && behind > 0 && stalledNow) {
|
|
269
|
+
out.push('no blocks arrived in the last couple of minutes: either the download has stalled or this node is between bursts, so no ETA is offered');
|
|
270
|
+
}
|
|
271
|
+
if (behind != null && behind > 0 && !stalledNow && seenTooYoung && !hasUsableRate) {
|
|
272
|
+
out.push('a rate was measured but the monitor has not been watching long enough to trust it against a bursty download, so no ETA yet');
|
|
273
|
+
}
|
|
274
|
+
if (behind != null && behind > 0 && !sawRate && !stalledNow && !seenTooYoung) {
|
|
275
|
+
out.push(state === STATE.IBD
|
|
276
|
+
? 'no download rate measured yet, so no ETA is shown: guessing one from the 10-minute cadence would be wildly wrong during initial block download'
|
|
277
|
+
: 'no download rate measured yet, so the ETA assumes the 10-minute cadence and will be optimistic');
|
|
278
|
+
}
|
|
279
|
+
if (state === STATE.REORG) out.push('the active chain moved backwards; the height, the percentage and the ETA are all about to change');
|
|
280
|
+
if (behind != null && behind > 0 && rateTrend === 'decelerating') {
|
|
281
|
+
out.push('the throughput over the last 2 minutes is well under the 10-minute average, so this ETA will get LONGER, not shorter');
|
|
282
|
+
}
|
|
283
|
+
if (behind != null && behind > 0 && rateTrend === 'accelerating') {
|
|
284
|
+
out.push('the throughput over the last 2 minutes is well over the 10-minute average, so this ETA will shorten');
|
|
285
|
+
}
|
|
286
|
+
if (behind != null && behind > 0 && etaBestSec != null && etaWorstSec != null) {
|
|
287
|
+
out.push(`the node downloads in bursts, so a single figure would be a guess: at the observed rates this completes in between ${formatEta(etaBestSec)} and ${formatEta(etaWorstSec)}`);
|
|
288
|
+
}
|
|
289
|
+
if (state === STATE.STALLED) out.push(Number.isFinite(peerBestHeight) && Number.isFinite(blocks) && peerBestHeight > blocks ? 'stalled: peers know a higher tip than this node holds' : 'no new block for over two hours, and no peer height to check it against');
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The sync viewer's one-line instrument strip.
|
|
295
|
+
*
|
|
296
|
+
* Everything the operator needs is assembled here, in one ordered list, for every
|
|
297
|
+
* state -- so the browser draws the same dense row whether the node is at the tip
|
|
298
|
+
* or 900k blocks behind, instead of swapping to a ~10-row hero whenever a sync is
|
|
299
|
+
* running. A monitoring header that grows when there is news pushes the actual
|
|
300
|
+
* charts off the screen, which is the opposite of when you need to see them.
|
|
301
|
+
*
|
|
302
|
+
* Every figure is the node's own. A stat with no value is omitted rather than
|
|
303
|
+
* rendered as a placeholder, so the row shrinks to exactly what is known.
|
|
304
|
+
*/
|
|
305
|
+
export function stripFacts(sync = {}) {
|
|
306
|
+
const {
|
|
307
|
+
height = null, headers = null, behind = null, blockRatePerSec = null, rateTrend = null,
|
|
308
|
+
eta = null, tipAgeSec = null, sizeOnDisk = null, txouts = null, peers = null,
|
|
309
|
+
chain = null, state = null,
|
|
310
|
+
} = sync;
|
|
311
|
+
const out = [];
|
|
312
|
+
// When we cannot know the height, say why in the row itself. An unexplained
|
|
313
|
+
// blank reads as a broken monitor -- the node usually told us what it is doing.
|
|
314
|
+
if (state === STATE.UNKNOWN && sync.reason) out.push({ label: 'node says', value: sync.reason, tone: 'warn', title: sync.reason });
|
|
315
|
+
if (chain) out.push({ label: 'chain', value: chain, tone: '' });
|
|
316
|
+
if (height != null) {
|
|
317
|
+
out.push({
|
|
318
|
+
label: 'height',
|
|
319
|
+
value: headers != null && headers !== height ? `${fmtNum(height)} / ${fmtNum(headers)}` : fmtNum(height),
|
|
320
|
+
tone: '',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (behind != null && behind > 0) out.push({ label: 'behind', value: fmtNum(behind), tone: 'accent' });
|
|
324
|
+
if (Number.isFinite(blockRatePerSec)) {
|
|
325
|
+
const tone = rateTrend === 'decelerating' || rateTrend === 'stalled' ? 'bad' : rateTrend === 'accelerating' ? 'ok' : '';
|
|
326
|
+
out.push({
|
|
327
|
+
label: 'rate',
|
|
328
|
+
// The arrow is the whole trend story, so the word "decelerating" does not
|
|
329
|
+
// have to spend the row's width. (An earlier .trim() here ate the space and
|
|
330
|
+
// rendered "0.15 blk/s↘".)
|
|
331
|
+
value: `${blockRatePerSec >= 10 ? blockRatePerSec.toFixed(0) : blockRatePerSec.toFixed(2)} blk/s${ARROW[rateTrend] ? ` ${ARROW[rateTrend]}` : ''}`,
|
|
332
|
+
title: rateTrend && rateTrend !== 'steady' && rateTrend !== 'unknown' ? `throughput is ${rateTrend}` : null,
|
|
333
|
+
tone,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (eta) out.push({ label: 'eta', value: eta, tone: 'accent' });
|
|
337
|
+
// Tip age is withheld during initial block download. The node is replaying
|
|
338
|
+
// history, so the tip block is old by definition -- the live node showed
|
|
339
|
+
// "tip 4929.1d" while downloading at 10 blk/s, which reads like a 13-year
|
|
340
|
+
// stall and is not a fact about responsiveness at all. Near the tip
|
|
341
|
+
// (catching_up, synced, stalled) the same figure genuinely is useful.
|
|
342
|
+
if (tipAgeSec != null && state !== STATE.IBD) out.push({ label: 'tip', value: tipAgeSec < 90 ? `${Math.round(tipAgeSec)}s` : tipAgeSec < 5400 ? `${Math.round(tipAgeSec / 60)}m` : tipAgeSec < 172800 ? `${(tipAgeSec / 3600).toFixed(1)}h` : `${(tipAgeSec / 86400).toFixed(1)}d`, tone: tipAgeSec > 2400 && state !== STATE.SYNCED ? 'bad' : '' });
|
|
343
|
+
if (sizeOnDisk != null) out.push({ label: 'on disk', value: fmtBytes(sizeOnDisk), tone: '' });
|
|
344
|
+
if (txouts != null) out.push({ label: 'utxos', value: shortNum(txouts), tone: '' });
|
|
345
|
+
if (peers != null) out.push({ label: 'peers', value: String(peers), tone: '' });
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ARROW = { accelerating: '\u2197', decelerating: '\u2198', stalled: '\u2014' };
|
|
350
|
+
// 'steady' and 'unknown' deliberately have no entry: no mark is the correct mark
|
|
351
|
+
// for a rate that is doing nothing notable.
|
|
352
|
+
|
|
353
|
+
function fmtNum(n) {
|
|
354
|
+
return Number(n).toLocaleString('en-US');
|
|
355
|
+
}
|
|
356
|
+
function shortNum(n) {
|
|
357
|
+
const a = Math.abs(n);
|
|
358
|
+
if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
|
359
|
+
if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
360
|
+
if (a >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
361
|
+
return String(n);
|
|
362
|
+
}
|
|
363
|
+
function fmtBytes(n) {
|
|
364
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
365
|
+
let v = Math.abs(n);
|
|
366
|
+
let i = 0;
|
|
367
|
+
while (v >= 1000 && i < u.length - 1) { v /= 1000; i += 1; }
|
|
368
|
+
return `${v.toFixed(i === 0 ? 0 : v >= 100 ? 0 : 1)}${u[i]}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function stateLabel(state) {
|
|
372
|
+
return {
|
|
373
|
+
[STATE.UNKNOWN]: 'Unknown',
|
|
374
|
+
[STATE.IBD]: 'Initial block download',
|
|
375
|
+
[STATE.CATCHING_UP]: 'Catching up',
|
|
376
|
+
[STATE.SYNCED]: 'Synced',
|
|
377
|
+
[STATE.STALLED]: 'Stalled',
|
|
378
|
+
[STATE.REORG]: 'Reorganising',
|
|
379
|
+
}[state] ?? state;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
function clamp(v) {
|
|
384
|
+
if (!Number.isFinite(v)) return null;
|
|
385
|
+
return Math.min(1, Math.max(0, v));
|
|
386
|
+
}
|