blockyard 0.0.9 → 0.1.1

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 (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -106,6 +106,22 @@ export const BOOKS = {
106
106
  export const DEPTH_STEP = 50; // dollars a level on the depth grid: fixed, so snapshots compare as the price moves
107
107
  export const DEPTH_SPAN = 0.12; // how far either side of the mid the grid reaches
108
108
  export const DEPTH_AGOS = [60, 300, 600, 1800, 3600];
109
+ // BOUNDS ON WHAT AN EXCHANGE CAN MAKE US DO (2026-09-16, audit L5). The grid size was
110
+ // ceil(mid x 0.24 / 50) with the mid taken from the books themselves, so one absurd book --
111
+ // bid 60,000, ask 2e10, answering alone -- made 48 million levels, blocked pollBooks for
112
+ // 10 s and held 5 GB for the hour the snapshot is kept. None of that takes a malicious
113
+ // exchange, only a broken, compromised or intercepted one. So:
114
+ // - a book whose mid is more than DEPTH_SANE from the median of every other book and
115
+ // every recent ticker is not drawn; its row says why (an exchange answering nonsense
116
+ // is reported, never silently dropped -- rule 8);
117
+ // - the grid never exceeds DEPTH_MAX_LEVELS (at $50 a level that is a mid near $400,000
118
+ // before it bites; today's is about 370 levels);
119
+ // - a reply body is read up to MAX_BODY_BYTES and no further (Coinbase's whole book, the
120
+ // largest, measured 1.1 MB on 2026-09-11), and a redirect is an error, not a new host.
121
+ export const DEPTH_SANE = 0.2;
122
+ export const DEPTH_MAX_LEVELS = 2000;
123
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024;
124
+ const TICKER_REF_MS = 600_000; // how old a ticker may be and still vouch for a book's price
109
125
  const r3 = (v) => Math.round(v * 1000) / 1000;
110
126
 
111
127
  // A book as cumulative depth on the grid p0 + i*step: bids[i] is the BTC bid at or above that
@@ -136,11 +152,36 @@ export function depthOf(book, p0, n, step = DEPTH_STEP) {
136
152
  }
137
153
 
138
154
  const UA = 'BlockYard (self-hosted Bitcoin node monitor)';
155
+
156
+ // A reply body, read no further than `limit` bytes (2026-09-16, audit L5). A declared
157
+ // Content-Length past the limit is refused before reading; otherwise the stream is read
158
+ // chunk by chunk and cancelled the moment it passes the limit, so a hostile or broken
159
+ // endpoint costs at most `limit` bytes of memory, not whatever it cares to send inside
160
+ // the 8 s timeout. A reply object with no stream (the tests' stubs) falls back to json().
161
+ export async function readJsonCapped(r, limit = MAX_BODY_BYTES) {
162
+ const declared = Number(r.headers?.get?.('content-length'));
163
+ if (Number.isFinite(declared) && declared > limit) throw new Error(`reply of ${declared} bytes is over the ${limit}-byte limit`);
164
+ const reader = r.body?.getReader?.();
165
+ if (!reader) return r.json();
166
+ const chunks = [];
167
+ let total = 0;
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (done) break;
171
+ total += value.byteLength;
172
+ if (total > limit) {
173
+ reader.cancel().catch(() => {});
174
+ throw new Error(`reply over the ${limit}-byte limit`);
175
+ }
176
+ chunks.push(value);
177
+ }
178
+ return JSON.parse(Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8'));
179
+ }
139
180
  const median = (xs) => (xs.length ? (xs.length % 2 ? xs[(xs.length - 1) / 2] : (xs[xs.length / 2 - 1] + xs[xs.length / 2]) / 2) : null);
140
181
 
141
182
  export class MarketFeed {
142
183
  constructor(cfg = {}, { log = () => {}, fetchImpl = globalThis.fetch, now = Date.now, exchanges = EXCHANGES } = {}) {
143
- this.cfg = { tickerMs: 15_000, candleMs: 300_000, bookMs: 30_000, idleAfterMs: 600_000, timeoutMs: 8_000, candles: 168, ...cfg };
184
+ this.cfg = { tickerMs: 15_000, candleMs: 300_000, bookMs: 30_000, idleAfterMs: 600_000, timeoutMs: 8_000, candles: 168, maxBodyBytes: MAX_BODY_BYTES, ...cfg };
144
185
  this.log = log;
145
186
  this.fetch = fetchImpl;
146
187
  this.now = now;
@@ -180,9 +221,11 @@ export class MarketFeed {
180
221
  }
181
222
 
182
223
  async get(url) {
183
- const r = await this.fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, signal: AbortSignal.timeout(this.cfg.timeoutMs) });
224
+ // redirect 'error' (2026-09-16, audit L5): every URL above is the exchange's own API
225
+ // host; a 3xx pointing somewhere else is not followed.
226
+ const r = await this.fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(this.cfg.timeoutMs) });
184
227
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
185
- return r.json();
228
+ return readJsonCapped(r, this.cfg.maxBodyBytes);
186
229
  }
187
230
 
188
231
  async pollTickers() {
@@ -231,15 +274,41 @@ export class MarketFeed {
231
274
  }
232
275
  }));
233
276
  if (!books.size) return;
