blockyard 0.0.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/scripts/check.js
CHANGED
|
@@ -148,6 +148,52 @@ export async function runChecks(node, { rpc, fs = { existsSync, statSync, readdi
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// ---- what KIND of node is this, and what can it do that Core cannot? ----
|
|
152
|
+
//
|
|
153
|
+
// Until 2026-09-16 there was no way to tell over RPC. The monitor says so in
|
|
154
|
+
// its own source: "both report the same non-Core subversion string. So RPC
|
|
155
|
+
// cannot tell you whether RPC is complete; only the log's build banner can."
|
|
156
|
+
// That is why `log.enabled` is a per-node setting -- identifying the node
|
|
157
|
+
// needed FILE ACCESS to a machine we can already reach by RPC.
|
|
158
|
+
//
|
|
159
|
+
// bmcgetcapabilities answers it in one call. A plain Core node does not have
|
|
160
|
+
// the method, and that is not a failure: it is how we learn this is Core.
|
|
161
|
+
// Every field it returns is LIVE STATE rather than a compile-time list, so
|
|
162
|
+
// "the node serves address history" means it is serving it now, not that the
|
|
163
|
+
// binary could if configured.
|
|
164
|
+
{
|
|
165
|
+
const caps = await one('bmcgetcapabilities', [], 5000);
|
|
166
|
+
if (!caps.ok) {
|
|
167
|
+
facts.nodeKind = 'core';
|
|
168
|
+
add('node kind', 'ok', 'Bitcoin Core (or a node without bmcgetcapabilities) — BlockYard supplies the address index itself');
|
|
169
|
+
} else {
|
|
170
|
+
const r = caps.result ?? {};
|
|
171
|
+
const x = r.extensions ?? {};
|
|
172
|
+
facts.nodeKind = r.node ?? 'bitcoinmachinecode';
|
|
173
|
+
facts.nodeBuild = r.build?.commit ?? null;
|
|
174
|
+
facts.nodeCapabilities = {
|
|
175
|
+
addrindex: Boolean(x.addrindex),
|
|
176
|
+
esploraPort: Number(x.esploraport ?? 0) || 0,
|
|
177
|
+
mempoolJournal: Boolean(x.mempooljournal?.enabled),
|
|
178
|
+
mempoolJournalCapacity: x.mempooljournal?.enabled ? Number(x.mempooljournal.capacity) : 0,
|
|
179
|
+
downloadInfo: Boolean(x.downloadinfo),
|
|
180
|
+
rpcComplete: Boolean(r.rpc_complete?.peerinfo && r.rpc_complete?.nettotals),
|
|
181
|
+
};
|
|
182
|
+
const have = [];
|
|
183
|
+
if (facts.nodeCapabilities.addrindex) have.push('address history from the node');
|
|
184
|
+
if (facts.nodeCapabilities.esploraPort) have.push(`Esplora facade on :${facts.nodeCapabilities.esploraPort}`);
|
|
185
|
+
if (facts.nodeCapabilities.mempoolJournal) have.push(`mempool departure journal (${facts.nodeCapabilities.mempoolJournalCapacity.toLocaleString()} records)`);
|
|
186
|
+
if (facts.nodeCapabilities.downloadInfo) have.push('download worker map');
|
|
187
|
+
add('node kind', 'ok',
|
|
188
|
+
`${facts.nodeKind} ${facts.nodeBuild ? `(${facts.nodeBuild}${r.build?.dirty ? ', dirty' : ''}) ` : ''}in ${caps.ms}`
|
|
189
|
+
+ (have.length ? ` — ${have.join(', ')}` : ' — no extensions enabled'));
|
|
190
|
+
// The one that changes what BlockYard has to DO: with the node serving
|
|
191
|
+
// address history there is no reason to build and follow a second copy.
|
|
192
|
+
if (facts.nodeCapabilities.addrindex)
|
|
193
|
+
add('address index', 'ok', 'the node serves it (addrindex=1) — BlockYard does not need to build its own');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
151
197
|
return { ok: !checks.some((c) => c.status === 'fail'), checks, facts };
|
|
152
198
|
}
|
|
153
199
|
|
package/scripts/donate-qr.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# Regenerate the donation QR
|
|
3
|
-
# with OpenCV's encoder, and check the modules written decode back to the address.
|
|
4
|
-
# the address changes; the
|
|
2
|
+
# Regenerate the donation QR (public/donate-qr.png, 4 px a module with the 4-module quiet zone) from
|
|
3
|
+
# the address below, with OpenCV's encoder, and check the modules written decode back to the address.
|
|
4
|
+
# Run by hand when the address changes; the PNG is committed, so nothing is generated at run time or
|
|
5
|
+
# in the browser. It was an inline SVG until 2026-09-15, when Safari painted its ground and none of
|
|
6
|
+
# its module rects; a bitmap is painted the same everywhere.
|
|
5
7
|
import cv2, numpy as np, re, sys
|
|
6
8
|
ADDR = 'bc1q249cv27lc2q7y0x53vkczgfvvgsjzhwxwv42gc'
|
|
7
9
|
img = cv2.QRCodeEncoder.create().encode(ADDR)
|
|
@@ -12,9 +14,10 @@ for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
|
|
|
12
14
|
x, y = int(mm.group(1)), int(mm.group(2)); m[y*8:(y+1)*8, x*8:(x+1)*8] = 0
|
|
13
15
|
data, _, _ = cv2.QRCodeDetector().detectAndDecode(m)
|
|
14
16
|
if data != ADDR: sys.exit(f'the QR does not decode to the address: {data!r}')
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
png = np.full((size * 4, size * 4), 255, np.uint8)
|
|
18
|
+
for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
|
|
19
|
+
x, y = int(mm.group(1)), int(mm.group(2)); png[y*4:(y+1)*4, x*4:(x+1)*4] = 0
|
|
20
|
+
cv2.imwrite('public/donate-qr.png', png)
|
|
21
|
+
check, _, _ = cv2.QRCodeDetector().detectAndDecode(cv2.imread('public/donate-qr.png'))
|
|
22
|
+
if check != ADDR: sys.exit(f'the PNG does not decode to the address: {check!r}')
|
|
23
|
+
print(f'{n}x{n} modules, public/donate-qr.png decodes to {check}')
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// How fast the DOS Diversions' emulated PC runs on this machine, headless (docs/MEASUREMENTS.md §32).
|
|
2
|
+
//
|
|
3
|
+
// Boots a game from games/ on a clock that counts instructions, lets its title and demos run, and
|
|
4
|
+
// reports instructions a second of wall time and the screens it drew. No browser: the same x86.js
|
|
5
|
+
// and dospc.js the worker runs, on Node's V8.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/dos-bench.js [doom|quake] [millions of instructions]
|
|
8
|
+
//
|
|
9
|
+
// DOOM runs at 30 million instructions to the virtual second (a fast 486); Quake at 70 million and
|
|
10
|
+
// with `+timedemo demo1`, so its screens drawn over the virtual time are the frame rate its own
|
|
11
|
+
// benchmark reports on a machine exactly as fast as this emulator.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createPC } from '../public/js/dospc.js';
|
|
16
|
+
import { createSoundCard } from '../public/js/soundcard.js';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const game = process.argv[2] === 'quake' ? 'quake' : 'doom';
|
|
20
|
+
const SPEC = {
|
|
21
|
+
doom: { dir: 'doom_dos', exe: 'DOOM.EXE', args: '', ips: 30e6, total: 400 },
|
|
22
|
+
quake: { dir: 'quake_dos', exe: 'QUAKE.EXE', args: '-nocdaudio +timedemo demo1', ips: 70e6, total: 3000 },
|
|
23
|
+
}[game];
|
|
24
|
+
const DIR = path.join(ROOT, 'games', SPEC.dir);
|
|
25
|
+
const total = Number(process.argv[3] ?? SPEC.total) * 1e6;
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(path.join(DIR, SPEC.exe))) {
|
|
28
|
+
console.error(`no ${path.join(DIR, SPEC.exe)}: this measures the shareware ${game}, which is not here`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const files = {};
|
|
32
|
+
const walk = (dir, pre) => {
|
|
33
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
if (e.name.startsWith('.')) continue;
|
|
35
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), `${pre}${e.name}/`);
|
|
36
|
+
else files[`${pre}${e.name}`] = new Uint8Array(fs.readFileSync(path.join(dir, e.name)));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(DIR, '');
|
|
40
|
+
|
|
41
|
+
let pc;
|
|
42
|
+
const now = () => (pc ? pc.cpu.cycles : 0) / SPEC.ips * 1000;
|
|
43
|
+
pc = createPC({ files, now, args: SPEC.args, sound: (mem) => createSoundCard({ mem, rate: 44100 }) });
|
|
44
|
+
pc.boot(files[SPEC.exe]);
|
|
45
|
+
|
|
46
|
+
const t0 = process.hrtime.bigint();
|
|
47
|
+
let done = 0, graphicsAt = null;
|
|
48
|
+
while (done < total && !pc.exited) {
|
|
49
|
+
done += pc.run(1e6);
|
|
50
|
+
if (graphicsAt === null && pc.vga.mode === 0x13) graphicsAt = done;
|
|
51
|
+
}
|
|
52
|
+
const secs = Number(process.hrtime.bigint() - t0) / 1e9;
|
|
53
|
+
console.log(`${game} on node ${process.version}`);
|
|
54
|
+
console.log(`${(done / 1e6).toFixed(0)} M instructions in ${secs.toFixed(2)} s: ${(done / secs / 1e6).toFixed(1)} M a second`);
|
|
55
|
+
console.log(`graphics mode after ${(graphicsAt / 1e6).toFixed(1)} M instructions`);
|
|
56
|
+
console.log(`${(pc.vga.frames / 2).toFixed(0)} pages flipped, ${(pc.vga.writes / 64000).toFixed(0)} screens' worth written, in ${(done / SPEC.ips).toFixed(1)} virtual seconds`);
|
package/scripts/index-build.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// node scripts/index-build.js --out <dir> [--node <id>] [--workers N] [--files 0,1,5754]
|
|
5
5
|
//
|
|
6
|
-
// --files builds a partial index for testing (the completeness check is skipped).
|
|
6
|
+
// --files builds a partial index for testing (the completeness check is skipped). A build stopped part
|
|
7
|
+
// way resumes when run again with the same --out (build.js, THE BUILD JOURNAL); the log line says so,
|
|
8
|
+
// or says why the unfinished work was discarded. Progress goes to
|
|
7
9
|
// stderr once a second; the manifest, with every phase's timings, goes to stdout at the end.
|
|
8
10
|
import { loadConfig } from '../server/config.js';
|
|
9
11
|
import { RpcClient } from '../server/rpc/client.js';
|
|
@@ -12,6 +14,10 @@ import path from 'node:path';
|
|
|
12
14
|
import { progress, progressLine, strip, c, fmt } from './ui.js';
|
|
13
15
|
|
|
14
16
|
const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
|
|
17
|
+
// --workers must be a whole number of at least one (audit 2026-09-16, I3): `abc` or `0` made a pool with
|
|
18
|
+
// no workers, and with --files that wrote a "successful" manifest of zero rows
|
|
19
|
+
const workersArg = process.argv.includes('--workers') ? String(arg('workers')) : null;
|
|
20
|
+
if (workersArg != null && !(/^\d+$/.test(workersArg) && Number(workersArg) >= 1 && Number.isSafeInteger(Number(workersArg)))) { console.error(`--workers must be a whole number of at least 1, not ${JSON.stringify(workersArg)}`); process.exit(2); }
|
|
15
21
|
const cfg = loadConfig();
|
|
16
22
|
const node = cfg.nodes.find((n) => n.id === arg('node', null)) ?? cfg.nodes.find((n) => n.datadir) ?? cfg.nodes[0];
|
|
17
23
|
if (!node.datadir) { console.error(`node ${node.id} has no datadir configured; the index is built from its block files`); process.exit(2); }
|
|
@@ -25,7 +31,8 @@ const bar = progress();
|
|
|
25
31
|
const manifest = await buildIndex({
|
|
26
32
|
rpc, blocksDir: path.join(node.datadir, 'blocks'), out,
|
|
27
33
|
pace: rpcPacer(rpc, { onChange: (held) => process.stderr.write(held ? ' paused while the node\'s RPC is slow or failing\n' : ' resumed\n') }),
|
|
28
|
-
|
|
34
|
+
log: (text) => { bar.done(); process.stderr.write(` ${text}\n`); },
|
|
35
|
+
workers: workersArg != null ? Number(workersArg) : undefined,
|
|
29
36
|
files: arg('files', null) ? arg('files').split(',').map(Number) : null,
|
|
30
37
|
onProgress: (p) => {
|
|
31
38
|
if (p.phase !== phase) {
|
package/scripts/pool-map.js
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// node scripts/pool-map.js # fetch and write data/pool-map.json
|
|
17
17
|
// node scripts/pool-map.js --file pools-v2.json # offline input
|
|
18
18
|
// node scripts/pool-map.js --check # compare against what we are seeing now
|
|
19
|
+
// node scripts/pool-map.js --yes # replace an existing data/pool-map.json without asking
|
|
20
|
+
// node scripts/pool-map.js --expect-sha256 HEX # refuse a download that is not exactly those bytes
|
|
21
|
+
//
|
|
22
|
+
// Before it replaces data/pool-map.json it prints which pools the new map adds, removes and
|
|
23
|
+
// changes, and writes only on --yes or a "y" at the prompt; redirects are followed to HTTPS only,
|
|
24
|
+
// by both the node:https path and the curl fallback; and the coverage check asks the monitor on
|
|
25
|
+
// the port and scheme its config names (2026-09-16, audit L15).
|
|
19
26
|
//
|
|
20
27
|
// The input shape as fetched on 2026-09-09: 35,733 bytes, 171 pools, 201 tags, keys
|
|
21
28
|
// per pool: addresses, id, link, name, tags. Tags are literal fragments of the coinbase
|
|
@@ -85,14 +92,29 @@ function build(content, source) {
|
|
|
85
92
|
// 367 ms. Pinning the family is the fix; a curl fallback is kept because a tool that
|
|
86
93
|
// fetches pool labels has to work on a box whose stack is half broken.
|
|
87
94
|
import https from 'node:https';
|
|
95
|
+
import readline from 'node:readline/promises';
|
|
88
96
|
import { execFileSync } from 'node:child_process';
|
|
97
|
+
import { loadConfig } from '../server/config.js';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where a redirect may go (2026-09-16, audit L15): HTTPS only. A `Location: http://...` would
|
|
101
|
+
* otherwise have been followed by the curl fallback, and the labels read over plain HTTP.
|
|
102
|
+
* Returns the absolute next URL, or throws.
|
|
103
|
+
*/
|
|
104
|
+
export function redirectTarget(location, from) {
|
|
105
|
+
const next = new URL(location, from);
|
|
106
|
+
if (next.protocol !== 'https:') throw new Error(`refusing a redirect from ${from} to ${next.protocol}//${next.host}: HTTPS only`);
|
|
107
|
+
return next.toString();
|
|
108
|
+
}
|
|
89
109
|
|
|
90
|
-
function getOverHttps(url, redirectsLeft = 3, extra = {}) {
|
|
110
|
+
export function getOverHttps(url, redirectsLeft = 3, extra = {}) {
|
|
91
111
|
return new Promise((resolve, reject) => {
|
|
92
112
|
const req = https.get(url, { family: 4, ...extra, headers: { 'user-agent': 'BlockYard pool-map/1' }, timeout: 20_000 }, (res) => {
|
|
93
113
|
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirectsLeft > 0) {
|
|
94
114
|
res.resume();
|
|
95
|
-
|
|
115
|
+
let next;
|
|
116
|
+
try { next = redirectTarget(res.headers.location, url); } catch (err) { return reject(err); }
|
|
117
|
+
return resolve(getOverHttps(next, redirectsLeft - 1));
|
|
96
118
|
}
|
|
97
119
|
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`)); }
|
|
98
120
|
const chunks = [];
|
|
@@ -105,53 +127,147 @@ function getOverHttps(url, redirectsLeft = 3, extra = {}) {
|
|
|
105
127
|
});
|
|
106
128
|
}
|
|
107
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The curl fallback's arguments. `--proto =https --proto-redir =https` (2026-09-16, audit L15):
|
|
132
|
+
* -L alone follows a redirect to any protocol curl speaks, plain http included.
|
|
133
|
+
*/
|
|
134
|
+
export function curlArgs(url) {
|
|
135
|
+
return ['-fsSL', '--proto', '=https', '--proto-redir', '=https', '--max-time', '25', url];
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
async function fetchContent() {
|
|
109
139
|
try {
|
|
110
140
|
return await getOverHttps(URL_);
|
|
111
141
|
} catch (err) {
|
|
112
142
|
// curl follows its own resolver order and got 200 here while node's fetch failed,
|
|
113
143
|
// so a second, different path is worth one retry before declaring the fetch dead.
|
|
144
|
+
// A refused non-HTTPS redirect is not a flaky stack: it is not retried another way.
|
|
145
|
+
if (/HTTPS only/.test(err?.message ?? '')) throw err;
|
|
114
146
|
try {
|
|
115
|
-
return execFileSync('curl',
|
|
147
|
+
return execFileSync('curl', curlArgs(URL_), { encoding: 'utf8', maxBuffer: 8 << 20 });
|
|
116
148
|
} catch {
|
|
117
149
|
throw new Error(`could not fetch ${URL_}: ${err?.message ?? err}`);
|
|
118
150
|
}
|
|
119
151
|
}
|
|
120
152
|
}
|
|
121
153
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
/**
|
|
155
|
+
* What a refresh changes, pool by pool (2026-09-16, audit L15): the file decides which
|
|
156
|
+
* organisation the dashboard names for a block, so a rewrite is shown before it is made.
|
|
157
|
+
* `changed` is a pool whose name, link or tags differ. Keys are the maps' own pool keys.
|
|
158
|
+
*/
|
|
159
|
+
export function diffPoolMaps(before, after) {
|
|
160
|
+
const index = (m) => new Map((m?.pools ?? []).map((p) => [p.key, p]));
|
|
161
|
+
const a = index(before), b = index(after);
|
|
162
|
+
const same = (x, y) => x.name === y.name && (x.link ?? null) === (y.link ?? null)
|
|
163
|
+
&& JSON.stringify([...(x.tags ?? [])].sort()) === JSON.stringify([...(y.tags ?? [])].sort());
|
|
164
|
+
const added = [...b.keys()].filter((k) => !a.has(k)).sort();
|
|
165
|
+
const removed = [...a.keys()].filter((k) => !b.has(k)).sort();
|
|
166
|
+
const changed = [...b.keys()].filter((k) => a.has(k) && !same(a.get(k), b.get(k))).sort();
|
|
167
|
+
return { added, removed, changed, unchanged: [...b.keys()].filter((k) => a.has(k)).length - changed.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function formatDiff(d, name = (k) => k) {
|
|
171
|
+
const list = (ks) => (ks.length > 12 ? `${ks.slice(0, 12).map(name).join(', ')}, and ${ks.length - 12} more` : ks.map(name).join(', '));
|
|
172
|
+
const lines = [`pools: ${d.added.length} added, ${d.removed.length} removed, ${d.changed.length} changed, ${d.unchanged} unchanged`];
|
|
173
|
+
if (d.added.length) lines.push(` + ${list(d.added)}`);
|
|
174
|
+
if (d.removed.length) lines.push(` - ${list(d.removed)}`);
|
|
175
|
+
if (d.changed.length) lines.push(` ~ ${list(d.changed)}`);
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** --expect-sha256: the download must be exactly these bytes (2026-09-16, audit L15). */
|
|
180
|
+
export function checkExpectedSha(content, expected) {
|
|
181
|
+
if (expected == null) return null;
|
|
182
|
+
const want = String(expected).trim().toLowerCase();
|
|
183
|
+
if (!/^[0-9a-f]{64}$/.test(want)) throw new Error('--expect-sha256 takes 64 hex characters');
|
|
184
|
+
const got = crypto.createHash('sha256').update(content).digest('hex');
|
|
185
|
+
if (got !== want) throw new Error(`sha256 mismatch: expected ${want}, got ${got}; nothing written`);
|
|
186
|
+
return got;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The running monitor's own mining endpoint, for the coverage check (2026-09-16, audit L15): it
|
|
191
|
+
* was https://<host>:8088 whatever the config said, and the default port has been 21000 since
|
|
192
|
+
* 2026-09-13, so coverage was never measured. Port, TLS and bind address come from the loaded
|
|
193
|
+
* config (config/local.json, BLOCKYARD_PORT, BLOCKYARD_TLS); HTTPS unless TLS is turned off.
|
|
194
|
+
*/
|
|
195
|
+
export function coverageUrl(cfg) {
|
|
196
|
+
const server = cfg?.server ?? {};
|
|
197
|
+
let host = server.hosts?.[0] ?? server.host ?? '127.0.0.1';
|
|
198
|
+
if (Array.isArray(host)) host = host[0] ?? '127.0.0.1';
|
|
199
|
+
if (host === '0.0.0.0') host = '127.0.0.1';
|
|
200
|
+
if (host === '::') host = '::1';
|
|
201
|
+
const port = Number.isInteger(Number(server.port)) && Number(server.port) > 0 ? Number(server.port) : 21000;
|
|
202
|
+
const scheme = cfg?.tls === false || server.tls?.enabled === false ? 'http' : 'https';
|
|
203
|
+
return `${scheme}://${host.includes(':') ? `[${host}]` : host}:${port}/api/mining?node=main`;
|
|
146
204
|
}
|
|
147
205
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
206
|
+
async function main() {
|
|
207
|
+
const argAfter = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] ?? null : null);
|
|
208
|
+
const content = asFile ? fs.readFileSync(asFile, 'utf8') : await fetchContent();
|
|
209
|
+
try { checkExpectedSha(content, argAfter('--expect-sha256')); } catch (err) { console.error(err.message); process.exit(1); }
|
|
210
|
+
const map = build(content, asFile ? `file:${path.basename(asFile)}` : URL_);
|
|
152
211
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
212
|
+
// What we are actually seeing right now, so the coverage number is measured and not
|
|
213
|
+
// assumed. Read live from the running monitor if it answers; otherwise say it is unknown.
|
|
214
|
+
let observed = null;
|
|
215
|
+
let coverageError = null;
|
|
216
|
+
try {
|
|
217
|
+
let cfg;
|
|
218
|
+
try { cfg = loadConfig(); } catch { cfg = { server: { port: Number(process.env.BLOCKYARD_PORT) || 21000 } }; }
|
|
219
|
+
const url = coverageUrl(cfg);
|
|
220
|
+
// Same family:4 path as the fetch above, plus the box's own CA: the monitor serves TLS
|
|
221
|
+
// from a local CA that is not in the system trust store, and "could not verify" is a
|
|
222
|
+
// different fact from "could not reach".
|
|
223
|
+
const caFile = process.env.BLOCKYARD_CA_FILE ?? null; // a CA file, only where the fetch needs a private one
|
|
224
|
+
const extra = caFile && fs.existsSync(caFile) ? { ca: fs.readFileSync(caFile) } : {};
|
|
225
|
+
const body = url.startsWith('https:') ? await getOverHttps(url, 0, extra) : await (await fetch(url)).text();
|
|
226
|
+
const d = JSON.parse(body);
|
|
227
|
+
const rows = d.recent ?? [];
|
|
228
|
+
const hit = rows.filter((r) => map.matchers.some((m) => normalizeText(`${r.rawCoinbase ? Buffer.from(r.rawCoinbase, 'hex').toString('utf8') : ''} ${r.tagText || ''}`).includes(m.tagNorm))).length;
|
|
229
|
+
observed = {
|
|
230
|
+
blocks: rows.length, labelled: hit, unknown: rows.filter((r) => !r.poolLabel).length,
|
|
231
|
+
labelSource: d.labelSource ? `labels from ${d.labelSource.source} @ ${(d.labelSource.sha256 || '').slice(0, 10)}` : 'no map loaded by the monitor yet',
|
|
232
|
+
};
|
|
233
|
+
} catch (err) {
|
|
234
|
+
coverageError = err?.message ?? String(err);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`pools: ${map.pools.length} matchers: ${map.matchers.length} sha256: ${map.sourceSha256}`);
|
|
238
|
+
if (observed) console.log(`coverage of the ${observed.blocks} attributed blocks: ${observed.labelled} matched a curated tag, ${observed.unknown} keep their raw tag\n ${observed.labelSource ?? ''}`);
|
|
239
|
+
else console.log(`coverage: not measured (${coverageError}) -- reported as unknown rather than assumed`);
|
|
240
|
+
|
|
241
|
+
// THE DIFF BEFORE THE WRITE (2026-09-16, audit L15): against the file this replaces, or, when
|
|
242
|
+
// there is none yet, the shipped config/pool-map.json the monitor uses until then.
|
|
243
|
+
const shipped = path.join(ROOT, 'config', 'pool-map.json');
|
|
244
|
+
const replacing = fs.existsSync(OUT);
|
|
245
|
+
const prevFile = replacing ? OUT : fs.existsSync(shipped) ? shipped : null;
|
|
246
|
+
let prev = null;
|
|
247
|
+
if (prevFile) { try { prev = JSON.parse(fs.readFileSync(prevFile, 'utf8')); } catch { prev = null; } }
|
|
248
|
+
if (prev) {
|
|
249
|
+
const names = new Map([...(prev.pools ?? []), ...map.pools].map((p) => [p.key, p.name]));
|
|
250
|
+
console.log(`against ${path.relative(ROOT, prevFile)}${prev.sourceSha256 ? ` (sha256 ${prev.sourceSha256.slice(0, 12)})` : ''}:`);
|
|
251
|
+
console.log(formatDiff(diffPoolMaps(prev, map), (k) => names.get(k) ?? k));
|
|
252
|
+
}
|
|
253
|
+
if (checkOnly) process.exit(0);
|
|
254
|
+
|
|
255
|
+
if (replacing && !args.includes('--yes')) {
|
|
256
|
+
if (!process.stdin.isTTY) { console.log(`not written: ${path.relative(ROOT, OUT)} exists; pass --yes to replace it`); process.exit(1); }
|
|
257
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
258
|
+
const a = (await rl.question(`replace ${path.relative(ROOT, OUT)}? (y/N) `)).trim().toLowerCase();
|
|
259
|
+
rl.close();
|
|
260
|
+
if (!a.startsWith('y')) { console.log('not written'); process.exit(1); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
|
264
|
+
const tmp = `${OUT}.tmp`;
|
|
265
|
+
fs.writeFileSync(tmp, `${JSON.stringify(map, null, 1)}\n`);
|
|
266
|
+
fs.renameSync(tmp, OUT);
|
|
267
|
+
console.log(`wrote ${path.relative(ROOT, OUT)}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// run only as a script, so the tests can import the pieces without a fetch
|
|
271
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
|
|
272
|
+
main().catch((err) => { console.error(err?.message ?? err); process.exit(1); });
|
|
273
|
+
}
|