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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- 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 +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- 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();
|
package/scripts/check.js
ADDED
|
@@ -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
|
+
}
|