blockyard 0.0.1 → 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 (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en" data-blockyard-build="%BLOCKYARD_BUILD%">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>sign in - BlockYard</title>
8
+ <link rel="stylesheet" href="/css/app.css">
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ffb85c%22%2F%3E%3Cstop%20offset%3D%22.55%22%20stop-color%3D%22%23f7931a%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23a8640d%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227.5%22%20fill%3D%22url%28%23g%29%22%2F%3E%3Cg%20fill%3D%22%2316110a%22%20opacity%3D%22.14%22%3E%3Crect%20x%3D%224%22%20y%3D%2224.6%22%20width%3D%228%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3Crect%20x%3D%2213%22%20y%3D%2224.6%22%20width%3D%226%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3Crect%20x%3D%2220%22%20y%3D%2224.6%22%20width%3D%228%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3C%2Fg%3E%3Cpath%20fill%3D%22%231a1206%22%20fill-rule%3D%22evenodd%22%20d%3D%22M5%208h6.6a4.2%204.2%200%200%201%202.35%207.66A4.4%204.4%200%200%201%2012%2024H5Zm3.3%202.9v3.5h3.1a1.75%201.75%200%200%200%200-3.5Zm0%206.4v3.8h3.4a1.9%201.9%200%200%200%200-3.8Z%22%2F%3E%3Cpath%20fill%3D%22%231a1206%22%20d%3D%22M16.8%208h3.3l2.75%205.3L25.6%208h3.3l-4.85%208.8V24h-3.3v-7.2Z%22%2F%3E%3C%2Fsvg%3E">
10
+ </head>
11
+ <body class="login-body">
12
+ <div class="login-card">
13
+ <h1>Block<b>Yard</b></h1>
14
+ <p class="sub" id="sub">Bitcoin node monitor</p>
15
+
16
+ <div class="err hidden" id="err"></div>
17
+
18
+ <form id="f" autocomplete="on">
19
+ <div class="field">
20
+ <label for="u">username</label>
21
+ <input id="u" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
22
+ </div>
23
+ <div class="field">
24
+ <label for="p">password</label>
25
+ <input id="p" name="password" type="password" autocomplete="current-password" required>
26
+ </div>
27
+ <button class="btn primary block" id="go" type="submit">sign in</button>
28
+ </form>
29
+ <p class="note tiny faint mt-14" id="hint">Read-only by default. This tool cannot start, stop or reconfigure the node unless the operator has explicitly enabled specific actions.</p>
30
+ </div>
31
+ <script type="module" src="/js/login.js"></script>
32
+ </body>
33
+ </html>
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // What would an address index over this node's chain cost? Measured on the node's own files, not
3
+ // estimated from blog posts.
4
+ //
5
+ // node scripts/blockfile-measure.js [--dir <datadir>/blocks] [--files 0,500,...] [--sample N] [--verify]
6
+ //
7
+ // For each sampled blkNNNNN.dat and its revNNNNN.dat it times, separately: the disk read, the XOR,
8
+ // decoding every block (transactions, output scripts, addresses), pairing each block with its undo
9
+ // record (shape, then Core's checksum), and decoding the undo data (every spent output, with its
10
+ // address). It counts what an index would have to store -- a funding row per spendable output, a
11
+ // spending row per input -- and extrapolates across all files by interpolating between the samples,
12
+ // because file contents change a great deal over the chain's history.
13
+ //
14
+ // --verify also checks one block per sampled file against getblock <hash> 3 (every prevout), so the
15
+ // numbers are never measured on a decoder that is quietly wrong.
16
+ //
17
+ // Read-only: the node's files are opened for reading and nothing else.
18
+ import { openSync, readSync, closeSync, fstatSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { createHash } from 'node:crypto';
21
+ import { Reader, readHeader, readTx, classifyScript } from '../server/chain/tx.js';
22
+ import { xorKey, unxor, records, decodeBlockUndo, pairBlocksWithUndo, MAGIC } from '../server/chain/blockfile.js';
23
+
24
+ const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
25
+ const dir = arg('dir', '/storage/core-oracle/blocks');
26
+ const all = readdirSync(dir).filter((f) => /^blk\d{5}\.dat$/.test(f)).sort();
27
+ const last = all.length - 1;
28
+ // the newest file is still being written, so the default sample stops one short of it
29
+ const sample = arg('files', null)
30
+ ? arg('files').split(',').map(Number)
31
+ : Array.from({ length: Number(arg('sample', 16)) }, (_, i) => Math.round((i * (last - 1)) / (Number(arg('sample', 16)) - 1)));
32
+ const key = xorKey(dir);
33
+ const verify = process.argv.includes('--verify');
34
+ const ms = (t0) => performance.now() - t0;
35
+
36
+ let rpcCall = null;
37
+ if (verify) {
38
+ const { loadConfig } = await import('../server/config.js');
39
+ const { RpcClient } = await import('../server/rpc/client.js');
40
+ const cfg = loadConfig();
41
+ const node = cfg.nodes.find((n) => n.datadir && dir.startsWith(n.datadir)) ?? cfg.nodes[0];
42
+ const rpc = new RpcClient(node, { ...(cfg.rpc ?? {}), ...(node.rpc ?? {}) }, { log: { info() {}, warn() {}, error() {}, debug() {} } });
43
+ rpcCall = async (method, params) => {
44
+ const [r] = await rpc.batch([{ method, params }], { key: `measure:${method}:${params[0]}`, timeoutMs: 600_000, maxWaitMs: 600_000 });
45
+ if (!r.ok) throw new Error(`${method}: ${r.error?.message}`);
46
+ return r.result;
47
+ };
48
+ }
49
+
50
+ function readRaw(file) {
51
+ const fd = openSync(file, 'r');
52
+ try {
53
+ const size = fstatSync(fd).size;
54
+ const buf = Buffer.allocUnsafe(size);
55
+ let got = 0;
56
+ while (got < size) { const n = readSync(fd, buf, got, size - got, got); if (n === 0) break; got += n; }
57
+ return buf.subarray(0, got);
58
+ } finally { closeSync(fd); }
59
+ }
60
+
61
+ const rows = [];
62
+ for (const n of sample) {
63
+ const id = String(n).padStart(5, '0');
64
+ const r = { file: n };
65
+ let t = performance.now();
66
+ const blk = readRaw(path.join(dir, `blk${id}.dat`));
67
+ const rev = readRaw(path.join(dir, `rev${id}.dat`));
68
+ r.readMs = ms(t); r.blkMB = blk.length / 1e6; r.revMB = rev.length / 1e6;
69
+ t = performance.now(); unxor(blk, key); unxor(rev, key); r.xorMs = ms(t);
70
+
71
+ // blocks: full decode, addresses included (classifyScript runs per output inside readTx)
72
+ t = performance.now();
73
+ const blocks = [];
74
+ let txs = 0, outputs = 0, inputs = 0, funding = 0, height0 = null;
75
+ const scripts = new Set();
76
+ for (const rec of records(blk, MAGIC.main)) {
77
+ const rd = new Reader(rec.body);
78
+ const header = readHeader(rd);
79
+ const ntx = rd.varint();
80
+ const inCounts = [];
81
+ for (let i = 0; i < ntx; i++) {
82
+ const tx = readTx(rd);
83
+ txs++; outputs += tx.vout.length;
84
+ if (i > 0) { inputs += tx.vin.length; inCounts.push(tx.vin.length); }
85
+ for (const o of tx.vout) if (o.scriptPubKey.type !== 'nulldata') { funding++; scripts.add(createHash('sha256').update(o.scriptPubKey.hex).digest('base64').slice(0, 11)); }
86
+ }
87
+ blocks.push({ header, ntx, inCounts });
88
+ }
89
+ r.decodeMs = ms(t);
90
+ Object.assign(r, { blocks: blocks.length, txs, outputs, inputs, funding, distinctScripts: scripts.size });
91
+
92
+ // pair each block with its undo record: tx count first, then Core's checksum
93
+ t = performance.now();
94
+ const undos = [...records(rev, MAGIC.main, 32)];
95
+ const pairs = pairBlocksWithUndo(blocks.map((b) => ({ hash: b.header.hash, previousblockhash: b.header.previousblockhash, ntx: b.ntx })), undos);
96
+ const paired = [...pairs].map(([i, u]) => [blocks[i], u]);
97
+ r.pairMs = ms(t); r.paired = paired.length; r.undoRecords = undos.length;
98
+
99
+ // undo: every spent coin, with the address its script pays
100
+ t = performance.now();
101
+ let spent = 0, special = 0;
102
+ for (const [, u] of paired) {
103
+ for (const coins of decodeBlockUndo(u.body)) for (const c of coins) {
104
+ spent++;
105
+ classifyScript(c.script);
106
+ if (c.script.length === 67) special++; // an uncompressed-key P2PK, rebuilt from 33 bytes
107
+ }
108
+ }
109
+ r.undoMs = ms(t); r.spent = spent; r.p2pkUncompressedSpent = special;
110
+
111
+ if (verify && paired.length) {
112
+ const [b, u] = paired[Math.floor(paired.length / 2)];
113
+ const want = await rpcCall('getblock', [b.header.hash, 3]);
114
+ const undo = decodeBlockUndo(u.body);
115
+ let bad = 0, checked = 0;
116
+ want.tx.slice(1).forEach((tx, i) => tx.vin.forEach((vin, j) => {
117
+ const c = undo[i][j]; const p = vin.prevout; checked++;
118
+ if (c.value_sat !== Math.round(p.value * 1e8) || c.script.toString('hex') !== p.scriptPubKey.hex || c.height !== p.height || c.coinbase !== p.generated) bad++;
119
+ }));
120
+ r.verified = { height: want.height, coins: checked, mismatches: bad };
121
+ height0 = want.height;
122
+ }
123
+ rows.push(r);
124
+ const f = (v, d = 0) => v.toFixed(d);
125
+ console.log(`blk${id}: ${f(r.blkMB)}+${f(r.revMB)} MB read ${f(r.readMs)} ms, xor ${f(r.xorMs)}, decode ${f(r.decodeMs)}, pair ${f(r.pairMs)}, undo ${f(r.undoMs)} | ${r.blocks} blocks (${r.paired} paired), ${txs} tx, ${outputs} out, ${inputs} in${r.verified ? ` | verified h${r.verified.height}: ${r.verified.coins} coins, ${r.verified.mismatches} mismatches` : ''}${height0 == null ? '' : ''}`);
126
+ }
127
+
128
+ // extrapolate across every file by linear interpolation between sampled files
129
+ const at = (k, fileNo) => {
130
+ const s = rows;
131
+ if (fileNo <= s[0].file) return s[0][k];
132
+ for (let i = 1; i < s.length; i++) if (fileNo <= s[i].file) {
133
+ const a = s[i - 1], b = s[i]; const u = (fileNo - a.file) / Math.max(1, b.file - a.file);
134
+ return a[k] + (b[k] - a[k]) * u;
135
+ }
136
+ return s[s.length - 1][k];
137
+ };
138
+ const total = (k) => { let v = 0; for (let fno = 0; fno <= last; fno++) v += at(k, fno); return v; };
139
+ const T = Object.fromEntries(['blkMB', 'revMB', 'readMs', 'xorMs', 'decodeMs', 'pairMs', 'undoMs', 'txs', 'outputs', 'inputs', 'funding', 'spent'].map((k) => [k, total(k)]));
140
+ const h = (x) => (x / 3.6e6).toFixed(2);
141
+ const G = (x) => (x / 1e9).toFixed(2);
142
+ const summary = {
143
+ files: all.length, sampled: sample.length,
144
+ data: { blkGB: +(T.blkMB / 1e3).toFixed(1), revGB: +(T.revMB / 1e3).toFixed(1) },
145
+ counts: { txs: Math.round(T.txs), outputs: Math.round(T.outputs), inputs: Math.round(T.inputs), fundingRows: Math.round(T.funding), spendingRows: Math.round(T.spent) },
146
+ singleCoreHours: { read: +h(T.readMs), xor: +h(T.xorMs), decodeBlocks: +h(T.decodeMs), pairUndo: +h(T.pairMs), decodeUndo: +h(T.undoMs), cpuTotal: +h(T.xorMs + T.decodeMs + T.pairMs + T.undoMs) },
147
+ // raw row bytes for two designs, before any storage-engine overhead
148
+ indexGB: {
149
+ historyOnly: +G((T.funding + T.spent) * 12), // script-hash prefix 8 B + height 4 B, and outpoint prefix 8 B + height 4 B
150
+ withValues: +G((T.funding + T.spent) * 20), // the same plus an 8 B amount, so a balance needs no node call
151
+ },
152
+ };
153
+ console.log(JSON.stringify(summary, null, 1));
154
+ const out = arg('json', null);
155
+ if (out) writeFileSync(out, JSON.stringify({ measuredAt: new Date().toISOString(), dir, sample: rows, summary }, null, 1) + '\n');
156
+ process.exit(0);
@@ -0,0 +1,286 @@
1
+ // Look at the page with a real browser, and ask the vision model what it sees.
2
+ //
3
+ // Two kinds of evidence, deliberately kept apart:
4
+ // HARD -- pixels sampled in the page by Runtime.evaluate, and element rects. The
5
+ // canvas is same-origin, so getImageData answers "did anything actually get
6
+ // painted, and in which colours" without any judgement at all.
7
+ // SOFT -- a screenshot handed to the local vision model (qwen3.8 on the bench host).
8
+ // Good for "does this read as a block being filled", "is the legend legible",
9
+ // "do these overlap". Bad at precise numbers. Where the two disagree, the
10
+ // pixels win, and the report says so.
11
+ //
12
+ // Zero dependencies: Chromium speaks CDP over a plain WebSocket, and Node 22 ships one.
13
+ //
14
+ // BLOCKYARD_BASE=https://<lan>:8088 [BROWSER_CDP=http://127.0.0.1:9333] \
15
+ // [VISION_BASE=http://198.51.100.20:8888 VISION_MODEL=<model-id>] \
16
+ // node scripts/browser-check.mjs [overview|mining]
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ const BASE = process.env.BLOCKYARD_BASE;
20
+ const CDP = process.env.BROWSER_CDP ?? 'http://127.0.0.1:9333';
21
+ const VISION_BASE = process.env.VISION_BASE; // the local vision model, e.g. vLLM on the bench host
22
+ const VISION_MODEL = process.env.VISION_MODEL ?? 'qwen3.8-flash-next';
23
+ const PAGE = process.argv[2] ?? 'mining';
24
+ if (!BASE) {
25
+ console.log('usage: BLOCKYARD_BASE=https://<address>:8088 node scripts/browser-check.mjs [overview|mining]');
26
+ console.log(' needs a headless chromium with --remote-debugging-port and --ignore-certificate-errors');
27
+ process.exit(2);
28
+ }
29
+ const URL = `${BASE}/#${PAGE}`;
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ // ------------------------------------------------------------------ CDP client
33
+ const targets = JSON.parse(await (await fetch(`${CDP}/json/list`)).text());
34
+ const page = targets.find((t) => t.type === 'page');
35
+ if (!page) { console.log('no page target; is chromium running headless with --remote-debugging-port?'); process.exit(1); }
36
+
37
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
38
+ await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
39
+
40
+ let nextId = 1;
41
+ const pending = new Map();
42
+ const events = [];
43
+ const consoleErrors = [];
44
+ const exceptions = [];
45
+ ws.onmessage = (ev) => {
46
+ const msg = JSON.parse(ev.data);
47
+ if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
48
+ if (msg.method) {
49
+ events.push(msg.method);
50
+ if (msg.method === 'Runtime.exceptionThrown') {
51
+ exceptions.push(msg.params.exceptionDetails?.exception?.description ?? msg.params.exceptionDetails?.text);
52
+ }
53
+ if (msg.method === 'Runtime.consoleAPICalled' && ['error', 'warning'].includes(msg.params.type)) {
54
+ consoleErrors.push((msg.params.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(' '));
55
+ }
56
+ if (msg.method === 'Log.entryAdded' && ['error', 'warning'].includes(msg.params.entry.level)) {
57
+ consoleErrors.push(`[${msg.params.entry.source}] ${msg.params.entry.text}`.slice(0, 200));
58
+ }
59
+ }
60
+ };
61
+ const send = (method, params = {}) => new Promise((res) => {
62
+ const id = nextId++;
63
+ pending.set(id, (msg) => res(msg.result ?? msg.error ?? {}));
64
+ ws.send(JSON.stringify({ id, method, params }));
65
+ });
66
+
67
+ await send('Page.enable');
68
+ await send('Runtime.enable');
69
+ await send('Log.enable');
70
+ await send('Network.enable');
71
+ await send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1900, deviceScaleFactor: 1, mobile: false });
72
+
73
+ const loadWait = new Promise((res) => {
74
+ const wait = (ev) => { if (ev === 'Page.loadEventFired') res(true); };
75
+ const orig = ws.onmessage;
76
+ ws.onmessage = (m) => { orig(m); wait(JSON.parse(m.data).method); };
77
+ setTimeout(() => res(false), 25_000);
78
+ });
79
+ // Cache-bust deliberately: Page.navigate to a URL that differs from the current one only
80
+ // in its #fragment is a NO-OP, so reusing a still-open browser silently re-measures the
81
+ // document that was loaded before the last deploy. Every "old code path" and "blank
82
+ // canvas" reading this harness ever produced against a warm browser was this.
83
+ //
84
+ // ...and the query-string bust is NOT enough on its own. It changes the
85
+ // DOCUMENT url only; the ES MODULES the document imports keep their own urls,
86
+ // so a warm browser serves them from cache and the harness measures the
87
+ // PREVIOUS build's JavaScript against the current build's HTML. Measured
88
+ // 2026-09-10: the viewer's projection was replaced outright, the server was
89
+ // confirmed to be serving the new file, every unit test agreed -- and the
90
+ // screenshot kept showing the old camera, from a headless shell that had been
91
+ // up 48 hours. Disabling the network cache is what makes this harness
92
+ // trustworthy for a JS change at all.
93
+ await send('Network.enable', {});
94
+ await send('Network.setCacheDisabled', { cacheDisabled: true });
95
+ const busted = URL + (URL.includes('?') ? '&' : '?') + 't=' + Date.now();
96
+ await send('Page.navigate', { url: busted });
97
+ const loaded = await loadWait;
98
+ // A document can load and still not be the app (a redirect to the login page loads
99
+ // perfectly well). Assert the shell is here before judging anything drawn inside it.
100
+ // NOTE what this does NOT check: document.body.firstChild. Leading whitespace is a text
101
+ // node in every ordinary document, and an earlier throwaway probe asserted on exactly
102
+ // that and "proved" the app rendered nothing when it had rendered fine all along.
103
+ const shell = await send('Runtime.evaluate', {
104
+ expression: `JSON.stringify({ bodyKids: document.body.children.length, nav: !!document.querySelector('#nav'), login: location.pathname.startsWith('/login') })`,
105
+ returnByValue: true,
106
+ }).then((r) => JSON.parse(r?.result?.value || '{}'));
107
+ if (!shell.bodyKids || !shell.nav || shell.login) {
108
+ console.error(`not the app: bodyKids=${shell.bodyKids} nav=${shell.nav} path=${shell.login ? '/login' : '?'}`);
109
+ await browser.close();
110
+ process.exit(1);
111
+ }
112
+ // The mining page asks the node for a block template on load (~1.3 s) and the map tweens
113
+ // for ~520 ms after that. Judge nothing before it has settled.
114
+ //
115
+ // AND switch the SPA to the requested page the way a user does — by clicking the nav
116
+ // button. Loading `/#mining` in this harness has repeatedly measured the OVERVIEW instead
117
+ // (every canvas zeroSized or from the wrong page), because the hash route is applied
118
+ // inside boot's async tail and a hash-only change on a warm document is no kind of
119
+ // navigation at all (rule 25). Clicking is deterministic; hoping for the hash is not.
120
+ await sleep(3000);
121
+ if (PAGE !== 'overview') {
122
+ await send('Runtime.evaluate', { expression: `document.querySelector('#nav button[data-page=${JSON.stringify(PAGE)}]')?.click()` });
123
+ }
124
+ await sleep(4000);
125
+ // Prove we are on the page we claim to be measuring, before judging a single pixel.
126
+ const onPage = JSON.parse(await send('Runtime.evaluate', {
127
+ expression: `(() => { const on = document.querySelector('.page.on'); return JSON.stringify({page: on?.dataset?.page ?? null, hash: location.hash}) })()`,
128
+ returnByValue: true,
129
+ }).then((r) => r?.result?.value ?? '{}'));
130
+ if (onPage.page && onPage.page !== PAGE) {
131
+ console.error(`not on #${PAGE}: the app is showing "${onPage.page}". The page switch failed; every measurement below would describe the wrong page.`);
132
+ ws.close();
133
+ process.exit(1);
134
+ }
135
+
136
+ // Refuse to review a page that did not load. Without this guard a broken invocation
137
+ // (an empty BLOCKYARD_BASE once sent it to https://:8088) produced a blank screenshot,
138
+ // the vision model confidently reported "the page is completely empty", and that read
139
+ // would have been believed. Check the document before asking anyone to judge it.
140
+ const sanity = await send('Runtime.evaluate', {
141
+ expression: `JSON.stringify({
142
+ href: location.href,
143
+ title: document.title,
144
+ nav: !!document.getElementById('nav'),
145
+ nodes: document.body ? document.body.childElementCount : 0,
146
+ text: document.body ? document.body.innerText.replace(/\\s+/g,' ').slice(0,120) : ''
147
+ })`,
148
+ returnByValue: true,
149
+ });
150
+ let sanityObj = {};
151
+ try { sanityObj = JSON.parse(sanity.result?.value ?? '{}'); } catch { /* reported below */ }
152
+ if (!sanityObj.nav || !sanityObj.nodes) {
153
+ console.log(`\n== ${URL} ==`);
154
+ console.log(`ABORT: nothing to look at. href=${sanityObj.href} title=${JSON.stringify(sanityObj.title)} bodyChildren=${sanityObj.nodes}`);
155
+ console.log('The browser reached a document with no app in it. Check BLOCKYARD_BASE (and that the service is up) before reading anything into a screenshot.');
156
+ if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((e) => console.log(` ! ${e}`));
157
+ ws.close();
158
+ process.exit(3);
159
+ }
160
+
161
+ // ------------------------------------------------------- HARD: measure the page
162
+ const PROBE = () => {
163
+ const out = { canvases: {}, rects: {}, cssom: {}, text: {}, scroll: {} };
164
+ const px = (id, label) => {
165
+ const c = document.getElementById(id);
166
+ if (!(c instanceof HTMLCanvasElement)) { out.canvases[id] = { missing: true }; return; }
167
+ const r = c.getBoundingClientRect();
168
+ out.rects[id] = { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
169
+ if (!r.width || !r.height) { out.canvases[id] = { zeroSized: true }; return; }
170
+ let ctx;
171
+ try { ctx = c.getContext('2d', { willReadFrequently: true }); } catch { out.canvases[id] = { noContext: true }; return; }
172
+ const dpr = c.width / r.width || 1;
173
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
174
+ const colours = new Map();
175
+ let lit = 0;
176
+ for (let i = 0; i < data.length; i += 4 * 17) { // sampled every 17 px, fast and enough
177
+ const a = data[i + 3];
178
+ if (a < 8) continue;
179
+ const key = `${data[i] >> 3},${data[i + 1] >> 3},${data[i + 2] >> 3}`;
180
+ colours.set(key, (colours.get(key) ?? 0) + 1);
181
+ if (data[i] + data[i + 1] + data[i + 2] > 200) lit++;
182
+ }
183
+ const top = [...colours.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
184
+ out.canvases[id] = {
185
+ sampled: label, dpr: +dpr.toFixed(2),
186
+ // If nothing painted, say what the drawing code left behind: __hasData is set by
187
+ // paint(), __rects by the treemap layout. hasData=true with 0 colours means it drew
188
+ // and something wiped it; false means the draw never ran at all.
189
+ hasData: !!c.__hasData, rects: c.__rects?.size ?? 0, cells: c.__cells ?? 0,
190
+ backing: `${c.width}x${c.height}`,
191
+ distinctColours: colours.size, litPixels: lit,
192
+ topColourShares: top.map(([k, v]) => `${k}:${(100 * v / [...colours.values()].reduce((a, b) => a + b, 0)).toFixed(0)}%`),
193
+ paintedPixelsPct: +((100 * [...colours.values()].reduce((a, b) => a + b, 0)) / (data.length / (4 * 17))).toFixed(1),
194
+ };
195
+ };
196
+ ['gnMempoolTreemap', 'ovGnTreemap', 'ovTrain', 'ovMpChart', 'ovFeeChart', 'mnFeeLandscape']
197
+ .forEach((id) => px(id, document.getElementById(id) ? 'present' : 'absent'));
198
+
199
+ // CSSOM applied? If the CSP/CSSOM conversion is broken the marks have no background.
200
+ const dots = [...document.querySelectorAll('.bdot')].slice(0, 6);
201
+ out.cssom.bdotBackgrounds = dots.map((d) => getComputedStyle(d).backgroundColor);
202
+ const cards = [...document.querySelectorAll('.bcard')].slice(0, 8);
203
+ out.cssom.cardPoolVars = cards.map((c) => getComputedStyle(c).getPropertyValue('--pool').trim() || '(none)');
204
+ const rail = document.querySelector('.rail');
205
+ out.cssom.railDuration = rail ? getComputedStyle(rail).animationDuration : '(no rail)';
206
+ const bar = document.querySelector('.bfill span');
207
+ out.cssom.fillWidth = bar ? `${getComputedStyle(bar).width} (data-w=${bar.dataset?.w ?? '-'})` : '(no fill)';
208
+ // Distinguish "we never set it" from "the browser computed something else": inline CSSOM
209
+ // values are what applyMiningStyles writes, computed values are what actually renders.
210
+ out.cssom.cardInlinePool = [...document.querySelectorAll('.bcard')].slice(0, 4)
211
+ .map((c) => `${c.style.getPropertyValue('--pool') || '(unset)'}|computed:${getComputedStyle(c).getPropertyValue('--pool').trim() || '(none)'}`);
212
+
213
+ // Card geometry: overlapping cards is the classic flexbox overflow failure.
214
+ const flows = [...document.querySelectorAll('.flow, .flowwrap')];
215
+ out.rects.flowOverflow = flows.map((f) => ({ scrollW: f.scrollWidth, clientW: f.clientWidth, clipped: f.scrollWidth > f.clientWidth + 2 }));
216
+
217
+ const txt = (id) => { const el = document.getElementById(id); const s = (el?.textContent ?? '').trim(); return s.length > 160 ? `${s.slice(0, 160)}…` : s; };
218
+ ['ovFeesCard', 'ovMpVsBlock', 'ovMpVsBlockKv', 'ovMempoolCard', 'ovGnTreemapNote', 'gnMempoolNote', 'mnPools', 'ovCaveats']
219
+ .forEach((id) => { out.text[id] = txt(id); });
220
+ out.scroll = { bodyH: document.body.scrollHeight, winH: innerHeight };
221
+ return out;
222
+ };
223
+ const probed = await send('Runtime.evaluate', { expression: `(${PROBE.toString()})()`, returnByValue: true });
224
+ const hard = probed.result?.value ?? { error: probed.error ?? 'no value' };
225
+
226
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
227
+ const png = shot.data ?? null;
228
+
229
+ // ------------------------------------------------------- SOFT: ask the vision model
230
+ let soft = null;
231
+ if (png && VISION_BASE) {
232
+ const prompt = [
233
+ 'You are reviewing a screenshot of a self-hosted Bitcoin node monitor (dark theme).',
234
+ `This is the "${PAGE}" page. Report only what you can see, and say "cannot tell" rather than guessing.`,
235
+ 'Answer each numbered question in one short sentence:',
236
+ '1. Are any large areas blank, empty boxes, or placeholder dashes? Name them.',
237
+ '2. Is there a treemap of rectangles (a block-space map)? Do the rectangles vary in size and colour, and is any large unfilled band visible?',
238
+ '3. Is there a row of block cards? Read the height numbers you can see, left to right, and say whether one is marked as current.',
239
+ '4. Is any text unreadable because of low contrast, overlap, or clipping?',
240
+ '5. Does anything look obviously broken (misaligned, clipped, overlapping, wrong colours)?',
241
+ '6. What is the single most damaging visual problem on this page, if any?',
242
+ ].join('\n');
243
+ const res = await fetch(`${VISION_BASE}/v1/chat/completions`, {
244
+ method: 'POST',
245
+ headers: { 'content-type': 'application/json' },
246
+ body: JSON.stringify({
247
+ model: VISION_MODEL,
248
+ max_tokens: 700,
249
+ temperature: 0.2,
250
+ messages: [{ role: 'user', content: [
251
+ { type: 'text', text: prompt },
252
+ { type: 'image_url', image_url: { url: `data:image/png;base64,${png}` } },
253
+ ] }],
254
+ }),
255
+ });
256
+ const j = await res.json().catch(() => null);
257
+ const m = j?.choices?.[0]?.message ?? {};
258
+ // This deployment is a reasoning model: it can answer with content=null and the text in
259
+ // `reasoning`. Reading only `content` reports a confident-looking empty review, which is
260
+ // the worst possible failure for a check whose whole job is to notice problems.
261
+ soft = m.content ?? m.reasoning ?? JSON.stringify(j).slice(0, 400);
262
+ }
263
+
264
+ // ------------------------------------------------------------------- report
265
+ console.log(`\n== ${URL} ${loaded ? '' : '(load event not seen)'} ==`);
266
+ console.log(`console errors/warnings: ${consoleErrors.length}`);
267
+ consoleErrors.slice(0, 8).forEach((e) => console.log(` ! ${e}`));
268
+ if (exceptions.length) { console.log(`uncaught exceptions: ${exceptions.length}`); exceptions.slice(0, 5).forEach((e) => console.log(` !! ${String(e).split('\n')[0]}`)); }
269
+ console.log('\n-- HARD: canvas pixels sampled in the page --');
270
+ for (const [id, v] of Object.entries(hard.canvases ?? {})) console.log(` ${id.padEnd(18)} ${JSON.stringify(v)}`);
271
+ console.log('-- HARD: CSSOM + geometry --');
272
+ console.log(' ', JSON.stringify(hard.cssom));
273
+ console.log(' flow:', JSON.stringify(hard.rects?.flowOverflow));
274
+ console.log('-- HARD: text on the page --');
275
+ for (const [id, v] of Object.entries(hard.text ?? {})) if (v) console.log(` ${id.padEnd(16)}: ${String(v).slice(0, 120)}`);
276
+ console.log(` page height ${hard.scroll?.bodyH} vs window ${hard.scroll?.winH}`);
277
+ if (png) {
278
+ const fs = await import('node:fs');
279
+ const path = `/tmp/browser-check-${PAGE}.png`;
280
+ fs.writeFileSync(path, Buffer.from(png, 'base64'));
281
+ console.log(`\nscreenshot: ${path} (${Math.round(png.length * 3 / 4 / 1024)} KB)`);
282
+ }
283
+ console.log(`\n-- SOFT: ${VISION_MODEL} on the screenshot --`);
284
+ console.log(soft ? soft.split('\n').map((l) => ` ${l.trim()}`).filter((l) => l.trim()).join('\n') : ' (no VISION_BASE set, so the screenshot was not reviewed)');
285
+ console.log('\nHARD evidence is measured in the page; SOFT is a model\'s reading. Where they disagree, the pixels win.');
286
+ ws.close();
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // CHECK A NODE BEFORE RUNNING AGAINST IT: the RPC server answers, the credentials work, the
3
+ // chain is the one configured, the indexes the explorer needs are there, the block files can be
4
+ // read (the address index is built from them), the node's log is where it should be, and an
5
+ // address index, if configured, is readable, writable and not far behind.
6
+ //
7
+ // npm run check # every node in config/local.json
8
+ // node scripts/check.js --node <id>
9
+ //
10
+ // Prints one line per check and exits 1 if anything FAILED. `npm run setup` runs the same checks
11
+ // on the answers it is given before it writes config/local.json. The checks are a function
12
+ // (runChecks) so they can be tested against a stub node, with the printing kept out here.
13
+ import { existsSync, statSync, readdirSync, accessSync, readFileSync, realpathSync, constants as FS } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { loadConfig, configProblems, resolveCookie } from '../server/config.js';
16
+ import { RpcClient } from '../server/rpc/client.js';
17
+ import { MAGIC, xorKey, readChainFile, records } from '../server/chain/blockfile.js';
18
+ import { c, checkLine } from './ui.js';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const QUIET = { info() {}, warn() {}, error() {}, debug() {} };
22
+
23
+ /** The RPC client a check talks through; setup and the tests pass their own. */
24
+ export function clientFor(node, cfg) {
25
+ return new RpcClient(node, { ...(cfg?.rpc ?? {}), ...(node.rpc ?? {}) }, { log: QUIET });
26
+ }
27
+
28
+ const CORE_VERSION_MIN = 250000; // getblock verbosity 3 (the follower's one call) arrived in 25.0
29
+
30
+ /**
31
+ * Every check for one node. `rpc` is anything with batch(calls) -> [{ok, result, error}].
32
+ * Returns { ok, checks: [{ name, status: 'ok'|'warn'|'fail'|'info', detail }], facts }.
33
+ */
34
+ export async function runChecks(node, { rpc, fs = { existsSync, statSync, readdirSync, accessSync, readFileSync }, readBlockFile = readChainFile } = {}) {
35
+ const checks = [];
36
+ const facts = {};
37
+ const add = (name, status, detail) => { checks.push({ name, status, detail }); return status; };
38
+
39
+ // credentials
40
+ const cred = resolveCookie(node);
41
+ if (!cred) add('credentials', 'fail', `no cookie file found under ${node.datadir ?? '(no datadir)'} and no rpcUser/rpcPassword`);
42
+ else add('credentials', 'ok', cred.source === 'config' ? `rpcUser "${cred.user}" from the config` : `cookie ${cred.source}`);
43
+
44
+ // the RPC server
45
+ // every call is timed: how fast the node answers is half of what an install needs to know
46
+ const ms = (t0) => `${Date.now() - t0 >= 1000 ? `${((Date.now() - t0) / 1000).toFixed(1)} s` : `${Date.now() - t0} ms`}`;
47
+ const one = async (method, params = [], timeoutMs = 20000) => {
48
+ const t0 = Date.now();
49
+ try { const [r] = await rpc.batch([{ method, params }], { timeoutMs }); r.ms = ms(t0); return r; }
50
+ catch (err) { return { ok: false, ms: ms(t0), error: { message: err.message, kind: err.kind } }; }
51
+ };
52
+ const info = await one('getblockchaininfo');
53
+ if (!info.ok) {
54
+ add('rpc', 'fail', `${node.rpcUrl}: ${info.error.message}`);
55
+ return { ok: false, checks, facts };
56
+ }
57
+ const chain = info.result.chain;
58
+ facts.chain = chain; facts.blocks = info.result.blocks; facts.headers = info.result.headers;
59
+ add('rpc', 'ok', `${node.rpcUrl} answers in ${info.ms}: chain ${chain}, block ${info.result.blocks.toLocaleString()} of ${info.result.headers.toLocaleString()} headers${info.result.initialblockdownload ? ', still in initial block download' : ''}${info.result.pruned ? ', PRUNED' : ''}`);
60
+ if (node.chainHint && node.chainHint !== chain) add('chain', 'fail', `config says chainHint "${node.chainHint}" but the node is on "${chain}"`);
61
+ if (info.result.pruned) add('pruned', 'fail', 'a pruned node has discarded old block files; the address index needs every one of them');
62
+
63
+ const net = await one('getnetworkinfo');
64
+ if (net.ok) {
65
+ facts.version = net.result.version; facts.subversion = net.result.subversion;
66
+ const v = net.result.version;
67
+ add('version', v >= CORE_VERSION_MIN ? 'ok' : 'fail', `${net.result.subversion} (${v})${v < CORE_VERSION_MIN ? ` -- the address index follower needs getblock verbosity 3, Bitcoin Core 25.0 or later` : ''}`);
68
+ } else add('version', 'warn', `getnetworkinfo: ${net.error.message}`);
69
+
70
+ const idx = await one('getindexinfo');
71
+ if (idx.ok) {
72
+ const tx = idx.result.txindex;
73
+ if (!tx) add('txindex', 'fail', 'txindex is off: the explorer cannot look a confirmed transaction up by id (set txindex=1 in bitcoin.conf)');
74
+ else add('txindex', tx.synced ? 'ok' : 'warn', tx.synced ? `synced to ${tx.best_block_height.toLocaleString()}` : `still building (${tx.best_block_height.toLocaleString()} of ${info.result.blocks.toLocaleString()})`);
75
+ const cs = idx.result.coinstatsindex;
76
+ add('coinstatsindex', cs ? (cs.synced ? 'ok' : 'warn') : 'info', cs ? (cs.synced ? 'synced: the Chain page has UTXO figures' : 'still building') : 'off: the Chain page marks UTXO figures unindexed (optional)');
77
+ } else add('txindex', 'warn', `getindexinfo: ${idx.error.message}`);
78
+
79
+ // the explorer's one heavy need from the node: a block with every input's prevout
80
+ const best = await one('getbestblockhash');
81
+ if (best.ok) {
82
+ const blk = await one('getblock', [best.result, 3], 120_000);
83
+ if (blk.ok) {
84
+ const withPrevout = blk.result.tx.slice(1, 4).every((t) => t.vin.every((v) => v.prevout));
85
+ const slow = blk.ms.endsWith(' s') && parseFloat(blk.ms) >= 5;
86
+ add('getblock 3', withPrevout ? (slow ? 'warn' : 'ok') : 'fail', withPrevout ? `the tip block decodes with prevouts (${blk.result.tx.length.toLocaleString()} transactions) in ${blk.ms}${slow ? ' -- slow: the block files are on a slow disk, or the node is busy' : ''}` : 'the node answered verbosity 3 without prevouts');
87
+ } else add('getblock 3', 'fail', `getblock <tip> 3: ${blk.error.message} -- the address index follower cannot run`);
88
+ }
89
+ // the mempool, verbose: the monitor's heaviest regular read, every 20 s; how long the node takes
90
+ // over it is what decides whether the block-space board fills
91
+ const mp = await one('getrawmempool', [true], 120_000);
92
+ if (mp.ok) {
93
+ const n = Object.keys(mp.result).length;
94
+ const slow = mp.ms.endsWith(' s') && parseFloat(mp.ms) >= 10;
95
+ add('mempool', slow ? 'warn' : 'ok', `${n.toLocaleString()} transactions, verbose, in ${mp.ms}${slow ? ' -- slow: the board and the block being built will lag behind this' : ''}`);
96
+ } else add('mempool', 'warn', `getrawmempool verbose: ${mp.error.message} after ${mp.ms}`);
97
+ const addr = await one('getaddresstxids', [{ addresses: [] }]);
98
+ add('address index rpc', 'info', addr.ok ? 'the node has insight-style address RPCs (unused: BlockYard keeps its own index)' : 'the node has no address index, as expected of Bitcoin Core; BlockYard builds its own');
99
+
100
+ // the data directory: block files and the log
101
+ if (!node.datadir) add('datadir', 'fail', 'no datadir configured: the address index is built from the node\'s block files');
102
+ else if (!fs.existsSync(node.datadir)) add('datadir', 'fail', `${node.datadir} does not exist on this machine`);
103
+ else {
104
+ const blocksDir = path.join(node.datadir, 'blocks');
105
+ let names = null;
106
+ try { names = fs.readdirSync(blocksDir); } catch (err) { add('block files', 'fail', `${blocksDir}: ${err.message}`); }
107
+ if (names) {
108
+ const blk = names.filter((f) => /^blk\d{5}\.dat$/.test(f)).sort();
109
+ const rev = names.filter((f) => /^rev\d{5}\.dat$/.test(f)).sort();
110
+ const xor = names.includes('xor.dat');
111
+ facts.blockFiles = blk.length; facts.undoFiles = rev.length;
112
+ if (!blk.length) add('block files', 'fail', `${blocksDir} holds no blk*.dat`);
113
+ else {
114
+ let bytes = 0;
115
+ for (const f of [...blk, ...rev]) { try { bytes += fs.statSync(path.join(blocksDir, f)).size; } catch { /* counted as far as readable */ } }
116
+ facts.blockBytes = bytes;
117
+ add('block files', rev.length === blk.length ? 'ok' : 'warn', `${blk.length} block files and ${rev.length} undo files, ${(bytes / 1e9).toFixed(1)} GB${xor ? ', XOR-obfuscated (xor.dat present)' : ''}${rev.length !== blk.length ? ' -- every block file needs its undo file' : ''}`);
118
+ // prove they can be read: the first file's first record is the genesis block
119
+ try {
120
+ const key = xorKey(blocksDir);
121
+ const buf = readBlockFile(path.join(blocksDir, blk[0]), key);
122
+ const first = records(buf, MAGIC[chain] ?? MAGIC.main, 0, key).next().value;
123
+ const genesis = first && first.body.subarray(4, 36).every((b) => b === 0);
124
+ add('read a block', genesis ? 'ok' : 'fail', genesis ? `${blk[0]} opens and its first record is the genesis block` : `${blk[0]} opens but its first record is not a ${chain} genesis block: wrong chain, or a key this reader does not know`);
125
+ } catch (err) { add('read a block', 'fail', `${blk[0]}: ${err.message}`); }
126
+ }
127
+ }
128
+ const logs = [path.join(node.datadir, chain === 'main' ? '' : ({ test: 'testnet3', testnet4: 'testnet4', signet: 'signet', regtest: 'regtest' }[chain] ?? chain), 'debug.log')];
129
+ const log = logs.find((f) => fs.existsSync(f));
130
+ if (log) { const st = fs.statSync(log); facts.logFile = log; add('node log', 'info', `${log} (${(st.size / 1e6).toFixed(1)} MB) -- found; not parsed on Bitcoin Core, and not needed`); }
131
+ else add('node log', 'info', `no debug.log under ${node.datadir} -- not needed`);
132
+ }
133
+
134
+ // an address index, if there is one
135
+ if (node.addressIndex) {
136
+ const dir = node.addressIndex;
137
+ const manifest = path.join(dir, 'manifest.json');
138
+ if (!fs.existsSync(manifest)) add('address index', node.addressIndexBuild === 'manual' ? 'warn' : 'info', `${dir}: not built yet${node.addressIndexBuild === 'manual' ? ` (node scripts/index-build.js --out ${dir})` : ' -- BlockYard builds it when it starts'}`);
139
+ else {
140
+ try {
141
+ const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
142
+ const behind = info.result.blocks - m.tip.height;
143
+ facts.indexTip = m.tip.height;
144
+ add('address index', behind > 200 ? 'warn' : 'ok', `${dir}: built at ${m.builtAt ?? '?'} to block ${m.tip.height.toLocaleString()}, ${behind.toLocaleString()} behind the node${behind > 200 ? ' (the follower catches up 50 blocks a poll)' : ''}`);
145
+ } catch (err) { add('address index', 'fail', `${manifest}: ${err.message}`); }
146
+ try { fs.accessSync(dir, FS.W_OK); add('index writable', 'ok', 'the follower can write live.log and layers/ there'); }
147
+ catch { add('index writable', 'fail', `${dir} is not writable by this user; the follower writes live.log and layers/ inside it`); }
148
+ }
149
+ }
150
+
151
+ return { ok: !checks.some((c) => c.status === 'fail'), checks, facts };
152
+ }
153
+
154
+ export function printChecks(label, { ok, checks }) {
155
+ console.log(`\n ${c.bold(label)}`);
156
+ for (const ch of checks) console.log(checkLine(ch.status, ch.name, ch.detail));
157
+ console.log(ok ? ` ${c.ok(c.bold('everything this needs is there'))}` : ` ${c.bad(c.bold('something this needs is missing'))}${c.dim(' (the ✗ lines say what)')}`);
158
+ }
159
+
160
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
161
+ const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : null; };
162
+ const cfg = loadConfig();
163
+ for (const p of configProblems()) console.log(checkLine('fail', 'config', p));
164
+ const nodes = arg('node') ? cfg.nodes.filter((n) => n.id === arg('node')) : cfg.nodes;
165
+ if (!nodes.length) { console.log(`no node ${arg('node') ?? ''} in ${cfg.__configFile ?? 'the configuration'}`); process.exit(2); }
166
+ let allOk = configProblems().length === 0;
167
+ for (const node of nodes) {
168
+ const r = await runChecks(node, { rpc: clientFor(node, cfg) });
169
+ printChecks(`node "${node.id}" (${node.label ?? ''})`, r);
170
+ allOk &&= r.ok;
171
+ }
172
+ process.exit(allOk ? 0 : 1);
173
+ }