blockyard 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +251 -1
  2. package/README.md +42 -23
  3. package/bin/blockyard.js +2 -1
  4. package/docs/API.md +16 -14
  5. package/docs/ARCHITECTURE.md +92 -5
  6. package/docs/CONFIGURATION.md +33 -26
  7. package/docs/GETTING-STARTED.md +5 -2
  8. package/docs/INSTALL.md +90 -33
  9. package/docs/MEASUREMENTS.md +147 -0
  10. package/docs/SECURITY.md +32 -15
  11. package/docs/TROUBLESHOOTING.md +35 -1
  12. package/docs/USER-GUIDE.md +266 -26
  13. package/package.json +1 -1
  14. package/public/404.html +1 -1
  15. package/public/css/app.css +306 -82
  16. package/public/donate-qr.png +0 -0
  17. package/public/index.html +295 -103
  18. package/public/js/agents.js +228 -51
  19. package/public/js/app.js +82 -8
  20. package/public/js/blockscene3d.js +179 -27
  21. package/public/js/charts.js +21 -21
  22. package/public/js/depthchart.js +31 -27
  23. package/public/js/details3d.js +1456 -71
  24. package/public/js/doom.js +31 -0
  25. package/public/js/dosaudio.js +48 -0
  26. package/public/js/dosgame.js +389 -0
  27. package/public/js/dosio.js +186 -0
  28. package/public/js/dospc.js +1353 -0
  29. package/public/js/dosworker.js +196 -0
  30. package/public/js/login.js +5 -0
  31. package/public/js/markets.js +46 -8
  32. package/public/js/mining.js +310 -32
  33. package/public/js/panels.js +14 -10
  34. package/public/js/pricechart.js +14 -13
  35. package/public/js/quake.js +20 -0
  36. package/public/js/settings.js +103 -21
  37. package/public/js/soundcard.js +459 -0
  38. package/public/js/theme.js +235 -0
  39. package/public/js/wolf3d.js +22 -0
  40. package/public/js/x86.js +1978 -0
  41. package/scripts/donate-qr.py +12 -9
  42. package/scripts/dos-bench.js +56 -0
  43. package/scripts/setup.js +34 -12
  44. package/scripts/shots.mjs +6 -0
  45. package/scripts/smoke.sh +1 -1
  46. package/scripts/tls.js +31 -0
  47. package/server/chain/index/build.js +21 -4
  48. package/server/collect/monitor.js +30 -1
  49. package/server/collect/network.js +295 -0
  50. package/server/config.js +46 -22
  51. package/server/http/api.js +49 -5
  52. package/server/http/games.js +77 -0
  53. package/server/http/server.js +8 -0
  54. package/server/main.js +53 -8
  55. package/server/tls/selfsigned.js +160 -0
  56. package/systemd/blockyard.service +7 -5
  57. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  58. package/docs/STATE-2026-09-09.md +0 -200
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- # Regenerate the donation QR in public/index.html (the <svg class="abqr">) from the address below,
3
- # with OpenCV's encoder, and check the modules written decode back to the address. Run by hand when
4
- # the address changes; the SVG is committed, so nothing is generated at run time or in the browser.
2
+ # Regenerate the donation QR (public/donate-qr.png, 4 px a module with the 4-module quiet zone) from
3
+ # the address below, with OpenCV's encoder, and check the modules written decode back to the address.
4
+ # Run by hand when the address changes; the PNG is committed, so nothing is generated at run time or
5
+ # in the browser. It was an inline SVG until 2026-09-15, when Safari painted its ground and none of
6
+ # its module rects; a bitmap is painted the same everywhere.
5
7
  import cv2, numpy as np, re, sys
6
8
  ADDR = 'bc1q249cv27lc2q7y0x53vkczgfvvgsjzhwxwv42gc'
7
9
  img = cv2.QRCodeEncoder.create().encode(ADDR)
@@ -12,9 +14,10 @@ for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
12
14
  x, y = int(mm.group(1)), int(mm.group(2)); m[y*8:(y+1)*8, x*8:(x+1)*8] = 0
13
15
  data, _, _ = cv2.QRCodeDetector().detectAndDecode(m)
14
16
  if data != ADDR: sys.exit(f'the QR does not decode to the address: {data!r}')