234
- const mids = [...books.values()].map((b) => {
277
+ const midOf = (b) => {
235
278
  let bb = -Infinity, ba = Infinity;
236
279
  for (const [p] of b.bids) if (p > bb) bb = p;
237
280
  for (const [p] of b.asks) if (p < ba) ba = p;
238
281
  return (bb + ba) / 2;
239
- }).sort((a, b) => a - b);
282
+ };
283
+ const bookMids = new Map([...books].map(([id, b]) => [id, midOf(b)]));
284
+ // Sanity (2026-09-16, audit L5): each book against everyone else -- the other books'
285
+ // mids and every ticker younger than ten minutes. With one book answering, the tickers
286
+ // are the check; with nothing to compare against, the book stands as before.
287
+ const tickers = this.exchanges.map((ex) => this.rows.get(ex.id)?.ticker)
288
+ .filter((t) => t?.last > 0 && this.now() - t.at <= TICKER_REF_MS).map((t) => t.last);
289
+ for (const [id, m] of bookMids) {
290
+ const others = [...[...bookMids].filter(([k]) => k !== id).map(([, v]) => v), ...tickers].filter((v) => Number.isFinite(v) && v > 0).sort((a, b) => a - b);
291
+ const ref = median(others);
292
+ const bad = !(Number.isFinite(m) && m > 0) ? 'no usable mid price'
293
+ : ref != null && Math.abs(m - ref) / ref > DEPTH_SANE ? `mid ${Math.round(m)} is more than ${DEPTH_SANE * 100}% from the other exchanges (${Math.round(ref)}); book not drawn`
294
+ : null;
295
+ if (bad) {
296
+ books.delete(id);
297
+ const row = this.rows.get(id);
298
+ if (row) row.bookError = { message: bad, at: this.now() };
299
+ }
300
+ }
301
+ if (!books.size) return;
302
+ const mids = [...books.keys()].map((id) => bookMids.get(id)).sort((a, b) => a - b);
240
303
  const mid = median(mids);
241
- const p0 = Math.floor((mid * (1 - DEPTH_SPAN)) / DEPTH_STEP) * DEPTH_STEP;
242
- const n = Math.ceil((mid * 2 * DEPTH_SPAN) / DEPTH_STEP) + 1;
304
+ let p0 = Math.floor((mid * (1 - DEPTH_SPAN)) / DEPTH_STEP) * DEPTH_STEP;
305
+ let n = Math.ceil((mid * 2 * DEPTH_SPAN) / DEPTH_STEP) + 1;
306
+ if (n > DEPTH_MAX_LEVELS) {
307
+ // Clamped: keep the mid in the middle of the grid rather than letting the grid stop
308
+ // short of it.
309
+ n = DEPTH_MAX_LEVELS;
310
+ p0 = Math.max(0, Math.floor((mid - (n / 2) * DEPTH_STEP) / DEPTH_STEP) * DEPTH_STEP);
311
+ }
243
312
  const snap = { at: this.now(), mid, p0, n, ex: {} };
244
313
  for (const [id, bk] of books) snap.ex[id] = depthOf(bk, p0, n);
245
314
  this.depthHist.push(snap);
