blockyard 0.0.1 → 0.0.9

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 (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // STEP 4 of the address index (docs/DEFECTS.md): what does storing it cost? Measured on real rows.
3
+ //
4
+ // node --no-warnings scripts/index-bench.js [--files 384,2685,5370] [--out <dir>]
5
+ //
6
+ // THE ROW. One row per (address script, transaction) that touched it -- an output paying the script,
7
+ // or an input spending an output that paid it (the spent script comes from the undo file, so no UTXO
8
+ // replay). Key: the first 8 bytes of sha256(script), then the block height and the transaction's
9
+ // position in the block. Value (optional): the net amount that transaction moved for that script.
10
+ // Everything else -- the transaction itself, its txid -- stays in the node, which has txindex.
11
+ //
12
+ // It builds the rows for the sampled files, then stores them two ways and measures each:
13
+ // A. node:sqlite, a WITHOUT ROWID table keyed (script hash, height, position) -- the runtime's own
14
+ // SQLite, already used by server/store/ledger.js, so no dependency
15
+ // B. sorted fixed-size rows in a flat file with a sparse block index, looked up by binary search
16
+ // and reports bytes per row, rows per second to write, and lookup latency for real addresses.
17
+ import { openSync, readSync, writeSync, closeSync, fstatSync, mkdirSync, rmSync, statSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { createHash } from 'node:crypto';
20
+ import { DatabaseSync } from 'node:sqlite';
21
+ import { loadConfig } from '../server/config.js';
22
+ import { RpcClient } from '../server/rpc/client.js';
23
+ import { Reader, readHeader, readTx } from '../server/chain/tx.js';
24
+ import { xorKey, readChainFile, records, decodeBlockUndo, pairBlocksWithUndo, MAGIC } from '../server/chain/blockfile.js';
25
+
26
+ const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
27
+ const dir = arg('dir', '/storage/core-oracle/blocks');
28
+ const files = arg('files', '384,2685,5370').split(',').map(Number);
29
+ const outDir = arg('out', path.join(process.env.TMPDIR ?? '/tmp', 'blockyard-index-bench'));
30
+ const ms = (t0) => performance.now() - t0;
31
+ const key = xorKey(dir);
32
+
33
+ const cfg = loadConfig();
34
+ const node = cfg.nodes.find((n) => n.datadir && dir.startsWith(n.datadir)) ?? cfg.nodes[0];
35
+ const rpc = new RpcClient(node, { ...(cfg.rpc ?? {}), ...(node.rpc ?? {}) }, { log: { info() {}, warn() {}, error() {}, debug() {} } });
36
+ const heightsOf = async (hashes) => {
37
+ const got = await rpc.batch(hashes.map((h) => ({ method: 'getblockheader', params: [h] })), { key: `bench:hdr:${hashes[0]}`, timeoutMs: 120_000, maxWaitMs: 120_000 });
38
+ return got.map((g) => (g.ok ? g.result.height : null));
39
+ };
40
+
41
+ // --- rows ------------------------------------------------------------------
42
+ // kept in parallel typed arrays while building; the stores below pick their own layout
43
+ let n = 0;
44
+ const cap = 40_000_000;
45
+ const SH = new BigUint64Array(cap), HT = new Uint32Array(cap), POS = new Uint16Array(cap), VAL = new Float64Array(cap);
46
+ const sh8 = (script) => createHash('sha256').update(script).digest().readBigUInt64BE(0);
47
+
48
+ const build = { decodeMs: 0, blocks: 0, txs: 0, rawRows: 0 };
49
+ for (const f of files) {
50
+ const id = String(f).padStart(5, '0');
51
+ const blk = readChainFile(path.join(dir, `blk${id}.dat`), key);
52
+ const rev = readChainFile(path.join(dir, `rev${id}.dat`), key);
53
+ const blocks = [...records(blk, MAGIC.main)].map((r) => { const rd = new Reader(r.body); const h = readHeader(rd); return { rec: r, hash: h.hash, previousblockhash: h.previousblockhash, ntx: rd.varint(), rd }; });
54
+ const pairs = pairBlocksWithUndo(blocks, [...records(rev, MAGIC.main, 32)]);
55
+ const idx = [...pairs.keys()];
56
+ const heights = await heightsOf(idx.map((i) => blocks[i].hash));
57
+ const t0 = performance.now();
58
+ idx.forEach((i, k) => {
59
+ const b = blocks[i], height = heights[k];
60
+ if (height == null) return;
61
+ const undo = decodeBlockUndo(pairs.get(i).body);
62
+ const rd = b.rd;
63
+ for (let p = 0; p < b.ntx; p++) {
64
+ const tx = readTx(rd);
65
+ build.txs++;
66
+ const moved = new Map(); // sh8 -> net sats for this transaction
67
+ for (const o of tx.vout) {
68
+ if (o.scriptPubKey.type === 'nulldata') continue;
69
+ const s = sh8(Buffer.from(o.scriptPubKey.hex, 'hex'));
70
+ moved.set(s, (moved.get(s) ?? 0) + o.value_sat); build.rawRows++;
71
+ }
72
+ if (p > 0) for (const c of undo[p - 1]) {
73
+ const s = sh8(c.script);
74
+ moved.set(s, (moved.get(s) ?? 0) - c.value_sat); build.rawRows++;
75
+ }
76
+ for (const [s, v] of moved) { SH[n] = s; HT[n] = height; POS[n] = p; VAL[n] = v; n++; }
77
+ }
78
+ build.blocks++;
79
+ });
80
+ build.decodeMs += ms(t0);
81
+ console.log(`blk${id}: ${idx.length} blocks, rows so far ${n.toLocaleString()} (raw ${build.rawRows.toLocaleString()} before merging a script's outputs and inputs within one transaction)`);
82
+ }
83
+
84
+ rmSync(outDir, { recursive: true, force: true });
85
+ mkdirSync(outDir, { recursive: true });
86
+ const order = new Uint32Array(n); for (let i = 0; i < n; i++) order[i] = i;
87
+ const toSigned = (u) => BigInt.asIntN(64, u);
88
+
89
+ // lookups: real scripts, chosen from the rows, so every query has at least one hit
90
+ let seed = 20260914;
91
+ const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
92
+ const probes = Array.from({ length: 20_000 }, () => SH[Math.floor(rnd() * n)]);
93
+
94
+ // --- A. node:sqlite ----------------------------------------------------------
95
+ const A = {};
96
+ {
97
+ const file = path.join(outDir, 'hist.sqlite');
98
+ const db = new DatabaseSync(file);
99
+ db.exec('PRAGMA page_size = 16384; PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF; PRAGMA locking_mode = EXCLUSIVE;');
100
+ db.exec('CREATE TABLE hist (sh INTEGER NOT NULL, h INTEGER NOT NULL, p INTEGER NOT NULL, v INTEGER NOT NULL, PRIMARY KEY (sh, h, p)) WITHOUT ROWID');
101
+ const ins = db.prepare('INSERT OR REPLACE INTO hist (sh, h, p, v) VALUES (?, ?, ?, ?)');
102
+ // in the order blocks arrive -- what a live, incremental index would do
103
+ let t0 = performance.now();
104
+ db.exec('BEGIN');
105
+ for (let i = 0; i < n; i++) {
106
+ ins.run(toSigned(SH[i]), HT[i], POS[i], VAL[i]);
107
+ if (i % 500_000 === 499_999) { db.exec('COMMIT'); db.exec('BEGIN'); }
108
+ }
109
+ db.exec('COMMIT');
110
+ A.insertMs = ms(t0);
111
+ A.bytes = statSync(file).size;
112
+ t0 = performance.now(); db.exec('VACUUM'); A.vacuumMs = ms(t0);
113
+ A.bytesVacuumed = statSync(file).size;
114
+ const q = db.prepare('SELECT h, p, v FROM hist WHERE sh = ? ORDER BY h DESC, p DESC LIMIT 25');
115
+ for (let i = 0; i < 2000; i++) q.all(toSigned(probes[i])); // warm
116
+ t0 = performance.now(); let hits = 0;
117
+ for (const s of probes) hits += q.all(toSigned(s)).length;
118
+ A.lookupMs = ms(t0) / probes.length; A.hits = hits;
119
+ db.close();
120
+ }
121
+
122
+ // --- B. sorted flat file -----------------------------------------------------
123
+ // row: sh8 8 | height 3 | position 2 | value 8 (signed sats: one transaction has moved 500,000 BTC, past 6 bytes'
124
+ // reach of 1.4 M only by a margin nobody should bet a format on) = 21 bytes; history-only drops the value (13)
125
+ const B = {};
126
+ {
127
+ let t0 = performance.now();
128
+ // bucket by the top 16 bits of the hash, then sort each small bucket: a comparator sort over tens of
129
+ // millions of rows at once is what makes a naive JS sort slow
130
+ const bucketOf = (i) => Number(SH[i] >> 48n);
131
+ const counts = new Uint32Array(65537);
132
+ for (let i = 0; i < n; i++) counts[bucketOf(i) + 1]++;
133
+ for (let b = 0; b < 65536; b++) counts[b + 1] += counts[b];
134
+ const fill = counts.slice();
135
+ for (let i = 0; i < n; i++) order[fill[bucketOf(i)]++] = i;
136
+ for (let b = 0; b < 65536; b++) {
137
+ const sub = order.subarray(counts[b], counts[b + 1]);
138
+ if (sub.length > 1) sub.sort((x, y) => (SH[x] < SH[y] ? -1 : SH[x] > SH[y] ? 1 : HT[x] - HT[y] || POS[x] - POS[y]));
139
+ }
140
+ B.sortMs = ms(t0);
141
+ const ROW = 21, BLOCK = 4096; // rows per index entry
142
+ const file = path.join(outDir, 'hist.rows');
143
+ const fd = openSync(file, 'w');
144
+ const buf = Buffer.allocUnsafe(ROW * 65536);
145
+ const sparse = new BigUint64Array(Math.ceil(n / BLOCK));
146
+ t0 = performance.now();
147
+ let off = 0;
148
+ for (let i = 0; i < n; i++) {
149
+ const r = order[i];
150
+ if (i % BLOCK === 0) sparse[i / BLOCK] = SH[r];
151
+ const at = off;
152
+ buf.writeBigUInt64BE(SH[r], at);
153
+ buf.writeUIntBE(HT[r], at + 8, 3);
154
+ buf.writeUInt16BE(POS[r], at + 11);
155
+ buf.writeBigInt64BE(BigInt(VAL[r]), at + 13);
156
+ off += ROW;
157
+ if (off === buf.length) { writeSync(fd, buf, 0, off); off = 0; }
158
+ }
159
+ if (off) writeSync(fd, buf, 0, off);
160
+ closeSync(fd);
161
+ B.writeMs = ms(t0);
162
+ B.bytes = statSync(file).size + sparse.byteLength;
163
+ B.historyOnlyBytes = n * 13 + sparse.byteLength;
164
+ // lookup: binary search the sparse index in memory, read one index block of rows, scan it
165
+ const rfd = openSync(file, 'r');
166
+ const block = Buffer.allocUnsafe(ROW * BLOCK * 2);
167
+ const lookup = (s) => {
168
+ let lo = 0, hi = sparse.length - 1;
169
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (sparse[mid] < s) lo = mid; else hi = mid - 1; }
170
+ const got = readSync(rfd, block, 0, block.length, lo * BLOCK * ROW);
171
+ let found = 0;
172
+ for (let o = 0; o + ROW <= got; o += ROW) { const k = block.readBigUInt64BE(o); if (k === s) found++; else if (k > s) break; }
173
+ return found;
174
+ };
175
+ for (let i = 0; i < 2000; i++) lookup(probes[i]);
176
+ t0 = performance.now(); let hits = 0;
177
+ for (const s of probes) hits += lookup(s);
178
+ B.lookupMs = ms(t0) / probes.length; B.hits = hits;
179
+ closeSync(rfd);
180
+ }
181
+
182
+ // --- A2. node:sqlite, bulk-loaded in key order --------------------------------
183
+ // A B-tree fed random keys stops fitting in memory long before 5 billion rows and then pays a seek per
184
+ // insert; fed sorted keys it only ever appends to its rightmost page. This is SQLite's bulk-build path,
185
+ // and the one a full build would have to take -- which means sorting the rows first either way.
186
+ {
187
+ const file = path.join(outDir, 'hist-sorted.sqlite');
188
+ const db = new DatabaseSync(file);
189
+ db.exec('PRAGMA page_size = 16384; PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF; PRAGMA locking_mode = EXCLUSIVE;');
190
+ db.exec('CREATE TABLE hist (sh INTEGER NOT NULL, h INTEGER NOT NULL, p INTEGER NOT NULL, v INTEGER NOT NULL, PRIMARY KEY (sh, h, p)) WITHOUT ROWID');
191
+ const ins = db.prepare('INSERT INTO hist (sh, h, p, v) VALUES (?, ?, ?, ?)');
192
+ // the flat file's order is unsigned; SQLite's INTEGER order is signed, so feed the negative half first
193
+ const t0 = performance.now();
194
+ db.exec('BEGIN');
195
+ let k = 0;
196
+ const firstNonNeg = order.findIndex((r) => SH[r] >= 0x8000000000000000n);
197
+ const seq = firstNonNeg < 0 ? [order] : [order.subarray(firstNonNeg), order.subarray(0, firstNonNeg)];
198
+ for (const part of seq) for (const r of part) {
199
+ ins.run(toSigned(SH[r]), HT[r], POS[r], VAL[r]);
200
+ if (++k % 500_000 === 0) { db.exec('COMMIT'); db.exec('BEGIN'); }
201
+ }
202
+ db.exec('COMMIT');
203
+ A.sortedInsertMs = ms(t0);
204
+ A.sortedBytes = statSync(file).size;
205
+ db.close();
206
+ }
207
+
208
+ const perRow = (bytes) => +(bytes / n).toFixed(2);
209
+ const report = {
210
+ sample: { files, blocks: build.blocks, txs: build.txs, rows: n, rawRows: build.rawRows, rowsPerTx: +(n / build.txs).toFixed(3), mergedAway: +(1 - n / build.rawRows).toFixed(3) },
211
+ sqlite: { bytesPerRow: perRow(A.bytes), bytesPerRowVacuumed: perRow(A.bytesVacuumed), bytesPerRowSortedLoad: perRow(A.sortedBytes), insertRowsPerSec: Math.round(n / (A.insertMs / 1000)), sortedInsertRowsPerSec: Math.round(n / (A.sortedInsertMs / 1000)), vacuumSec: +(A.vacuumMs / 1000).toFixed(1), lookupMs: +A.lookupMs.toFixed(4), meanHitsPerLookup: +(A.hits / probes.length).toFixed(2) },
212
+ sortedFile: { bytesPerRow: perRow(B.bytes), bytesPerRowHistoryOnly: perRow(B.historyOnlyBytes), sortRowsPerSec: Math.round(n / (B.sortMs / 1000)), writeRowsPerSec: Math.round(n / (B.writeMs / 1000)), lookupMs: +B.lookupMs.toFixed(4), meanHitsPerLookup: +(B.hits / probes.length).toFixed(2) },
213
+ };
214
+ console.log(JSON.stringify(report, null, 1));
215
+ rmSync(outDir, { recursive: true, force: true });
216
+ process.exit(0);
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // How does the built address index compare? (docs/MEASUREMENTS.md §30)
3
+ //
4
+ // node scripts/index-benchmark.js --index <dir> [--sample 400] [--verify 40]
5
+ //
6
+ // 1. opening the index (the sparse keys load into memory)
7
+ // 2. lookup latency for real addresses: a sample from blocks across the chain's history, plus
8
+ // addresses known to have enormous histories -- summary (count, balance, newest 25 rows) timed
9
+ // 3. CORRECTNESS against the node: for --verify addresses, the index balance plus any blocks the node
10
+ // has connected since the build, against `scantxoutset` at the same height. scantxoutset is Core's
11
+ // only address query; it reads the whole UTXO set, answers balance only (no history), and is also
12
+ // timed, as the method the index replaces.
13
+ import { loadConfig } from '../server/config.js';
14
+ import { RpcClient } from '../server/rpc/client.js';
15
+ import { IndexStore } from '../server/chain/index/store.js';
16
+ import { scriptKey } from '../server/chain/index/rows.js';
17
+ import { addressToScript } from '../server/chain/tx.js';
18
+
19
+ const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
20
+ const dir = arg('index', null);
21
+ if (!dir) { console.error('--index <dir> is required'); process.exit(2); }
22
+ const SAMPLE = Number(arg('sample', 400)), VERIFY = Number(arg('verify', 40));
23
+ const cfg = loadConfig();
24
+ const node = cfg.nodes.find((n) => n.id === arg('node', null)) ?? cfg.nodes.find((n) => n.datadir) ?? cfg.nodes[0];
25
+ const rpc = new RpcClient(node, { ...(cfg.rpc ?? {}), ...(node.rpc ?? {}) }, { log: { info() {}, warn() {}, error() {}, debug() {} } });
26
+ const call = async (method, params, timeoutMs = 600_000) => {
27
+ const [r] = await rpc.batch([{ method, params }], { key: `ibench:${method}:${JSON.stringify(params).slice(0, 80)}`, timeoutMs, maxWaitMs: 900_000 });
28
+ if (!r.ok) throw new Error(`${method}: ${r.error?.message}`);
29
+ return r.result;
30
+ };
31
+ const pct = (arr, p) => { const s = arr.slice().sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
32
+ const report = {};
33
+
34
+ // 1. open
35
+ let t = performance.now();
36
+ const store = new IndexStore(dir);
37
+ report.open = { ms: +(performance.now() - t).toFixed(1), rows: store.manifest.rows, gb: +(store.manifest.bytes / 1e9).toFixed(1), tip: store.manifest.tip.height };
38
+ const tip = store.manifest.tip.height;
39
+
40
+ // 2. addresses: outputs from blocks spread over the chain, and some famously heavy ones
41
+ const heavy = [
42
+ '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // the genesis address, sent dust for fifteen years
43
+ '34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo', // a long-lived exchange cold wallet
44
+ 'bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h', // a large exchange wallet
45
+ '1FeexV6bAHb8ybZjqQMjJrcCrHGW9sb6uF', // untouched since 2011
46
+ ];
47
+ let seed = 20260914;
48
+ const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
49
+ const addresses = new Set();
50
+ const heights = Array.from({ length: 24 }, (_, i) => Math.floor(((i + 0.5) / 24) * tip));
51
+ for (const h of heights) {
52
+ const b = await call('getblock', [await call('getblockhash', [h]), 2]);
53
+ const found = [];
54
+ for (const tx of b.tx) for (const o of tx.vout) if (o.scriptPubKey.address) found.push(o.scriptPubKey.address);
55
+ for (let k = 0; k < Math.ceil(SAMPLE / heights.length) && found.length; k++) addresses.add(found[Math.floor(rnd() * found.length)]);
56
+ }
57
+ const sample = [...addresses].slice(0, SAMPLE);
58
+
59
+ const lookups = [];
60
+ for (const [label, list] of [['sampled', sample], ['heavy', heavy]]) {
61
+ for (const a of list) {
62
+ const script = addressToScript(a);
63
+ if (!script) continue;
64
+ const key = scriptKey(script);
65
+ const t0 = performance.now();
66
+ const s = store.summaryForKey(key);
67
+ lookups.push({ label, address: a, ms: performance.now() - t0, txCount: s.txCount, balance: s.balance });
68
+ }
69
+ }
70
+ const sampled = lookups.filter((l) => l.label === 'sampled');
71
+ report.lookup = {
72
+ sampled: sampled.length,
73
+ msP50: +pct(sampled.map((l) => l.ms), 0.5).toFixed(3), msP90: +pct(sampled.map((l) => l.ms), 0.9).toFixed(3),
74
+ msP99: +pct(sampled.map((l) => l.ms), 0.99).toFixed(3), msMax: +Math.max(...sampled.map((l) => l.ms)).toFixed(3),
75
+ txCountP50: pct(sampled.map((l) => l.txCount), 0.5), txCountMax: Math.max(...sampled.map((l) => l.txCount)),
76
+ heavy: lookups.filter((l) => l.label === 'heavy').map((l) => ({ address: l.address, txCount: l.txCount, btc: l.balance / 1e8, ms: +l.ms.toFixed(1) })),
77
+ };
78
+ // warm: the same lookups again, now that their pages are in the page cache
79
+ const warm = [];
80
+ for (const l of sampled) { const t0 = performance.now(); store.summaryForKey(scriptKey(addressToScript(l.address))); warm.push(performance.now() - t0); }
81
+ report.lookup.warmMsP50 = +pct(warm, 0.5).toFixed(3);
82
+ report.lookup.warmMsP99 = +pct(warm, 0.99).toFixed(3);
83
+
84
+ // 3. correctness against scantxoutset, at one height
85
+ const check = [...heavy.slice(1), ...sample.slice(0, Math.max(0, VERIFY - heavy.length + 1))];
86
+ for (let attempt = 0; attempt < 3; attempt++) {
87
+ const nodeTip = await call('getblockcount', []);
88
+ // rows the node has connected since the build, for just these scripts
89
+ const want = new Map(check.map((a) => [addressToScript(a).toString('hex'), a]));
90
+ const delta = new Map();
91
+ for (let h = tip + 1; h <= nodeTip; h++) {
92
+ const b = await call('getblock', [await call('getblockhash', [h]), 3]);
93
+ for (const tx of b.tx) {
94
+ for (const o of tx.vout) if (want.has(o.scriptPubKey.hex)) delta.set(o.scriptPubKey.hex, (delta.get(o.scriptPubKey.hex) ?? 0) + Math.round(o.value * 1e8));
95
+ for (const v of tx.vin) if (v.prevout && want.has(v.prevout.scriptPubKey.hex)) delta.set(v.prevout.scriptPubKey.hex, (delta.get(v.prevout.scriptPubKey.hex) ?? 0) - Math.round(v.prevout.value * 1e8));
96
+ }
97
+ }
98
+ t = performance.now();
99
+ const scan = await call('scantxoutset', ['start', check.map((a) => `addr(${a})`)], 900_000);
100
+ const scanMs = performance.now() - t;
101
+ if (scan.height !== nodeTip) continue; // a block arrived mid-check: again
102
+ const utxo = new Map();
103
+ for (const u of scan.unspents) { const hex = u.scriptPubKey; utxo.set(hex, (utxo.get(hex) ?? 0) + Math.round(u.amount * 1e8)); }
104
+ const rows = check.map((a) => {
105
+ const hex = addressToScript(a).toString('hex');
106
+ const indexSat = store.summaryForKey(scriptKey(Buffer.from(hex, 'hex')), { limit: 0 }).balance + (delta.get(hex) ?? 0);
107
+ const nodeSat = utxo.get(hex) ?? 0;
108
+ return { address: a, indexSat, nodeSat, match: indexSat === nodeSat };
109
+ });
110
+ report.verify = {
111
+ height: nodeTip, blocksSinceBuild: nodeTip - tip, addresses: rows.length, matches: rows.filter((r) => r.match).length,
112
+ mismatches: rows.filter((r) => !r.match), scantxoutsetSec: +(scanMs / 1000).toFixed(1),
113
+ };
114
+ break;
115
+ }
116
+ console.log(JSON.stringify(report, null, 1));
117
+ process.exit(0);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Build the address index from the configured node's block files (server/chain/index/build.js).
3
+ //
4
+ // node scripts/index-build.js --out <dir> [--node <id>] [--workers N] [--files 0,1,5754]
5
+ //
6
+ // --files builds a partial index for testing (the completeness check is skipped). Progress goes to
7
+ // stderr once a second; the manifest, with every phase's timings, goes to stdout at the end.
8
+ import { loadConfig } from '../server/config.js';
9
+ import { RpcClient } from '../server/rpc/client.js';
10
+ import { buildIndex, rpcPacer } from '../server/chain/index/build.js';
11
+ import path from 'node:path';
12
+ import { progress, progressLine, strip, c, fmt } from './ui.js';
13
+
14
+ const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
15
+ const cfg = loadConfig();
16
+ const node = cfg.nodes.find((n) => n.id === arg('node', null)) ?? cfg.nodes.find((n) => n.datadir) ?? cfg.nodes[0];
17
+ if (!node.datadir) { console.error(`node ${node.id} has no datadir configured; the index is built from its block files`); process.exit(2); }
18
+ const out = arg('out', null);
19
+ if (!out) { console.error('--out <dir> is required'); process.exit(2); }
20
+ const rpc = new RpcClient(node, { ...(cfg.rpc ?? {}), ...(node.rpc ?? {}) }, { log: { info() {}, warn() {}, error() {}, debug() {} } });
21
+
22
+ const started = Date.now();
23
+ let phase = null, phaseStart = started;
24
+ const bar = progress();
25
+ const manifest = await buildIndex({
26
+ rpc, blocksDir: path.join(node.datadir, 'blocks'), out,
27
+ pace: rpcPacer(rpc, { onChange: (held) => process.stderr.write(held ? ' paused while the node\'s RPC is slow or failing\n' : ' resumed\n') }),
28
+ workers: arg('workers', null) ? Number(arg('workers')) : undefined,
29
+ files: arg('files', null) ? arg('files').split(',').map(Number) : null,
30
+ onProgress: (p) => {
31
+ if (p.phase !== phase) {
32
+ if (phase) bar.done(strip(progressLine({ phase, done: 1, total: 1, elapsed: (Date.now() - phaseStart) / 1000 })));
33
+ phase = p.phase; phaseStart = Date.now();
34
+ }
35
+ bar.update({ ...p, elapsed: (Date.now() - phaseStart) / 1000 });
36
+ },
37
+ });
38
+ bar.done(` ${c.ok('✓')} ${fmt.big(manifest.rows ?? 0)} rows to block ${Number(manifest.tip?.height ?? 0).toLocaleString()} in ${((Date.now() - started) / 60000).toFixed(1)} min -> ${out}`);
39
+ console.log(JSON.stringify(manifest, (k, v) => (k === 'bucketRows' ? undefined : v), 1));
40
+ process.exit(0);
@@ -0,0 +1,89 @@
1
+ // Render EVERY page against the LIVE monitor, under the DOM stub.
2
+ //
3
+ // Why: unit tests of a chart's geometry passed while the page that draws it threw a
4
+ // ReferenceError on the first real frame -- and an uncaught throw inside one renderer
5
+ // takes the whole page's cards with it, so the symptom was "two cards render nothing"
6
+ // when the cause was an undefined variable three lines above them. Nothing in the suite
7
+ // had ever run the page code with real-shaped data. This does, for every page.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { installDom } from '../test/dom-stub.js';
10
+
11
+ // No address is written here. Point it at whatever is running:
12
+ // BLOCKYARD_BASE=https://<address>:8088 BLOCKYARD_CA=/path/ca.crt npm run render:live
13
+ // With no BLOCKYARD_BASE it boots its own server against the fake node, so the check runs
14
+ // on any machine with no host identity to leak and no assumption about this one.
15
+ const CA = process.env.BLOCKYARD_CA;
16
+ const BASE = process.env.BLOCKYARD_BASE;
17
+ const curlArgs = (p) => ['--max-time', '20', ...(CA ? ['--cacert', CA] : []), `${BASE}${p}`];
18
+ const get = (p) => JSON.parse(execFileSync('curl', ['-s', ...curlArgs(p)], { encoding: 'utf8', maxBuffer: 1 << 26 }));
19
+
20
+ if (!BASE) {
21
+ console.log('usage: BLOCKYARD_BASE=https://<address>:8088 [BLOCKYARD_CA=<ca.pem>] npm run render:live');
22
+ console.log(' (point it at a running monitor; no address is baked into this script)');
23
+ process.exit(2);
24
+ }
25
+
26
+ installDom();
27
+ let rafN = 0;
28
+ globalThis.window = { devicePixelRatio: 1, matchMedia: () => ({ matches: true }) };
29
+ globalThis.matchMedia = () => ({ matches: true });
30
+ globalThis.requestAnimationFrame = (fn) => { rafN += 1; fn(rafN * 16); return rafN; };
31
+ globalThis.cancelAnimationFrame = () => {};
32
+ globalThis.performance = { now: () => 0 };
33
+
34
+ const F = await import('../public/js/fmt.js');
35
+ const panels = await import('../public/js/panels.js');
36
+ panels.setFmt(F);
37
+ const app = await import('../public/js/app.js');
38
+
39
+ const snap = get('/api/state?node=main');
40
+ try { snap.attribution = { ...(snap.attribution || {}), nextBlock: get('/api/nextblock?node=main') }; } catch { /* the page says so itself */ }
41
+ let dist = null;
42
+ try { dist = get('/api/mempool?node=main')?.dist ?? null; } catch { /* ditto */ }
43
+
44
+ Object.assign(app.state, { snap, page: 'overview', series: snap.series ?? {}, node: snap.id ?? 'main', byNode: new Map([[snap.id ?? 'main', { series: snap.series ?? {}, snap }]]), mempoolDist: dist ? { ...dist, fetchedAt: Date.now() } : null });
45
+
46
+ const PAGES = ['overview', 'chain', 'mempool', 'peers', 'network', 'mining', 'logs', 'node'];
47
+ // Cards that must have something in them once the page has rendered with live data.
48
+ const MUST_FILL = {
49
+ overview: ['ovMpCount', 'ovMpBytes', 'ovMpVsBlock', 'ovMpVsBlockKv', 'ovFeesCard', 'ovCaveats', 'ovTrain', 'ovGnTreemapNote'],
50
+ chain: ['chState', 'chUtxo', 'chDiff'],
51
+ mempool: ['mpLimits'],
52
+ peers: ['prCount', 'prBudget'],
53
+ network: ['ntRpc'],
54
+ mining: ['mnPools', 'gnMempoolNote'],
55
+ };
56
+
57
+ let failed = 0;
58
+ for (const page of PAGES) {
59
+ app.state.page = page;
60
+ try {
61
+ app.render();
62
+ } catch (e) {
63
+ failed += 1;
64
+ console.log(`FAIL ${page}: render threw -- ${e.message}`);
65
+ console.log(String(e.stack).split('\n').slice(1, 4).map((l) => ` ${l.trim()}`).join('\n'));
66
+ continue;
67
+ }
68
+ // The stub does not register ids out of injected markup, so a card rewritten through
69
+ // innerHTML is asserted on the element that was written -- which is the element the
70
+ // browser also paints. Asserting the stale child would measure the harness.
71
+ const empties = (MUST_FILL[page] ?? []).filter((id) => {
72
+ const el = globalThis.document.getElementById(id);
73
+ // Content may be written as markup or as plain text; an empty `_html` string is not
74
+ // null, so `??` would never look at textContent and a perfectly good note would be
75
+ // reported as empty. Read both.
76
+ const html = String(el?.innerHTML ?? '');
77
+ const txt = (html || String(el?.textContent ?? '')).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
78
+ return !txt || txt === '–';
79
+ });
80
+ if (empties.length) { failed += 1; console.log(`FAIL ${page}: cards left empty: ${empties.join(', ')}`); }
81
+ const fees = String(globalThis.document.getElementById('ovFeesCard')?.innerHTML ?? '');
82
+ if (page === 'overview' && /in 1 block/.test(fees) && !/\d/.test(fees.replace(/in \d+ block/g, ''))) {
83
+ failed += 1; console.log('FAIL overview: the fee tiers rendered with no numbers in them');
84
+ }
85
+ else console.log(`ok ${page}`);
86
+ }
87
+
88
+ console.log(failed ? `\n${failed} page(s) failed` : '\nall pages rendered with live data');
89
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // CLI user administration, for the cases where the web UI is unreachable
3
+ // (lost password, locked out, first boot went wrong) or a script needs an
4
+ // account. Never prints or accepts a password on a command line where it would
5
+ // land in shell history: use stdin or the interactive prompt.
6
+ //
7
+ // node scripts/manage-users.js list
8
+ // node scripts/manage-users.js create <username> [role]
9
+ // node scripts/manage-users.js passwd <username>
10
+ // node scripts/manage-users.js role <username> <role>
11
+ // node scripts/manage-users.js disable <username> | enable <username>
12
+ // node scripts/manage-users.js rm <username>
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import readline from 'node:readline';
16
+ import { loadConfig, ROOT } from '../server/config.js';
17
+ import { UserStore, ROLES } from '../server/auth/users.js';
18
+
19
+ const cfg = loadConfig();
20
+ // Accounts are OFF by default, so this CLI can edit a user file the running server
21
+ // will never consult. Say that up front instead of letting someone create an
22
+ // account, fail to log in, and conclude the tool is broken.
23
+ if (!cfg.auth.enabled) {
24
+ process.stderr.write('note: accounts are DISABLED on this config (auth.enabled=false), so the\n'
25
+ + 'server is open without sign-in and ignores users.json. Start with BLOCKYARD_AUTH=1\n'
26
+ + 'to use accounts.\n');
27
+ }
28
+ const file = path.join(cfg.auth.dataDir, 'users.json');
29
+ const store = new UserStore(file, cfg.auth);
30
+
31
+ const [cmd, ...args] = process.argv.slice(2);
32
+
33
+ function ask(question, { hidden = false } = {}) {
34
+ return new Promise((resolve) => {
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdout.isTTY });
36
+ if (!hidden) { rl.question(question, (a) => { rl.close(); resolve(a.trim()); }); return; }
37
+ process.stdout.write(question);
38
+ // Turn off echo rather than logging the prompt into a tty that keeps it.
39
+ const tty = process.stdin;
40
+ if (tty.isTTY) tty.setRawMode(true);
41
+ let buf = '';
42
+ tty.resume();
43
+ tty.setEncoding('utf8');
44
+ const onData = (ch) => {
45
+ // Enter arrives as LF or CR depending on the terminal; both are handled.
46
+ // (This line used to carry a third comparison whose operand was a raw CR
47
+ // inside the quotes -- a pasted keystroke. Legal in CJS, a syntax error as
48
+ // ESM, and nothing ever imported this file to find out.)
49
+ if (ch === '\n' || ch === '\r') {
50
+ tty.removeListener('data', onData);
51
+ if (tty.isTTY) tty.setRawMode(false);
52
+ tty.pause();
53
+ process.stdout.write('\n');
54
+ resolve(buf);
55
+ } else if (ch === '\u007f') { // DEL: what backspace sends on a tty
56
+ buf = buf.slice(0, -1);
57
+ } else if (ch === '\u0003') { // Ctrl-C
58
+ process.stdout.write('\n');
59
+ process.exit(130);
60
+ }
61
+ else buf += ch;
62
+ };
63
+ tty.on('data', onData);
64
+ });
65
+ }
66
+
67
+ async function main() {
68
+ const loaded = await store.load();
69
+ if (!loaded.loaded && cmd !== 'create' && cmd !== 'list') {
70
+ process.stdout.write(`no user store at ${file} yet -- start the server once, or: create <username>\n`);
71
+ }
72
+ switch (cmd) {
73
+ case 'list': {
74
+ if (!store.count) { process.stdout.write('no users\n'); break; }
75
+ const rows = store.list();
76
+ process.stdout.write(rows.map((u) =>
77
+ `${u.username.padEnd(24)} ${u.role.padEnd(9)} ${u.disabled ? 'disabled' : 'enabled '} created ${new Date(u.createdAt).toISOString().slice(0, 10)} last ${u.lastLoginAt ? new Date(u.lastLoginAt).toISOString().slice(0, 16) : 'never'}`
78
+ ).join('\n') + '\n');
79
+ break;
80
+ }
81
+ case 'create': {
82
+ const username = args[0] || await ask('username: ');
83
+ const role = args[1] || await ask(`role (${ROLES.join('/')}): `) || 'viewer';
84
+ const pw = await ask('password (stdin, not echoed): ', { hidden: true });
85
+ try {
86
+ const u = await store.createUser(username, pw, { role });
87
+ process.stdout.write(`created ${u.username} as ${u.role}\n`);
88
+ } catch (err) { process.stderr.write(`${err.message}\n`); process.exitCode = 1; }
89
+ break;
90
+ }
91
+ case 'passwd': {
92
+ const username = args[0] || await ask('username: ');
93
+ const pw = await ask('new password (stdin, not echoed): ', { hidden: true });
94
+ try { await store.setPassword(username, pw); process.stdout.write(`password set for ${username}; all their sessions were left intact -- sign them out from the UI if needed\n`); }
95
+ catch (err) { process.stderr.write(`${err.message}\n`); process.exitCode = 1; }
96
+ break;
97
+ }
98
+ case 'role': {
99
+ const [username, role] = args;
100
+ if (!username || !role) { process.stderr.write('usage: role <username> <viewer|operator|admin>\n'); process.exitCode = 2; break; }
101
+ try { const r = await store.setRole(username, role); process.stdout.write(`${r.username} is now ${r.role}\n`); }
102
+ catch (err) { process.stderr.write(`${err.message}\n`); process.exitCode = 1; }
103
+ break;
104
+ }
105
+ case 'disable':
106
+ case 'enable': {
107
+ const username = args[0] || await ask('username: ');
108
+ try { const r = await store.setDisabled(username, cmd === 'disable'); process.stdout.write(`${r.username} ${r.disabled ? 'disabled' : 'enabled'}\n`); }
109
+ catch (err) { process.stderr.write(`${err.message}\n`); process.exitCode = 1; }
110
+ break;
111
+ }
112
+ case 'rm': {
113
+ const username = args[0] || await ask('username to delete: ');
114
+ const sure = await ask(`delete ${username}? type the username to confirm: `);
115
+ if (sure !== username) { process.stdout.write('not deleted\n'); break; }
116
+ try { const r = await store.deleteUser(username); process.stdout.write(`deleted ${r.deleted}\n`); }
117
+ catch (err) { process.stderr.write(`${err.message}\n`); process.exitCode = 1; }
118
+ break;
119
+ }
120
+ default:
121
+ process.stdout.write(`usage: manage-users.js <list|create|passwd|role|disable|enable|rm> [args]
122
+
123
+ users file: ${file} (0600, scrypt hashes only -- no recoverable passwords)
124
+ roles: ${ROLES.join(' < ')} viewer=read, operator=+enabled actions, admin=+users and audit
125
+ `);
126
+ }
127
+ }
128
+
129
+ main().catch((err) => {
130
+ process.stderr.write(`${err.message}\n`);
131
+ process.exitCode = 1;
132
+ });