15
- svg = f'<svg class="abqr" viewBox="0 0 {size} {size}" width="132" height="132" role="img" aria-label="QR code of the donation address" shape-rendering="crispEdges"><rect width="{size}" height="{size}" fill="#fff"/><path fill="#000" d="{d}"/></svg>'
16
- p = 'public/index.html'; s = open(p).read()
17
- s2 = re.sub(r'<svg class="abqr".*?</svg>', svg, s, count=1, flags=re.S)
18
- if s2 == s and svg not in s: sys.exit('no <svg class="abqr"> in public/index.html to replace')
19
- open(p, 'w').write(s2)
20
- print(f'{n}x{n} modules, decodes to {data}')
17
+ png = np.full((size * 4, size * 4), 255, np.uint8)
18
+ for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
19
+ x, y = int(mm.group(1)), int(mm.group(2)); png[y*4:(y+1)*4, x*4:(x+1)*4] = 0
20
+ cv2.imwrite('public/donate-qr.png', png)
21
+ check, _, _ = cv2.QRCodeDetector().detectAndDecode(cv2.imread('public/donate-qr.png'))
22
+ if check != ADDR: sys.exit(f'the PNG does not decode to the address: {check!r}')
23
+ print(f'{n}x{n} modules, public/donate-qr.png decodes to {check}')
@@ -0,0 +1,56 @@
1
+ // How fast the DOS Diversions' emulated PC runs on this machine, headless (docs/MEASUREMENTS.md §32).
2
+ //
3
+ // Boots a game from games/ on a clock that counts instructions, lets its title and demos run, and
4
+ // reports instructions a second of wall time and the screens it drew. No browser: the same x86.js
5
+ // and dospc.js the worker runs, on Node's V8.
6
+ //
7
+ // node scripts/dos-bench.js [doom|quake] [millions of instructions]
8
+ //
9
+ // DOOM runs at 30 million instructions to the virtual second (a fast 486); Quake at 70 million and
10
+ // with `+timedemo demo1`, so its screens drawn over the virtual time are the frame rate its own
11
+ // benchmark reports on a machine exactly as fast as this emulator.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { createPC } from '../public/js/dospc.js';
16
+ import { createSoundCard } from '../public/js/soundcard.js';
17
+
18
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const game = process.argv[2] === 'quake' ? 'quake' : 'doom';
20
+ const SPEC = {
21
+ doom: { dir: 'doom_dos', exe: 'DOOM.EXE', args: '', ips: 30e6, total: 400 },
22
+ quake: { dir: 'quake_dos', exe: 'QUAKE.EXE', args: '-nocdaudio +timedemo demo1', ips: 70e6, total: 3000 },
23
+ }[game];
24
+ const DIR = path.join(ROOT, 'games', SPEC.dir);
25
+ const total = Number(process.argv[3] ?? SPEC.total) * 1e6;
26
+
27
+ if (!fs.existsSync(path.join(DIR, SPEC.exe))) {
28
+ console.error(`no ${path.join(DIR, SPEC.exe)}: this measures the shareware ${game}, which is not here`);
29
+ process.exit(1);
30
+ }
31
+ const files = {};
32
+ const walk = (dir, pre) => {
33
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ if (e.name.startsWith('.')) continue;
35
+ if (e.isDirectory()) walk(path.join(dir, e.name), `${pre}${e.name}/`);
36
+ else files[`${pre}${e.name}`] = new Uint8Array(fs.readFileSync(path.join(dir, e.name)));
37
+ }
38
+ };
39
+ walk(DIR, '');
40
+
41
+ let pc;
42
+ const now = () => (pc ? pc.cpu.cycles : 0) / SPEC.ips * 1000;
43
+ pc = createPC({ files, now, args: SPEC.args, sound: (mem) => createSoundCard({ mem, rate: 44100 }) });
44
+ pc.boot(files[SPEC.exe]);
45
+
46
+ const t0 = process.hrtime.bigint();
47
+ let done = 0, graphicsAt = null;
48
+ while (done < total && !pc.exited) {
49
+ done += pc.run(1e6);
50
+ if (graphicsAt === null && pc.vga.mode === 0x13) graphicsAt = done;
51
+ }
52
+ const secs = Number(process.hrtime.bigint() - t0) / 1e9;
53
+ console.log(`${game} on node ${process.version}`);
54
+ console.log(`${(done / 1e6).toFixed(0)} M instructions in ${secs.toFixed(2)} s: ${(done / secs / 1e6).toFixed(1)} M a second`);
55
+ console.log(`graphics mode after ${(graphicsAt / 1e6).toFixed(1)} M instructions`);
56
+ console.log(`${(pc.vga.frames / 2).toFixed(0)} pages flipped, ${(pc.vga.writes / 64000).toFixed(0)} screens' worth written, in ${(done / SPEC.ips).toFixed(1)} virtual seconds`);
package/scripts/setup.js CHANGED
@@ -19,6 +19,8 @@
19
19
  import { existsSync, statSync, writeFileSync, copyFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import net from 'node:net';
22
+ import http from 'node:http';
23
+ import https from 'node:https';
22
24
  import path from 'node:path';
23
25
  import readline from 'node:readline/promises';
24
26
  import { stdin, stdout } from 'node:process';
@@ -177,15 +179,24 @@ export const RPC_PORT = { main: 8332, test: 18332, testnet4: 48332, signet: 3833
177
179
 
178
180
  /** Is a BlockYard (or anything) already answering on this port? */
179
181
  export async function portInUse(host, port) {
180
- const at = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}/api/health`;
181
- try {
182
- const r = await fetch(at, { signal: AbortSignal.timeout(1500) });
183
- const j = await r.json().catch(() => null);
184
- return { busy: true, blockyard: j?.version ?? null };
185
- } catch (err) {
186
- const refused = /ECONNREFUSED/.test(err?.cause?.code ?? '') || /ECONNREFUSED/.test(err?.message ?? '') || err?.name === 'TimeoutError';
187
- return { busy: !refused, blockyard: null };
188
- }
182
+ // HTTPS is the default (2026-09-15) and its certificate is self-signed, so the probe tries
183
+ // https first without checking the chain, then plain http
184
+ const h = host === '0.0.0.0' ? '127.0.0.1' : host;
185
+ const probe = (mod, scheme) => new Promise((resolve) => {
186
+ const req = mod.get({ host: h, port, path: '/api/health', timeout: 1500, rejectUnauthorized: false }, (res) => {
187
+ let text = '';
188
+ res.on('data', (d) => { text += d; });
189
+ res.on('end', () => { let j = null; try { j = JSON.parse(text); } catch { /* not ours */ } resolve({ ok: true, scheme, blockyard: j?.version ?? null }); });
190
+ });
191
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
192
+ req.on('error', (err) => resolve({ ok: false, err }));
193
+ });
194
+ const s = await probe(https, 'https');
195
+ if (s.ok) return { busy: true, blockyard: s.blockyard };
196
+ const p = await probe(http, 'http');
197
+ if (p.ok) return { busy: true, blockyard: p.blockyard };
198
+ const refused = (e) => /ECONNREFUSED/.test(e?.code ?? '') || /ECONNREFUSED/.test(e?.message ?? '') || /timeout/.test(e?.message ?? '');
199
+ return { busy: !(refused(s.err) && refused(p.err)), blockyard: null };
189
200
  }
190
201
 
191
202
  // ------------------------------------------------------------------------------------ the flow
@@ -298,9 +309,20 @@ async function main() {
298
309
 
299
310
  // ------------------------------------------------------------------------ 3. the web interface
300
311
  out(step(3, STEPS, 'The web interface'));
301
- say(c.dim('127.0.0.1 keeps it to this machine; 0.0.0.0 opens it to everyone who can'));
302
- say(c.dim('reach the port (docs/SECURITY.md).'));
312
+ say(c.dim('127.0.0.1 (the default) keeps it to this machine -- reach it from elsewhere over an SSH'));
313
+ say(c.dim('tunnel; a LAN address, or 0.0.0.0, opens it to everyone who can reach the port. Sign-in'));
314
+ say(c.dim('is on either way: the first start prints the admin password once, and it serves HTTPS'));
315
+ say(c.dim('with a certificate it makes for itself -- expect one browser warning per address (docs/SECURITY.md).'));
303
316
  a.host = await ask('bind address', arg('host', '127.0.0.1'), validate.host);
317
+ // ANYTHING BUT LOOPBACK IS SAID TWICE (operator, 2026-09-15: "hardened and on 127.0.0.1,
318
+ // unless the user explicitly types 0.0.0.0 in the installer"): typing it is the first time,
319
+ // this question the second -- except when --host named it on the command line, which is as
320
+ // explicit as it gets
321
+ if (!['127.0.0.1', '::1'].includes(a.host) && arg('host') !== a.host) {
322
+ const who = a.host === '0.0.0.0' ? 'everyone who can reach this machine on any interface' : `everyone who can reach ${a.host}`;
323
+ say(`${c.warn('!')} ${who} will get the sign-in page on port ${arg('port', '21000')}; docs/SECURITY.md before opening it further`);
324
+ if (!(await yes(`bind ${a.host} rather than 127.0.0.1?`, false))) { a.host = '127.0.0.1'; say(c.dim('127.0.0.1 then -- an SSH tunnel reaches it from elsewhere')); }
325
+ }
304
326
  for (;;) {
305
327
  a.port = await ask('port', arg('port', '21000'), validate.port);
306
328
  const inUse = await portInUse(a.host, a.port);
@@ -386,7 +408,7 @@ async function main() {
386
408
  else say(c.dim('nothing to build'));
387
409
 
388
410
  // --------------------------------------------------------------------------------- done
389
- const url = `http://${a.host === '0.0.0.0' ? '127.0.0.1' : a.host}:${a.port}`;
411
+ const url = `https://${a.host === '0.0.0.0' ? '127.0.0.1' : a.host}:${a.port}`;
390
412
  out();
391
413
  out(box([
392
414
  `${c.bold('start it')} ${c.accent('npm start')}${!YES ? c.dim(' (or answer yes below)') : ''}`,
package/scripts/shots.mjs CHANGED
@@ -129,6 +129,8 @@ for (const [name, p, settle, height = null] of [
129
129
  // measures fine; this one is told what it is.
130
130
  ['kiosk', 'kiosk', 12000, 1000],
131
131
  ['explorer-home', 'explorer', 6000],
132
+ // the network row, the labelled pie and the year chart (2026-09-16); content-sized like the rest
133
+ ['mining', 'mining', 12000],
132
134
  ]) {
133
135
  if (!doing(name)) continue;
134
136
  try { await nav(p); done.push(await shoot(name, { settle, height })); } catch (e) { fail(name, e); }
@@ -240,6 +242,10 @@ if (doing('block-space-neon')) {
240
242
  await sleep(3000);
241
243
  await evl(`document.getElementById('btnSettings')?.click()`);
242
244
  await sleep(1200);
245
+ // THE BLOCK SPACE TAB FIRST (2026-09-16): the sheet opens on its first tab, Appearance since
246
+ // the themes arrived, and a tab's controls exist only while it is the open one.
247
+ await evl(`document.querySelector('[data-cfgtab="space"]')?.click()`);
248
+ await sleep(500);
243
249
  // space.neon lives in the Block space group; find its control by data-cfg.
244
250
  const on = await evl(`(() => {
245
251
  const el=document.querySelector('[data-cfg="space.neon"]');
package/scripts/smoke.sh CHANGED
@@ -63,7 +63,7 @@ echo "== booting server on :${PORT} (fake node :${FAKE_PORT}) =="
63
63
  # signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
64
64
  # own instance further down, on its own port, so both are asserted rather than one
65
65
  # replacing the other.
66
- BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 \
66
+ BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 BLOCKYARD_TLS=0 \
67
67
  BLOCKYARD_DATA="$DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$PORT" BLOCKYARD_ADMIN_PASSWORD="$PW" \
68
68
  BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$FAKE_PORT" node server/main.js >"$DIR/server.log" 2>&1 &
69
69
  SRV=$!
package/scripts/tls.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // blockyard tls -- make (or remake) this monitor's own self-signed certificate.
3
+ //
4
+ // node scripts/tls.js [--out DIR] [--san a,b,c] [--days N] [--force] [--print]
5
+ //
6
+ // The server does this by itself on first start (HTTPS is the default), under <data>/tls,
7
+ // naming the addresses it is reached on. This command is for doing it by hand: after the
8
+ // machine's address changed, to add a name (--san), or to start over (--force). --print writes
9
+ // the certificate to stdout, for pasting into a browser's or another machine's trust store.
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import fs from 'node:fs';
13
+ import { ROOT } from '../server/config.js';
14
+ import { localAddresses } from '../server/netinfo.js';
15
+ import { ensureSelfSigned } from '../server/tls/selfsigned.js';
16
+ import crypto from 'node:crypto';
17
+
18
+ const argv = process.argv.slice(2);
19
+ const flag = (n) => argv.includes(`--${n}`);
20
+ const arg = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] != null && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
21
+ if (flag('help') || flag('h')) {
22
+ process.stdout.write('usage: blockyard tls [--out DIR] [--san a,b,c] [--days N] [--force] [--print]\n');
23
+ process.exit(0);
24
+ }
25
+ const dir = arg('out', path.join(process.env.BLOCKYARD_DATA || path.join(ROOT, 'data'), 'tls'));
26
+ const extra = (arg('san', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
27
+ const sans = ['localhost', os.hostname(), '127.0.0.1', '::1', ...localAddresses().map((a) => a.address), ...extra];
28
+ const r = ensureSelfSigned(dir, { sans, mustName: extra, days: Number(arg('days', 825)) || 825, force: flag('force') });
29
+ if (flag('print')) { process.stdout.write(fs.readFileSync(r.certFile, 'utf8')); process.exit(0); }
30
+ const names = (new crypto.X509Certificate(fs.readFileSync(r.certFile, 'utf8')).subjectAltName ?? '').split(',').map((s) => s.trim().replace(/^(DNS|IP Address):/, ''));
31
+ process.stdout.write(`${r.made ? `made a new certificate (${r.why})` : 'the certificate is current; nothing written (--force to remake it)'}\n cert ${r.certFile}\n key ${r.keyFile}\n names ${names.join(', ')}\n${r.made ? ' restart BlockYard to serve it; browsers warn once per address\n' : ''}`);
@@ -42,21 +42,38 @@ async function chainHashes(rpc, tip, onProgress, pace = null) {
42
42
  return { table, hashes };
43
43
  }
44
44
 
45
- class Pool {
46
- constructor(size, workerData) {
47
- this.workers = Array.from({ length: size }, () => new Worker(new URL('./worker.js', import.meta.url), { workerData }));
45
+ export class Pool {
46
+ constructor(size, workerData, script = new URL('./worker.js', import.meta.url)) {
47
+ this.workers = Array.from({ length: size }, () => new Worker(script, { workerData }));
48
48
  }
49
49
  // run jobs, at most one per worker; onResult may be async (it is awaited before that worker's next
50
50
  // job); `pace`, if given, is awaited before each job is handed out -- the server's background build
51
51
  // uses it to hold the workers while the node's RPC is slow, since they share its disk
52
+ //
53
+ // A WORKER THAT DIES FAILS THE RUN (2026-09-15, the first Mac install: the build sat at "scan
54
+ // 5,720 of 5,721, about 1 s left" for an hour and a half). Only `message` was listened for, so a
55
+ // worker killed outright -- out of memory is the way on a machine with four of them beside the
56
+ // node -- answered nothing, its job was never finished and never reported, and the other workers
57
+ // drained the list and left the run waiting for a reply that could not come. `exit` and `error`
58
+ // are the reply now: the run rejects, naming the job and the way to run again.
52
59
  async run(jobs, onResult, pace = null) {
53
60
  let next = 0, failed = null;
54
61
  await Promise.all(this.workers.map((w) => new Promise((resolve) => {
62
+ let current = null;
63
+ const die = (why) => {
64
+ if (failed) { resolve(); return; }
65
+ failed = new Error(`an index worker ${why} while on ${current ? JSON.stringify(current) : 'no job'} -- if the machine ran out of memory, run again with fewer workers (addressIndexWorkers in config/local.json; each needs about 2.5 GB)`);
66
+ resolve();
67
+ };
68
+ w.on('exit', (code) => { if (current) die(`exited with code ${code}`); });
69
+ w.on('error', (err) => die(`threw: ${err?.message ?? err}`));
55
70
  const go = async () => {
56
- if (failed || next >= jobs.length) { resolve(); return; }
71
+ if (failed || next >= jobs.length) { current = null; resolve(); return; }
57
72
  const job = jobs[next++];
58
73
  if (pace) { try { await pace(); } catch (err) { failed = err; resolve(); return; } }
74
+ current = job;
59
75
  w.once('message', async (msg) => {
76
+ current = null;
60
77
  if (msg.type === 'error') { failed = new Error(`${JSON.stringify(msg.job)}: ${msg.message}`); resolve(); return; }
61
78
  try { await onResult(msg); } catch (err) { failed = err; resolve(); return; }
62
79
  go();
@@ -21,6 +21,7 @@ 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
23
  import { decodeCoinbase, 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';
@@ -93,6 +94,10 @@ export class NodeMonitor extends EventEmitter {
93
94
  };
94
95
  this.miningQueue = [];
95
96
  this.miningBusy = false;
97
+ // THE NETWORK OVER A WEEK AND A YEAR (2026-09-15; network.js): rewards, the difficulty
98
+ // period, hashrate samples, a week of pool shares -- refreshed from the mid tier
99
+ // (a thin handle on this.rpc -- the constructor's `rpc` argument is the lane TIMING block, not the client)
100
+ this.network = new NetworkStats({ rpc: { batch: (calls, opts) => this.rpc.batch(calls, opts) }, log: this.log, poolMap: () => this.mining.poolMap, aliases: () => this.mining.aliases });
96
101
  // THE BLOCK BEING BUILT, assembled here from the mempool (2026-09-13; operator, on how
97
102
  // mempool.space manages this against a base Core install: "do it"). It used to be one
98
103
  // getblocktemplate call costing this node 1.3-1.5 s of its single RPC thread and 1.79 MB,
@@ -515,6 +520,9 @@ export class NodeMonitor extends EventEmitter {
515
520
  this.state.peers.connections = ni.connections;
516
521
  }
517
522
  this.state.mining = unwrap(res[1]);
523
+ // ...not in the first half minute: the boot's own backfills have the lane, and the network
524
+ // row's first gathering (144 block stats, 400 headers) can wait for the live polls to settle
525
+ if (this.miningCfg.enabled && this.state.chainInfo && Date.now() - this.state.startedAt > 30_000) this.network.refresh(this.state.chainInfo).catch(() => {});
518
526
  const tips = unwrap(res[2]);
519
527
  if (Array.isArray(tips)) {
520
528
  this.state.tips = tips;
@@ -561,8 +569,27 @@ export class NodeMonitor extends EventEmitter {
561
569
  let raw = null;
562
570
  try {
563
571
  raw = await this.rpc.call('getrawmempool', [true], { heavy: true, key: `${this.id}:pool-verbose`, priority: 6 });
572
+ // A STREAK OF STALE DROPS IS ONE STORY, NOT A HUNDRED (2026-09-15: on a day the node answered
573
+ // slowly for thirteen hours, this poll -- lowest priority, so last to the lane -- was dropped
574
+ // as stale every four minutes and each drop was its own warn event: 188 of the feed's 200
575
+ // rows, everything else pushed out). A drop now opens a quality flag that counts, one event
576
+ // marks the streak's start, and one marks its end with the count and the span.
577
+ if (this.poolDrops?.n) {
578
+ const d = this.poolDrops;
579
+ this.addEvent({ kind: 'collector_recovered', severity: 'info', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose answers again: dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} over ${Math.round((Date.now() - d.since) / 60000)} min (longest wait ${Math.round(d.maxWait / 1000)}s) -- the node's RPC was too slow for the lowest-priority poll to get a turn` });
580
+ this.clearQuality('pool-poll-dropped');
581
+ this.poolDrops = null;
582
+ }
564
583
  } catch (err) {
565
- this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
584
+ if (err?.kind === 'stale' && /dropped: waited/.test(err.message)) {
585
+ const waited = Number((/waited (\d+)ms/.exec(err.message) ?? [])[1] ?? 0);
586
+ const d = (this.poolDrops ??= { n: 0, since: Date.now(), maxWait: 0 });
587
+ d.n += 1; d.maxWait = Math.max(d.maxWait, waited);
588
+ if (d.n === 1) this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose dropped as stale (waited ${Math.round(waited / 1000)}s for the lane): the node's RPC is slow and this poll is the last in line; further drops are counted on the Node & RPC page until it answers again` });
589
+ this.flagQuality('pool-poll-dropped', `the full-pool poll (getrawmempool verbose) has been dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} since ${new Date(d.since).toISOString().slice(11, 16)} UTC (longest wait ${Math.round(d.maxWait / 1000)}s): the node's RPC is answering slowly and this lowest-priority poll waits behind the live ones; the mempool panels show their last reading meanwhile`, 'warn');
590
+ } else {
591
+ this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
592
+ }
566
593
  }
567
594
  if (raw && typeof raw === 'object') {
568
595
  this.state.mempoolDist = summarizeMempool(raw);
@@ -1996,6 +2023,7 @@ export class NodeMonitor extends EventEmitter {
1996
2023
  uploadMeasured: s.net.totalSent != null && s.net.totalSent > 0 && s.net.outBps != null,
1997
2024
  },
1998
2025
  attribution: this.miningView(),
2026
+ network: this.network.view(),
1999
2027
  blocks: {
2000
2028
  count: blocks.length,
2001
2029
  // 40 in the live frame; /api/blocks?limit= serves up to 400 for the chart.
@@ -2185,6 +2213,7 @@ export class NodeMonitor extends EventEmitter {
2185
2213
 
2186
2214
  async stop() {
2187
2215
  this.stopped = true;
2216
+ this.network?.stop();
2188
2217
  for (const t of this.tierTimers.values()) clearTimeout(t);
2189
2218
  if (this.logHealthTimer) clearInterval(this.logHealthTimer);
2190
2219
  if (this.tail) await this.tail.stop();
@@ -0,0 +1,295 @@
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 { decodeCoinbase, 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
+ withCb.forEach((x, k) => {
260
+ const tx = cbs[k]?.ok ? cbs[k].result : null;
261
+ const hex = tx?.vin?.[0]?.coinbase ?? '';
262
+ const decoded = decodeCoinbase(hex);
263
+ const tagText = decoded?.tagText ?? '';
264
+ const matched = matchPool(map, { tagText, rawHex: hex });
265
+ const rawKey = decoded?.tag ? decoded.tag : tagFingerprint(tagText); // the ledger's own key rule (minerRow)
266
+ const key = matched ? matched.key : rawKey;
267
+ const name = matched ? matched.name : (aliasFor(aliases, rawKey) ?? rawKey); // the ledger shows an unlabelled pool by its key
268
+ this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name, labelled: !!matched, tagText });
269
+ });
270
+ this.at = this.now();
271
+ }
272
+
273
+ view() {
274
+ const now = this.now();
275
+ const tip = this.tip;
276
+ const epochStart = tip != null ? tip - (tip % EPOCH) : null;
277
+ const cur = epochStart != null ? this.epochs.get(epochStart) : null;
278
+ const prev = epochStart != null ? this.epochs.get(epochStart - 1) : null;
279
+ const est = tip != null && cur ? difficultyEstimate({ height: tip, tipTime: this.tipTime, epochStartTime: cur.time, difficulty: this.difficulty, prevDifficulty: prev?.difficulty ?? null, now }) : null;
280
+ const series = hashrateSeries([...this.samples.values(), ...(tip != null && this.tipTime != null && this.difficulty != null ? [{ height: tip, time: this.tipTime, difficulty: this.difficulty }] : [])]);
281
+ const shares = poolShares([...this.pools.values()], { now, windowSec: this.windowDays * 86_400 });
282
+ const windowFloor = tip != null ? tip - Math.ceil(this.windowDays * DAY_BLOCKS * 1.15) : null;
283
+ return {
284
+ at: this.at, lastError: this.lastError, tip,
285
+ // exactly the newest 144: the map keeps a few extra rows below the window between prunes
286
+ rewards: rewardStats([...this.rewards.values()].sort((a, b) => b.height - a.height).slice(0, this.rewardBlocks)),
287
+ difficulty: this.difficulty,
288
+ adjustment: est,
289
+ halving: tip != null ? halvingInfo(tip, { now }) : null,
290
+ 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
291
+ hashrate: { networkHashPs: this.networkHashPs, windowBlocks: DAY_BLOCKS * this.windowDays, series },
292
+ pools: { ...shares, filled: this.pools.size, todo: this.poolTodo.length, filling: this.poolFilling, floor: windowFloor },
293
+ };
294
+ }
295
+ }