@@ -43,6 +43,16 @@ export function parsePushes(hex) {
43
43
  const op = bytes[i];
44
44
  let n = op;
45
45
  let head = 1;
46
+ // 2026-09-16 (audit L4): a scriptSig may END on an OP_PUSHDATA opcode with its length
47
+ // bytes missing -- consensus allows any bytes after the BIP34 height, so a pool can
48
+ // put this in its own block for free. readUInt16LE/readUInt32LE past the end threw
49
+ // ERR_OUT_OF_RANGE, the monitor's lane retried that block forever and every later
50
+ // block waited behind it. The length is read only when all of its bytes are there;
51
+ // a truncated frame header ends the walk like any other unparseable frame.
52
+ if (op === 0x4c) head = 2;
53
+ else if (op === 0x4d) head = 3;
54
+ else if (op === 0x4e) head = 5;
55
+ if (i + head > bytes.length) break;
46
56
  if (op === 0x4c) { n = bytes[i + 1]; head = 2; }
47
57
  else if (op === 0x4d) { n = bytes.readUInt16LE(i + 1); head = 3; }
48
58
  else if (op === 0x4e) { n = bytes.readUInt32LE(i + 1); head = 5; }
@@ -54,6 +64,26 @@ export function parsePushes(hex) {
54
64
  return { consumed: i, total: bytes.length, pushes: out };
55
65
  }
56
66
 
67
+ /**
68
+ * decodeCoinbase that cannot throw (2026-09-16, audit L4). The decoder is meant never to
69
+ * throw on any bytes, and the fuzz test holds it to that; this is the belt to that pair of
70
+ * braces for the two lanes that call it on untrusted blocks. A coinbase that still fails
71
+ * to decode is a fact about THAT block -- permanent, so it is recorded as unparseable (an
72
+ * unknown pool, `decodeError` saying why) and the lane moves on. Retrying it would only
73
+ * stall every block queued behind it, which is exactly what L4 found.
74
+ */
75
+ export function decodeCoinbaseSafe(hex) {
76
+ try {
77
+ return decodeCoinbase(hex);
78
+ } catch (err) {
79
+ return {
80
+ parseable: false, truncatedAt: 0, height: null, tagText: null, tag: null,
81
+ commitment: null, extraNonce: null, raw: typeof hex === 'string' ? hex : '',
82
+ decodeError: String(err?.message ?? err),
83
+ };
84
+ }
85
+ }
86
+
57
87
  /**
58
88
  * The longest printable prefix of a push. Pool tags and extra nonces share one frame
59
89
  * more often than they get a frame each, so "is this frame text" is the wrong question;
@@ -257,6 +287,8 @@ export function minerRow({ height, hash, decoded, stats, at }) {
257
287
  extraNonce: decoded?.extraNonce ?? null,
258
288
  commitment: decoded?.commitment ?? null,
259
289
  rawCoinbase: decoded?.raw ?? null,
290
+ // 2026-09-16 (audit L4): present only when the coinbase could not be decoded at all.
291
+ ...(decoded?.decodeError ? { decodeError: decoded.decodeError } : {}),
260
292
  };
261
293
  }
262
294
 
@@ -15,12 +15,13 @@
15
15
  // empty in this deployment -- so `health.quality` records every such gap
16
16
  // rather than silently preferring one.
17
17
  import { EventEmitter } from 'node:events';
18
- import { RpcClient, RpcError } from '../rpc/client.js';
18
+ import { RpcClient, RpcError, displayUrl, shortPath } from '../rpc/client.js';
19
19
  import { LogTail } from './logtail.js';
20
20
  import { CounterRate } from '../store/ring.js';
21
21
  import { computeSync, stripFacts } from './sync.js';
22
22
  import { SHAPES, RULE_TO_SHAPE } from './logparse.js';
23
- import { decodeCoinbase, minerRow, ledgerApply, ledgerRows, aliasFor, matchPool } from './mining.js';
23
+ import { decodeCoinbaseSafe, minerRow, ledgerApply, ledgerRows, aliasFor, matchPool } from './mining.js';
24
+ import { NetworkStats } from './network.js';
24
25
  import { summarizeTemplate, packagesFromTemplate, blockEconomy, templateCells } from './nextblock.js';
25
26
  import { templateFromMempool, LOCAL_TEMPLATE_NOTE } from './gbt.js';
26
27
  import fs from 'node:fs';
@@ -38,6 +39,15 @@ export const BLOCKSTATS_FIELDS = ['totalfee', 'txs', 'total_size', 'total_weight
38
39
  'swtotal_size', 'swtxs', 'subsidy', 'utxo_increase', 'ins', 'outs', 'avgfee', 'medianfee', 'maxfee',
39
40
  'feerate_percentiles', 'height', 'blockhash', 'time', 'mediantime'];
40
41
 
42
+
43
+ // WHAT THE NODE SAYS IS DATA OF A KNOWN SHAPE, OR NOTHING (audit 2026-09-16, L2). These fields went
44
+ // from the RPC reply to the page unchecked, and the page wrote some of them as markup: a node that
45
+ // answered `chain: "main<img src=x onerror=…>"` put an element on the Chain page. The page now
46
+ // escapes them too; the server also refuses to pass on a value that is not the type it names.
47
+ export const chainName = (v) => (typeof v === 'string' && /^[a-z0-9_-]{1,24}$/i.test(v) ? v : null);
48
+ export const boolOrNull = (v) => (typeof v === 'boolean' ? v : null);
49
+ export const countOrNull = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
50
+
41
51
  export class NodeMonitor extends EventEmitter {
42
52
  constructor(nodeCfg, { rpc, poll, store, log, history, logCfg, miningCfg }) {
43
53
  super();
@@ -93,6 +103,10 @@ export class NodeMonitor extends EventEmitter {
93
103
  };
94
104
  this.miningQueue = [];
95
105
  this.miningBusy = false;
106
+ // THE NETWORK OVER A WEEK AND A YEAR (2026-09-15; network.js): rewards, the difficulty
107
+ // period, hashrate samples, a week of pool shares -- refreshed from the mid tier
108
+ // (a thin handle on this.rpc -- the constructor's `rpc` argument is the lane TIMING block, not the client)
109
+ this.network = new NetworkStats({ rpc: { batch: (calls, opts) => this.rpc.batch(calls, opts) }, log: this.log, poolMap: () => this.mining.poolMap, aliases: () => this.mining.aliases });
96
110
  // THE BLOCK BEING BUILT, assembled here from the mempool (2026-09-13; operator, on how
97
111
  // mempool.space manages this against a base Core install: "do it"). It used to be one
98
112
  // getblocktemplate call costing this node 1.3-1.5 s of its single RPC thread and 1.79 MB,
@@ -515,6 +529,9 @@ export class NodeMonitor extends EventEmitter {
515
529
  this.state.peers.connections = ni.connections;
516
530
  }
517
531
  this.state.mining = unwrap(res[1]);
532
+ // ...not in the first half minute: the boot's own backfills have the lane, and the network
533
+ // row's first gathering (144 block stats, 400 headers) can wait for the live polls to settle
534
+ if (this.miningCfg.enabled && this.state.chainInfo && Date.now() - this.state.startedAt > 30_000) this.network.refresh(this.state.chainInfo).catch(() => {});
518
535
  const tips = unwrap(res[2]);
519
536
  if (Array.isArray(tips)) {
520
537
  this.state.tips = tips;
@@ -561,8 +578,27 @@ export class NodeMonitor extends EventEmitter {
561
578
  let raw = null;
562
579
  try {
563
580
  raw = await this.rpc.call('getrawmempool', [true], { heavy: true, key: `${this.id}:pool-verbose`, priority: 6 });
581
+ // A STREAK OF STALE DROPS IS ONE STORY, NOT A HUNDRED (2026-09-15: on a day the node answered
582
+ // slowly for thirteen hours, this poll -- lowest priority, so last to the lane -- was dropped
583
+ // as stale every four minutes and each drop was its own warn event: 188 of the feed's 200
584
+ // rows, everything else pushed out). A drop now opens a quality flag that counts, one event
585
+ // marks the streak's start, and one marks its end with the count and the span.
586
+ if (this.poolDrops?.n) {
587
+ const d = this.poolDrops;
588
+ 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` });
589
+ this.clearQuality('pool-poll-dropped');
590
+ this.poolDrops = null;
591
+ }
564
592
  } catch (err) {
565
- this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
593
+ if (err?.kind === 'stale' && /dropped: waited/.test(err.message)) {
594
+ const waited = Number((/waited (\d+)ms/.exec(err.message) ?? [])[1] ?? 0);
595
+ const d = (this.poolDrops ??= { n: 0, since: Date.now(), maxWait: 0 });
596
+ d.n += 1; d.maxWait = Math.max(d.maxWait, waited);
597
+ 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` });
598
+ 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');
599
+ } else {
600
+ this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
601
+ }
566
602
  }
567
603
  if (raw && typeof raw === 'object') {
568
604
  this.state.mempoolDist = summarizeMempool(raw);
@@ -861,7 +897,11 @@ export class NodeMonitor extends EventEmitter {
861
897
  const rtRes = await this.rpc.batch([{ method: 'getrawtransaction', params: [cbTxid, 2] }], { priority: 3 });
862
898
  if (!rtRes?.[0]?.ok) throw new Error(rtRes?.[0]?.error?.message ?? 'getrawtransaction unanswered');
863
899
  const vin0 = rtRes[0].result?.vin?.[0] ?? {};
864
- const decoded = decodeCoinbase(vin0.coinbase ?? vin0.coinbaseHex ?? '');
900
+ // 2026-09-16 (audit L4): everything above that throws is the node not answering, and
901
+ // pumpMining puts the height back and backs off. What follows is the block's own bytes,
902
+ // and a coinbase we cannot read will read the same way next time -- so it never throws:
903
+ // the block is recorded as unparseable (an unknown pool) and the queue moves on.
904
+ const decoded = decodeCoinbaseSafe(vin0.coinbase ?? vin0.coinbaseHex ?? '');
865
905
  const stats = this.state.blocks.get(height) ?? {};
866
906
  const row = minerRow({
867
907
  height, hash,
@@ -1794,8 +1834,8 @@ export class NodeMonitor extends EventEmitter {
1794
1834
  label: s.label,
1795
1835
  color: s.color,
1796
1836
  online: this.rpc.telemetry().online,
1797
- chain: s.chain,
1798
- ibd: s.chainInfo?.initialblockdownload ?? null,
1837
+ chain: chainName(s.chain),
1838
+ ibd: boolOrNull(s.chainInfo?.initialblockdownload),
1799
1839
  tip: {
1800
1840
  height: s.chainInfo?.blocks ?? null,
1801
1841
  headers: s.chainInfo?.headers ?? null,
@@ -1814,12 +1854,12 @@ export class NodeMonitor extends EventEmitter {
1814
1854
  sync: computeSync({
1815
1855
  blocks: s.chainInfo?.blocks ?? null,
1816
1856
  headers: s.chainInfo?.headers ?? null,
1817
- ibd: s.chainInfo?.initialblockdownload ?? null,
1857
+ ibd: boolOrNull(s.chainInfo?.initialblockdownload),
1818
1858
  verificationProgress: s.chainInfo?.verificationprogress ?? null,
1819
1859
  tipTime: s.chainInfo?.time ?? null,
1820
1860
  bestHash: s.chainInfo?.bestblockhash ?? null,
1821
1861
  sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
1822
- chain: s.chain,
1862
+ chain: chainName(s.chain),
1823
1863
  warnings: s.chainInfo?.warnings ?? [],
1824
1864
  blockRatePerSec: this.blockRateFast.rate(),
1825
1865
  blockRateFastSpanMs: this.blockRateFast.span,
@@ -1848,7 +1888,7 @@ export class NodeMonitor extends EventEmitter {
1848
1888
  hashrateNote: hashrateSuppressed ?? (hashrateRaw == null ? 'difficulty or a block-gap sample is missing' : null),
1849
1889
  avgBlockGapSec: avgGap,
1850
1890
  sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
1851
- pruned: s.chainInfo?.pruned ?? null,
1891
+ pruned: boolOrNull(s.chainInfo?.pruned),
1852
1892
  chainwork: s.chainInfo?.chainwork ?? null,
1853
1893
  uptimeSec: s.uptimeSec ?? null,
1854
1894
  network: s.networkInfo ? {
@@ -1874,7 +1914,7 @@ export class NodeMonitor extends EventEmitter {
1874
1914
  minFee: mi.mempoolminfee ?? null,
1875
1915
  minRelayFee: mi.minrelaytxfee ?? null,
1876
1916
  incrementalRelayFee: mi.incrementalrelayfee ?? null,
1877
- unbroadcast: mi.unbroadcastcount ?? null,
1917
+ unbroadcast: countOrNull(mi.unbroadcastcount),
1878
1918
  maxDataCarrier: mi.maxdatacarriersize ?? null,
1879
1919
  permitBareMultisig: mi.permitbaremultisig ?? null,
1880
1920
  ingestRate: s.logState.relayRate ?? s.logState.acceptRate ?? null,
@@ -1996,6 +2036,7 @@ export class NodeMonitor extends EventEmitter {
1996
2036
  uploadMeasured: s.net.totalSent != null && s.net.totalSent > 0 && s.net.outBps != null,
1997
2037
  },
1998
2038
  attribution: this.miningView(),
2039
+ network: this.network.view(),
1999
2040
  blocks: {
2000
2041
  count: blocks.length,
2001
2042
  // 40 in the live frame; /api/blocks?limit= serves up to 400 for the chart.
@@ -2010,7 +2051,7 @@ export class NodeMonitor extends EventEmitter {
2010
2051
  indexes: s.indexes,
2011
2052
  tips: s.tips,
2012
2053
  deployments: s.deployments,
2013
- rpcInfo: s.rpcInfo,
2054
+ rpcInfo: s.rpcInfo ? { ...s.rpcInfo, logpath: shortPath(s.rpcInfo.logpath) } : null, // no directories to viewers (audit 2026-09-16, L11)
2014
2055
  log: {
2015
2056
  ...(this.tail ? this.tail.status() : { exists: false, file: null }),
2016
2057
  // 'disabled' is a configuration decision; 'missing' would be a fault. The
@@ -2100,7 +2141,7 @@ export class NodeMonitor extends EventEmitter {
2100
2141
  // the bar without drawing which node it belongs to.
2101
2142
  out.sync.node = s.id;
2102
2143
  out.sync.nodeLabel = s.label;
2103
- out.sync.endpoint = this.cfg.rpcUrl;
2144
+ out.sync.endpoint = displayUrl(this.cfg.rpcUrl); // no userinfo to viewers (audit 2026-09-16, L3)
2104
2145
  // One dense row for every state; `strip` is the ordered facts to draw in it.
2105
2146
  out.sync.strip = stripFacts(out.sync);
2106
2147
  return out;
@@ -2185,6 +2226,7 @@ export class NodeMonitor extends EventEmitter {
2185
2226
 
2186
2227
  async stop() {
2187
2228
  this.stopped = true;
2229
+ this.network?.stop();
2188
2230
  for (const t of this.tierTimers.values()) clearTimeout(t);
2189
2231
  if (this.logHealthTimer) clearInterval(this.logHealthTimer);
2190
2232
  if (this.tail) await this.tail.stop();
@@ -0,0 +1,305 @@
1
+ // THE NETWORK OVER A WEEK AND A YEAR, from this node alone (operator, 2026-09-15, with a
2
+ // mempool.space screenshot of its mining dashboard: "Why don't we have this view in our mining
3
+ // tab?" -- "Do it", then the adjustments table: "add this too"). Nothing here is fetched from
4
+ // anyone but the node:
5
+ //
6
+ // rewards the last 144 blocks' subsidy, fees and transaction counts -- getblockstats, one
7
+ // call per block, batched, rolled forward a block at a time
8
+ // epochs the start of each difficulty period back a year: its height, time and difficulty
9
+ // (getblockhash + getblockheader, two in-memory lookups per epoch) -- the
10
+ // adjustment table, the estimate for the current period, the chart's steps
11
+ // samples a block every 144 back a year, with its time and difficulty: the daily hashrate
12
+ // estimate is difficulty * 2^32 / the mean interval across the day
13
+ // pools the coinbase of every block of the last week, attributed the way the ledger
14
+ // does it (decodeCoinbase + the curated pool map) -- a week is ~1000 blocks at
15
+ // two calls each, so it fills in the background at eight blocks every few seconds
16
+ // and the view says how far it has got
17
+ //
18
+ // The arithmetic is in pure functions above the class, which is what the tests hold.
19
+ import { decodeCoinbaseSafe, matchPool, aliasFor, tagFingerprint } from './mining.js';
20
+
21
+ export const EPOCH = 2016;
22
+ export const TARGET_SPACING = 600;
23
+ export const HALVING_INTERVAL = 210_000;
24
+ const DAY_BLOCKS = 144;
25
+
26
+ /** Sum a window of getblockstats rows into the reward figures. Satoshis in, satoshis out. */
27
+ export function rewardStats(rows) {
28
+ const r = rows.filter((x) => x && Number.isFinite(x.totalfee) && Number.isFinite(x.subsidy));
29
+ if (!r.length) return { blocks: 0, minersRewardSat: 0, avgBlockFeeSat: null, avgTxFeeSat: null, txs: 0, from: null, to: null };
30
+ const fees = r.reduce((a, x) => a + x.totalfee, 0);
31
+ const subsidy = r.reduce((a, x) => a + x.subsidy, 0);
32
+ const txs = r.reduce((a, x) => a + Math.max(0, (x.txs ?? 1) - 1), 0); // the coinbase pays no fee
33
+ const hs = r.map((x) => x.height);
34
+ return {
35
+ blocks: r.length,
36
+ minersRewardSat: fees + subsidy,
37
+ avgBlockFeeSat: Math.round(fees / r.length),
38
+ avgTxFeeSat: txs ? Math.round(fees / txs) : null,
39
+ txs,
40
+ from: Math.min(...hs), to: Math.max(...hs),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Where the current difficulty period stands and what the next adjustment looks like, from the
46
+ * tip's height and time, the period's first block time, and the two difficulties.
47
+ * The estimate is what Core would compute if the rest of the period kept the pace so far,
48
+ * clamped to the protocol's factor-of-four bounds. Before ten blocks there is no pace to speak
49
+ * of, so the estimate is null.
50
+ */
51
+ export function difficultyEstimate({ height, tipTime, epochStartTime, difficulty, prevDifficulty = null, now = Date.now() }) {
52
+ if (!Number.isFinite(height) || !Number.isFinite(tipTime) || !Number.isFinite(epochStartTime)) return null;
53
+ const epochStart = height - (height % EPOCH);
54
+ const into = height - epochStart; // blocks mined since the period's first
55
+ const remaining = EPOCH - into;
56
+ const elapsed = Math.max(1, tipTime - epochStartTime); // seconds over `into` intervals
57
+ const pace = into >= 10 ? elapsed / into : null;
58
+ let estimatePct = null;
59
+ if (pace != null) {
60
+ const projected = pace * EPOCH;
61
+ const factor = Math.max(0.25, Math.min(4, (EPOCH * TARGET_SPACING) / projected));
62
+ estimatePct = (factor - 1) * 100;
63
+ }
64
+ const previousPct = Number.isFinite(prevDifficulty) && prevDifficulty > 0 && Number.isFinite(difficulty) ? (difficulty / prevDifficulty - 1) * 100 : null;
65
+ const etaSec = remaining * (pace ?? TARGET_SPACING);
66
+ return { epochStart, into, remaining, elapsedSec: elapsed, paceSec: pace, estimatePct, previousPct, etaSec, at: now + etaSec * 1000 };
67
+ }
68
+
69
+ /** The next halving: its height, the blocks to go, and when at the target spacing. */
70
+ export function halvingInfo(height, { now = Date.now() } = {}) {
71
+ if (!Number.isFinite(height)) return null;
72
+ const nextHeight = (Math.floor(height / HALVING_INTERVAL) + 1) * HALVING_INTERVAL;
73
+ const blocksLeft = nextHeight - height;
74
+ const etaSec = blocksLeft * TARGET_SPACING;
75
+ return { nextHeight, blocksLeft, etaSec, at: now + etaSec * 1000, era: Math.floor(height / HALVING_INTERVAL) };
76
+ }
77
+
78
+ /**
79
+ * Hashrate estimates from block samples ({ height, time, difficulty }), any spacing: between two
80
+ * consecutive samples the mean interval is (t1 - t0) / (h1 - h0), and the hashrate that produces
81
+ * one block per that interval at the later sample's difficulty is difficulty * 2^32 / interval.
82
+ * Ascending by height; a sample with a non-positive interval (clock skew) is skipped.
83
+ */
84
+ export function hashrateSeries(samples) {
85
+ const s = [...samples].filter((x) => x && Number.isFinite(x.height) && Number.isFinite(x.time) && Number.isFinite(x.difficulty)).sort((a, b) => a.height - b.height);
86
+ const out = [];
87
+ for (let i = 1; i < s.length; i++) {
88
+ const dh = s[i].height - s[i - 1].height, dt = s[i].time - s[i - 1].time;
89
+ if (dh <= 0 || dt <= 0) continue;
90
+ const interval = dt / dh;
91
+ out.push({ t: s[i].time * 1000, height: s[i].height, hashrate: s[i].difficulty * 4294967296 / interval, difficulty: s[i].difficulty });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** The adjustment table: each epoch start against the one before it. Newest first. */
97
+ export function adjustments(epochs) {
98
+ const e = [...epochs].filter((x) => x && Number.isFinite(x.height) && Number.isFinite(x.difficulty)).sort((a, b) => a.height - b.height);
99
+ const out = [];
100
+ for (let i = 1; i < e.length; i++) {
101
+ out.push({ height: e[i].height, time: e[i].time, difficulty: e[i].difficulty, changePct: e[i - 1].difficulty > 0 ? (e[i].difficulty / e[i - 1].difficulty - 1) * 100 : null });
102
+ }
103
+ return out.reverse();
104
+ }
105
+
106
+ /**
107
+ * Pool shares over the window: blocks inside it grouped by pool, share of the window, and the
108
+ * window's luck -- the blocks found against the number the target spacing would give.
109
+ * rows: { height, time, poolKey, name, labelled }.
110
+ */
111
+ export function poolShares(rows, { now = Date.now(), windowSec = 7 * 86_400 } = {}) {
112
+ const since = now / 1000 - windowSec;
113
+ const inWin = rows.filter((r) => r && Number.isFinite(r.time) && r.time >= since);
114
+ const by = new Map();
115
+ for (const r of inWin) {
116
+ const k = r.poolKey ?? 'unknown';
117
+ const e = by.get(k) ?? { key: k, name: r.name ?? k, labelled: !!r.labelled, blocks: 0 };
118
+ e.blocks += 1;
119
+ by.set(k, e);
120
+ }
121
+ const pools = [...by.values()].sort((a, b) => b.blocks - a.blocks || a.name.localeCompare(b.name));
122
+ for (const p of pools) p.sharePct = inWin.length ? (p.blocks / inWin.length) * 100 : 0;
123
+ // luck against the span actually covered: while the week is still being read, the oldest
124
+ // block read bounds it, so a half-read week is not reported as half the luck
125
+ const oldest = inWin.length ? Math.min(...inWin.map((r) => r.time)) : null;
126
+ const spanSec = oldest == null ? windowSec : Math.min(windowSec, Math.max(TARGET_SPACING, now / 1000 - oldest));
127
+ const expected = spanSec / TARGET_SPACING;
128
+ return { blocks: inWin.length, expected, spanSec, luckPct: inWin.length ? (inWin.length / expected) * 100 : null, pools, count: pools.length, windowSec };
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------- the collector
132
+ export class NetworkStats {
133
+ /**
134
+ * @param {object} o
135
+ * @param {{batch: Function}} o.rpc the node's RPC client (batch(calls, opts))
136
+ * @param {Function} [o.log]
137
+ * @param {Function} [o.poolMap] () => the curated coinbase map, or null
138
+ * @param {Function} [o.aliases] () => the operator's alias table, or null
139
+ * @param {Function} [o.now]
140
+ */
141
+ constructor({ rpc, log = () => {}, poolMap = () => null, aliases = () => null, now = Date.now, windowDays = 7, sampleDays = 366, rewardBlocks = 144 } = {}) {
142
+ this.rpc = rpc; this.log = log; this.poolMapOf = poolMap; this.aliasesOf = aliases; this.now = now;
143
+ this.windowDays = windowDays; this.sampleDays = sampleDays; this.rewardBlocks = rewardBlocks;
144
+ this.rewards = new Map(); // height -> { height, time, totalfee, subsidy, txs }
145
+ this.epochs = new Map(); // epoch start height -> { height, time, difficulty }
146
+ this.samples = new Map(); // sample height -> { height, time, difficulty }
147
+ this.pools = new Map(); // height -> { height, time, poolKey, name, labelled, tagText }
148
+ this.tip = null; this.tipTime = null; this.difficulty = null; this.networkHashPs = null;
149
+ this.busy = false; this.stopped = false; this.lastError = null; this.at = null;
150
+ this.poolTimer = null; this.poolFilling = false; this.poolTodo = [];
151
+ }
152
+
153
+ stop() { this.stopped = true; if (this.poolTimer) clearTimeout(this.poolTimer); }
154
+
155
+ /** Called by the monitor whenever fresh chain info lands; cheap when nothing changed. */
156
+ async refresh(chainInfo) {
157
+ if (this.stopped || this.busy || !chainInfo || !Number.isFinite(chainInfo.blocks)) return;
158
+ if (chainInfo.initialblockdownload === true) return;
159
+ const tip = chainInfo.blocks;
160
+ this.busy = true;
161
+ try {
162
+ const tipChanged = tip !== this.tip;
163
+ this.tip = tip; this.difficulty = chainInfo.difficulty ?? this.difficulty;
164
+ // getblockchaininfo carries the tip's time on Core 24+; older builds get one header read
165
+ if (Number.isFinite(chainInfo.time)) this.tipTime = chainInfo.time;
166
+ else if (tipChanged || this.tipTime == null) { const h = await this.headers([tip]); this.tipTime = h.get(tip)?.time ?? this.tipTime; }
167
+ if (tipChanged || this.networkHashPs == null) {
168
+ const [hr] = await this.rpc.batch([{ method: 'getnetworkhashps', params: [DAY_BLOCKS * this.windowDays] }], { priority: 6 });
169
+ if (hr?.ok) this.networkHashPs = hr.result;
170
+ }
171
+ await this.ensureRewards(tip);
172
+ await this.ensureEpochs(tip);
173
+ await this.ensureSamples(tip);
174
+ this.queuePools(tip);
175
+ this.at = this.now();
176
+ this.lastError = null;
177
+ } catch (err) {
178
+ this.lastError = err?.message ?? String(err);
179
+ } finally {
180
+ this.busy = false;
181
+ }
182
+ }
183
+
184
+ async ensureRewards(tip) {
185
+ const want = [];
186
+ for (let h = tip; h > tip - this.rewardBlocks && h > 0; h--) if (!this.rewards.has(h)) want.push(h);
187
+ for (let i = 0; i < want.length; i += 12) { // twelve block reads a batch: a step the live polls can slip between
188
+ const chunk = want.slice(i, i + 12);
189
+ const res = await this.rpc.batch(chunk.map((h) => ({ method: 'getblockstats', params: [h, ['height', 'time', 'totalfee', 'subsidy', 'txs']] })), { priority: 6, heavy: true });
190
+ res.forEach((r, k) => { if (r?.ok && r.result) this.rewards.set(chunk[k], r.result); });
191
+ }
192
+ for (const h of [...this.rewards.keys()]) if (h <= tip - this.rewardBlocks - 10) this.rewards.delete(h);
193
+ }
194
+
195
+ /** height -> { height, time, difficulty } for a list of heights, two batched in-memory lookups each. */
196
+ async headers(heights) {
197
+ const out = new Map();
198
+ for (let i = 0; i < heights.length; i += 50) {
199
+ const chunk = heights.slice(i, i + 50);
200
+ const hashes = await this.rpc.batch(chunk.map((h) => ({ method: 'getblockhash', params: [h] })), { priority: 6 });
201
+ const withHash = chunk.map((h, k) => ({ h, hash: hashes[k]?.ok ? hashes[k].result : null })).filter((x) => x.hash);
202
+ if (!withHash.length) continue;
203
+ const hdrs = await this.rpc.batch(withHash.map((x) => ({ method: 'getblockheader', params: [x.hash] })), { priority: 6 });
204
+ hdrs.forEach((r, k) => { if (r?.ok && r.result) out.set(withHash[k].h, { height: withHash[k].h, time: r.result.time, difficulty: r.result.difficulty }); });
205
+ }
206
+ return out;
207
+ }
208
+
209
+ async ensureEpochs(tip) {
210
+ const epochStart = tip - (tip % EPOCH);
211
+ const perYear = Math.ceil((this.sampleDays * DAY_BLOCKS) / EPOCH) + 1;
212
+ const want = [];
213
+ for (let i = 0; i <= perYear; i++) { const h = epochStart - i * EPOCH; if (h >= 0 && !this.epochs.has(h)) want.push(h); }
214
+ // the block before the current period too, for the previous adjustment's base difficulty
215
+ if (epochStart > 0 && !this.epochs.has(epochStart - 1)) want.push(epochStart - 1);
216
+ if (!want.length) return;
217
+ for (const [h, v] of await this.headers(want)) this.epochs.set(h, v);
218
+ }
219
+
220
+ async ensureSamples(tip) {
221
+ const anchor = tip - (tip % DAY_BLOCKS);
222
+ const want = [];
223
+ for (let i = 0; i <= this.sampleDays; i++) { const h = anchor - i * DAY_BLOCKS; if (h >= 0 && !this.samples.has(h)) want.push(h); }
224
+ if (want.length) for (const [h, v] of await this.headers(want)) this.samples.set(h, v);
225
+ for (const h of [...this.samples.keys()]) if (h < anchor - (this.sampleDays + 2) * DAY_BLOCKS) this.samples.delete(h);
226
+ }
227
+
228
+ // ---- the week of coinbases, in the background
229
+ queuePools(tip) {
230
+ const floor = Math.max(1, tip - Math.ceil(this.windowDays * DAY_BLOCKS * 1.15)); // a little past a week, then the view trims by time
231
+ const todo = [];
232
+ for (let h = tip; h >= floor; h--) if (!this.pools.has(h)) todo.push(h);
233
+ this.poolTodo = todo;
234
+ for (const h of [...this.pools.keys()]) if (h < floor - 50) this.pools.delete(h);
235
+ if (todo.length && !this.poolFilling) this.pumpPools();
236
+ }
237
+
238
+ pumpPools() {
239
+ if (this.stopped) return;
240
+ this.poolFilling = true;
241
+ const chunk = this.poolTodo.splice(0, 8);
242
+ if (!chunk.length) { this.poolFilling = false; return; }
243
+ this.fetchPools(chunk)
244
+ .catch((err) => { this.lastError = `pools: ${err?.message ?? err}`; this.poolTodo.unshift(...chunk); })
245
+ .finally(() => {
246
+ if (this.stopped) return;
247
+ this.poolTimer = setTimeout(() => this.pumpPools(), this.lastError ? 15_000 : 3_000);
248
+ this.poolTimer.unref?.();
249
+ });
250
+ }
251
+
252
+ async fetchPools(heights) {
253
+ const hashes = await this.rpc.batch(heights.map((h) => ({ method: 'getblockhash', params: [h] })), { priority: 7 });
254
+ const withHash = heights.map((h, k) => ({ h, hash: hashes[k]?.ok ? hashes[k].result : null })).filter((x) => x.hash);
255
+ const blocks = await this.rpc.batch(withHash.map((x) => ({ method: 'getblock', params: [x.hash, 1] })), { priority: 7, heavy: true });
256
+ const withCb = withHash.map((x, k) => ({ ...x, block: blocks[k]?.ok ? blocks[k].result : null })).filter((x) => x.block?.tx?.length);
257
+ const cbs = await this.rpc.batch(withCb.map((x) => ({ method: 'getrawtransaction', params: [x.block.tx[0], 2, x.hash] })), { priority: 7, heavy: true });
258
+ const map = this.poolMapOf(), aliases = this.aliasesOf();
259
+ // 2026-09-16 (audit L4): one block's coinbase used to be able to throw out of this loop,
260
+ // and pumpPools then put the whole chunk of eight back at the front and retried it every
261
+ // 15 s -- forever, since the bytes never change. Only the RPC calls above are worth a
262
+ // retry. A block whose coinbase cannot be read is a permanent fact about that block: it
263
+ // is recorded as an unknown pool (`unparseable`) and the week keeps filling.
264
+ withCb.forEach((x, k) => {
265
+ const tx = cbs[k]?.ok ? cbs[k].result : null;
266
+ const hex = typeof tx?.vin?.[0]?.coinbase === 'string' ? tx.vin[0].coinbase : '';
267
+ try {
268
+ const decoded = decodeCoinbaseSafe(hex);
269
+ const tagText = decoded?.tagText ?? '';
270
+ const matched = matchPool(map, { tagText, rawHex: hex });
271
+ const rawKey = decoded?.tag ? decoded.tag : tagFingerprint(tagText); // the ledger's own key rule (minerRow)
272
+ const key = matched ? matched.key : rawKey;
273
+ const name = matched ? matched.name : (aliasFor(aliases, rawKey) ?? rawKey); // the ledger shows an unlabelled pool by its key
274
+ this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name, labelled: !!matched, tagText, ...(decoded?.decodeError ? { unparseable: true } : {}) });
275
+ } catch {
276
+ const key = tagFingerprint('');
277
+ this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name: key, labelled: false, tagText: '', unparseable: true });
278
+ }
279
+ });
280
+ this.at = this.now();
281
+ }
282
+
283
+ view() {
284
+ const now = this.now();
285
+ const tip = this.tip;
286
+ const epochStart = tip != null ? tip - (tip % EPOCH) : null;
287
+ const cur = epochStart != null ? this.epochs.get(epochStart) : null;
288
+ const prev = epochStart != null ? this.epochs.get(epochStart - 1) : null;
289
+ const est = tip != null && cur ? difficultyEstimate({ height: tip, tipTime: this.tipTime, epochStartTime: cur.time, difficulty: this.difficulty, prevDifficulty: prev?.difficulty ?? null, now }) : null;
290
+ const series = hashrateSeries([...this.samples.values(), ...(tip != null && this.tipTime != null && this.difficulty != null ? [{ height: tip, time: this.tipTime, difficulty: this.difficulty }] : [])]);
291
+ const shares = poolShares([...this.pools.values()], { now, windowSec: this.windowDays * 86_400 });
292
+ const windowFloor = tip != null ? tip - Math.ceil(this.windowDays * DAY_BLOCKS * 1.15) : null;
293
+ return {
294
+ at: this.at, lastError: this.lastError, tip,
295
+ // exactly the newest 144: the map keeps a few extra rows below the window between prunes
296
+ rewards: rewardStats([...this.rewards.values()].sort((a, b) => b.height - a.height).slice(0, this.rewardBlocks)),
297
+ difficulty: this.difficulty,
298
+ adjustment: est,
299
+ halving: tip != null ? halvingInfo(tip, { now }) : null,
300
+ adjustments: adjustments([...this.epochs.values()].filter((e) => e.height % EPOCH === 0)).slice(0, 27), // a year and a period; the card shows six, View more all
301
+ hashrate: { networkHashPs: this.networkHashPs, windowBlocks: DAY_BLOCKS * this.windowDays, series },
302
+ pools: { ...shares, filled: this.pools.size, todo: this.poolTodo.length, filling: this.poolFilling, floor: windowFloor },
303
+ };
304
+ }
305
+ }