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.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- 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
|
-
|
|
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.
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
242
|
-
|
|
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);
|
package/server/collect/mining.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|