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.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. 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
+ }