blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,418 @@
1
+ // THE EXPLORER (operator, 2026-09-11: "we need to completely rip off the mempool space block and
2
+ // transaction explorers. I want us to be a complete superior replacement for mempool space").
3
+ //
4
+ // Our own implementation over this node's RPC -- no mempool.space code. What the node gives, measured the same day:
5
+ // * getrawtransaction <txid> 2 carries the fee and every input's prevout -- value, address
6
+ // (in the descriptor), the height it was created -- so one call is a whole transaction page;
7
+ // * gettxspendingprevout answers for CONFIRMED outputs too (a spent-by index), many
8
+ // outpoints per call;
9
+ // * getaddressbalance / getaddresstxids are an address index (insight-style arguments) -- and
10
+ // CORE DOES NOT HAVE THEM at any setting. Measured 2026-09-13 against both configured nodes,
11
+ // an Umbrel and a local Core: "Method not found" from each. xAddress therefore reports
12
+ // `indexed: false` with a NULL txCount rather than an empty list, because a refusal is not a
13
+ // count of zero. validateaddress answers everywhere (script parsing, no index), so the address
14
+ // is still confirmed and typed.
15
+ //
16
+ // Every call goes through the serialized lane like everything else (rule 1), and a page is ONE
17
+ // batched request: the lane spaces requests 250 ms apart, so 25 transactions fetched one by one
18
+ // would take six seconds. Explorer requests queue at priority 3 (ahead of the heavy tiers) and
19
+ // may wait 45 s rather than being dropped as stale. Decoded confirmed transactions are cached.
20
+ // Handlers answer { ok: false, error, hint } rather than throwing, so a bad query is a sentence.
21
+
22
+ import { statSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { IndexStore } from '../chain/index/store.js';
25
+ import { scriptKey } from '../chain/index/rows.js';
26
+ import { addressToScript } from '../chain/tx.js';
27
+
28
+ const HEX64 = /^[0-9a-fA-F]{64}$/;
29
+ export const PAGE = 25;
30
+ const OPTS = (key) => ({ key, priority: 3, maxWaitMs: 45_000 });
31
+
32
+ const txCache = new Map(); // txid -> summary; confirmed transactions only, LRU
33
+ const CACHE_MAX = 3000;
34
+ // a summary carries an object per input and output; a 20,000-output transaction is megabytes, and
35
+ // three thousand of those is not the cache this was meant to be (audit 2026-09-14, L2): the giants
36
+ // are decoded on demand and never kept
37
+ const CACHE_MAX_IO = 2000;
38
+ function remember(txid, summary) {
39
+ if (!summary || !(summary.confirmations > 0)) return;
40
+ if ((summary.vin?.length ?? 0) + (summary.vout?.length ?? 0) > CACHE_MAX_IO) return;
41
+ txCache.delete(txid);
42
+ txCache.set(txid, summary);
43
+ if (txCache.size > CACHE_MAX) txCache.delete(txCache.keys().next().value);
44
+ }
45
+ export function _resetCache() { txCache.clear(); noIndex.clear(); }
46
+
47
+ // A NODE THAT HAS NO ADDRESS INDEX IS NOT ASKED AGAIN ON EVERY VIEW (docs/DEFECTS.md, "the explorer
48
+ // has no address index"). Core refuses getaddressbalance and getaddresstxids at every setting, and
49
+ // xAddress used to send both on every address page -- two guaranteed failures per view, queued on a
50
+ // serialized lane that spaces requests 250 ms apart. A refusal is now remembered per node and the
51
+ // two calls are skipped.
52
+ //
53
+ // REMEMBERED FOR A WHILE, NOT FOR EVER. What a node answers is a fact about the node behind that id
54
+ // today: the config can point it at a different daemon, or an operator can swap in one that has the
55
+ // index. So the refusal expires and the next view asks again (AGENTS.md: a cached answer is a
56
+ // staleness bug with documentation attached). Only a real "method not found" counts -- a timeout or
57
+ // a busy node says nothing about whether the method exists, and must not switch the lookup off.
58
+ export const INDEX_RECHECK_MS = 10 * 60_000;
59
+ const noIndex = new Map(); // node id -> when its address index was last refused
60
+ let clock = () => Date.now();
61
+ export function _setClock(fn) { clock = fn ?? (() => Date.now()); }
62
+ const methodMissing = (r) => !r.ok && (r.error?.code === -32601 || /method not found/i.test(String(r.error?.message ?? '')));
63
+
64
+ const bad = (message, hint = null) => ({ ok: false, error: { message }, hint });
65
+ const sat = (btc) => (Number.isFinite(btc) ? Math.round(btc * 1e8) : null);
66
+ const addrOf = (spk) => spk?.address ?? spk?.addresses?.[0] ?? spk?.desc?.match(/^addr\(([^)]+)\)/)?.[1] ?? null;
67
+ const pageOf = (q) => Math.max(0, Math.min(1_000_000, Math.floor(Number(q?.page) || 0)));
68
+
69
+ // One transaction as the pages use it. Pure: the verbose (verbosity 2) reply in, a summary out.
70
+ export function txSummary(tx, height = null) {
71
+ const vin = (tx?.vin ?? []).map((v) => (v.coinbase != null
72
+ ? { coinbase: true, sequence: v.sequence ?? null }
73
+ : {
74
+ txid: v.txid ?? null, vout: v.vout ?? null, sequence: v.sequence ?? null,
75
+ value: sat(v.prevout?.value), address: addrOf(v.prevout?.scriptPubKey),
76
+ type: v.prevout?.scriptPubKey?.type ?? null, height: v.prevout?.height ?? null,
77
+ witness: Array.isArray(v.txinwitness) && v.txinwitness.length > 0,
78
+ }));
79
+ const vout = (tx?.vout ?? []).map((o) => ({
80
+ n: o.n ?? null, value: sat(o.value), address: addrOf(o.scriptPubKey), type: o.scriptPubKey?.type ?? null, spentBy: null,
81
+ }));
82
+ const coinbase = vin.length > 0 && vin[0].coinbase === true;
83
+ const known = vin.every((v) => v.coinbase || v.value != null);
84
+ const inSat = coinbase || !known ? null : vin.reduce((a, v) => a + v.value, 0);
85
+ const outSat = vout.reduce((a, o) => a + (o.value ?? 0), 0);
86
+ const fee = coinbase ? 0 : Number.isFinite(tx?.fee) ? sat(tx.fee) : inSat != null ? inSat - outSat : null;
87
+ const vsize = tx?.vsize ?? null;
88
+ const features = [];
89
+ if (vin.some((v) => v.witness)) features.push('segwit');
90
+ if (vin.some((v) => v.type === 'witness_v1_taproot') || vout.some((o) => o.type === 'witness_v1_taproot')) features.push('taproot');
91
+ if (!coinbase && vin.some((v) => Number.isFinite(v.sequence) && v.sequence < 0xfffffffe)) features.push('rbf');
92
+ if (!coinbase && vin.length >= 5 && vout.length <= 2) features.push('consolidation');
93
+ if (vout.some((o) => o.type === 'nulldata')) features.push('op_return');
94
+ const confirmations = tx?.confirmations ?? 0;
95
+ return {
96
+ txid: tx?.txid ?? null, hash: tx?.hash ?? null, version: tx?.version ?? null, locktime: tx?.locktime ?? null,
97
+ size: tx?.size ?? null, vsize, weight: tx?.weight ?? null,
98
+ fee, feerate: fee != null && vsize ? Math.round((fee / vsize) * 100) / 100 : null,
99
+ coinbase, inSat, outSat, vin, vout, features,
100
+ blockhash: tx?.blockhash ?? null, confirmations, time: tx?.blocktime ?? tx?.time ?? null, height,
101
+ };
102
+ }
103
+
104
+ // the row a block or address page lists
105
+ const brief = (s) => (s.missing ? s : {
106
+ txid: s.txid, fee: s.fee, feerate: s.feerate, vsize: s.vsize, outSat: s.outSat, coinbase: s.coinbase,
107
+ inCount: s.vin.length, outCount: s.vout.length, features: s.features, height: s.height, time: s.time,
108
+ confirmations: s.confirmations,
109
+ });
110
+
111
+ async function batch(m, calls, key) {
112
+ try { return await m.rpc.batch(calls, OPTS(key)); } catch (err) { return calls.map((c) => ({ ok: false, method: c.method, error: { message: err.message } })); }
113
+ }
114
+
115
+ // Fetch the transactions a page lists: cached ones free, the rest in ONE batch.
116
+ async function fetchTxs(m, txids, blockhash, height, key) {
117
+ const need = txids.filter((t) => !txCache.has(t));
118
+ const fresh = new Map();
119
+ if (need.length) {
120
+ const got = await batch(m, need.map((t) => ({ method: 'getrawtransaction', params: blockhash ? [t, 2, blockhash] : [t, 2] })), key);
121
+ got.forEach((g, i) => {
122
+ if (!g.ok || !g.result) return;
123
+ const conf = g.result.confirmations ?? 0;
124
+ const tip = m.state?.chainInfo?.blocks ?? null;
125
+ const h = height ?? (conf > 0 && tip != null ? tip - conf + 1 : null);
126
+ const s = txSummary(g.result, h);
127
+ fresh.set(need[i], s);
128
+ remember(need[i], s);
129
+ });
130
+ }
131
+ return txids.map((t) => txCache.get(t) ?? fresh.get(t) ?? { txid: t, missing: true });
132
+ }
133
+
134
+ export async function xSearch(m, q) {
135
+ const s = String(q?.q ?? '').trim();
136
+ if (!s) return bad('type a block height, a block hash, a transaction id or an address');
137
+ if (/^\d{1,9}$/.test(s)) return { ok: true, type: 'block', id: s };
138
+ if (HEX64.test(s)) {
139
+ const [h] = await batch(m, [{ method: 'getblockheader', params: [s, true] }], `${m.id}:x:find:${s}`);
140
+ return { ok: true, type: h.ok ? 'block' : 'tx', id: s.toLowerCase() };
141
+ }
142
+ if (/^[A-Za-z0-9]{14,100}$/.test(s)) {
143
+ const [va] = await batch(m, [{ method: 'validateaddress', params: [s] }], `${m.id}:x:find:${s}`);
144
+ // validateaddress works on every node (script parsing, no index), so a valid address still
145
+ // resolves -- but the page it lands on can only confirm the address, not list its history,
146
+ // wherever the node has no address index. The search says so rather than implying a hit.
147
+ if (va.ok && va.result?.isvalid) return { ok: true, type: 'address', id: s };
148
+ }
149
+ return bad(`nothing on this node matches "${s}"`, 'a height is digits; a block hash or txid is 64 hex characters; an address starts 1, 3 or bc1');
150
+ }
151
+
152
+ // AN UNCONFIRMED TRANSACTION HAS NO PREVOUTS IN ITS VERBOSE REPLY (operator, 2026-09-14, a mempool
153
+ // transaction with 858 inputs: every one "unknown script", no amounts, no fee). Core fills `prevout`
154
+ // from block undo data, which a transaction still in the mempool does not have yet, so
155
+ // getrawtransaction <txid> 2 answers those inputs with an outpoint and nothing else -- measured the same
156
+ // on both configured nodes. The spent outputs are read from the parents instead: one batch of
157
+ // getrawtransaction <parent> 1 (txindex for a confirmed parent, the mempool for an unconfirmed one),
158
+ // each distinct parent once. A parent the node cannot supply leaves its inputs as they were.
159
+ async function fillPrevouts(m, tx, key) {
160
+ const need = (tx.vin ?? []).filter((v) => v.coinbase == null && v.txid && !v.prevout);
161
+ if (!need.length) return tx;
162
+ const parents = [...new Set(need.map((v) => v.txid))];
163
+ const got = await batch(m, parents.map((p) => ({ method: 'getrawtransaction', params: [p, 1] })), key);
164
+ const tip = m.state?.chainInfo?.blocks ?? null;
165
+ const byId = new Map();
166
+ got.forEach((g, i) => { if (g.ok && g.result) byId.set(parents[i], g.result); });
167
+ for (const v of need) {
168
+ const parent = byId.get(v.txid);
169
+ const out = parent?.vout?.find((o) => o.n === v.vout);
170
+ if (!out) continue;
171
+ const conf = parent.confirmations ?? 0;
172
+ v.prevout = { value: out.value, scriptPubKey: out.scriptPubKey, height: conf > 0 && tip != null ? tip - conf + 1 : null, generated: false };
173
+ }
174
+ return tx;
175
+ }
176
+
177
+ export async function xTx(m, q) {
178
+ const txid = String(q?.txid ?? '').trim().toLowerCase();
179
+ if (!HEX64.test(txid)) return bad(`"${txid}" is not a 64-hex-character transaction id`);
180
+ const [r] = await batch(m, [{ method: 'getrawtransaction', params: [txid, 2] }], `${m.id}:x:tx:${txid}`);
181
+ if (!r.ok || !r.result) {
182
+ const msg = r.error?.message ?? 'the node refused';
183
+ return bad(msg, /not found|No such|information available/i.test(msg) ? 'not in this node\'s mempool or chain' : null);
184
+ }
185
+ const tx = await fillPrevouts(m, r.result, `${m.id}:x:txprev:${txid}`);
186
+ // one more turn: the block's height, and who spent each output
187
+ const calls = [];
188
+ if (tx.blockhash) calls.push({ method: 'getblockheader', params: [tx.blockhash, true] });
189
+ const outs = (tx.vout ?? []).slice(0, 500).map((o) => ({ txid, vout: o.n }));
190
+ if (outs.length) calls.push({ method: 'gettxspendingprevout', params: [outs] });
191
+ let height = null, spends = null;
192
+ for (const x of calls.length ? await batch(m, calls, `${m.id}:x:txmore:${txid}`) : []) {
193
+ if (!x.ok) continue;
194
+ if (x.method === 'getblockheader') height = x.result?.height ?? null;
195
+ if (x.method === 'gettxspendingprevout') spends = x.result;
196
+ }
197
+ const s = txSummary(tx, height);
198
+ if (Array.isArray(spends)) {
199
+ for (const sp of spends) {
200
+ const o = s.vout.find((v) => v.n === sp.vout);
201
+ if (o && sp.spendingtxid) o.spentBy = { txid: sp.spendingtxid, blockhash: sp.blockhash ?? null };
202
+ }
203
+ }
204
+ remember(txid, s);
205
+ return { ok: true, node: m.id, tx: s, tip: m.state?.chainInfo?.blocks ?? null, outputsShown: Math.min(s.vout.length, 500) };
206
+ }
207
+
208
+ export async function xBlock(m, q) {
209
+ const id = String(q?.id ?? '').trim();
210
+ const page = pageOf(q);
211
+ let hash = id.toLowerCase();
212
+ if (/^\d{1,9}$/.test(id)) {
213
+ const [r] = await batch(m, [{ method: 'getblockhash', params: [Number(id)] }], `${m.id}:x:bh:${id}`);
214
+ if (!r.ok) return bad(r.error?.message ?? `no block at height ${id}`, 'past the tip, or not stored on this node');
215
+ hash = r.result;
216
+ } else if (!HEX64.test(id)) return bad(`"${id}" is not a block height or a 64-hex-character block hash`);
217
+ const [b, st] = await batch(m, [{ method: 'getblock', params: [hash, 1] }, { method: 'getblockstats', params: [hash] }], `${m.id}:x:block:${hash}`);
218
+ if (!b.ok || !b.result) return bad(b.error?.message ?? 'the node refused', 'not stored on this node');
219
+ const blk = b.result;
220
+ const txids = Array.isArray(blk.tx) ? blk.tx : [];
221
+ const slice = txids.slice(page * PAGE, page * PAGE + PAGE);
222
+ const txs = (await fetchTxs(m, slice, blk.hash ?? hash, blk.height ?? null, `${m.id}:x:btx:${hash}:${page}`)).map(brief);
223
+ const row = m.mining?.rows?.get?.(blk.height) ?? null;
224
+ return {
225
+ ok: true, node: m.id,
226
+ block: {
227
+ hash: blk.hash ?? hash, height: blk.height ?? null, confirmations: blk.confirmations ?? null, time: blk.time ?? null,
228
+ mediantime: blk.mediantime ?? null, size: blk.size ?? null, strippedsize: blk.strippedsize ?? null, weight: blk.weight ?? null,
229
+ version: blk.version ?? null, versionHex: blk.versionHex ?? null, merkleroot: blk.merkleroot ?? null, bits: blk.bits ?? null,
230
+ nonce: blk.nonce ?? null, difficulty: blk.difficulty ?? null, chainwork: blk.chainwork ?? null, nTx: blk.nTx ?? txids.length,
231
+ previousblockhash: blk.previousblockhash ?? null, nextblockhash: blk.nextblockhash ?? null,
232
+ },
233
+ stats: st.ok ? st.result : null,
234
+ pool: row ? { label: row.poolLabel ?? null, tag: row.tagText ?? null } : null,
235
+ page, pages: Math.max(1, Math.ceil(txids.length / PAGE)), txs,
236
+ tip: m.state?.chainInfo?.blocks ?? null,
237
+ };
238
+ }
239
+
240
+ // THE LOCAL ADDRESS INDEX (server/chain/index/, built by scripts/index-build.js from the node's own
241
+ // block files). Core has no address lookup at any setting, so without this the page can only confirm
242
+ // that an address is valid. A node gets one with `"addressIndex": "<dir>"` in its config; the same
243
+ // index serves any node on the same chain. Opened once, and reopened when a rebuild replaces its
244
+ // manifest. A missing or unreadable index is reported on the page, not fatal.
245
+ const indexes = new Map(); // dir -> { store, error, mtimeMs }
246
+ // followers registered by main.js (server/chain/index/live.js): their store carries the live tail,
247
+ // so a page served through one includes every block the follower has taken in
248
+ const followers = new Map();
249
+ export function registerLiveIndex(dir, live) { followers.set(dir, live); }
250
+ // a build the server is running in the background (main.js): its progress, so the page can say
251
+ // "being built, 34%, about 20 min left" instead of "no index" (operator, 2026-09-14)
252
+ const builds = new Map();
253
+ export function registerIndexBuild(dir, status) { if (status) builds.set(dir, status); else builds.delete(dir); }
254
+ export function indexBuildStatus(dir) { return builds.get(dir) ?? null; }
255
+ function localIndex(m) {
256
+ if (m.addressIndex) return { store: m.addressIndex, error: null }; // tests inject a store
257
+ const dir = m.cfg?.addressIndex;
258
+ if (!dir) return null;
259
+ const live = followers.get(dir);
260
+ if (live) return { store: live.store, error: null, live };
261
+ const building = builds.get(dir);
262
+ if (building) return { store: null, error: null, building };
263
+ let mtimeMs = null;
264
+ try { mtimeMs = statSync(path.join(dir, 'manifest.json')).mtimeMs; } catch (err) { return { store: null, error: `no finished index at ${dir}` }; }
265
+ const had = indexes.get(dir);
266
+ if (had && had.mtimeMs === mtimeMs) return had;
267
+ try { had?.store?.close(); } catch { /* already closed */ }
268
+ let entry;
269
+ try { entry = { store: new IndexStore(dir), error: null, mtimeMs }; } catch (err) { entry = { store: null, error: err.message, mtimeMs }; }
270
+ indexes.set(dir, entry);
271
+ return entry;
272
+ }
273
+ export function _resetIndexes() { for (const e of indexes.values()) { try { e.store?.close(); } catch { /* closed */ } } indexes.clear(); }
274
+
275
+ // block height -> its txids in order, for turning an index row (height, position) into a transaction
276
+ const blockTxids = new Map();
277
+ const BLOCK_TXIDS_MAX = 64;
278
+ // THE UNSPENT OUTPUTS of an address (operator, 2026-09-14: "Why don't we do this"): the index says
279
+ // which transactions touched it; each one's outputs paying the address are asked of `gettxout`,
280
+ // which answers from the UTXO set and the mempool (an output a pending transaction spends is
281
+ // already gone). That walk is the whole history, so it is done for an address with at most this
282
+ // many transactions and declined, in words, for a longer one.
283
+ const UTXO_MAX_TXS = 100;
284
+
285
+ // positions -> txids: the blocks' hashes in one batch and their txid lists in another, cached
286
+ async function blocksFor(m, heights, key) {
287
+ const need = [...new Set(heights)].filter((h) => !blockTxids.has(h));
288
+ if (!need.length) return;
289
+ const hashes = await batch(m, need.map((h) => ({ method: 'getblockhash', params: [h] })), `${key}:hash:${need[0]}`);
290
+ const blocks = await batch(m, hashes.map((h) => ({ method: 'getblock', params: [h.ok ? h.result : '', 1] })), `${key}:blk:${need[0]}`);
291
+ blocks.forEach((b, i) => {
292
+ if (!b.ok || !Array.isArray(b.result?.tx)) return;
293
+ blockTxids.set(need[i], { hash: b.result.hash, tx: b.result.tx });
294
+ if (blockTxids.size > BLOCK_TXIDS_MAX) blockTxids.delete(blockTxids.keys().next().value);
295
+ });
296
+ }
297
+
298
+ async function unspentFor(m, store, key, addr, nodeTip) {
299
+ const all = store.rowsForKey(key).filter((r) => nodeTip == null || r.height <= nodeTip);
300
+ await blocksFor(m, all.map((r) => r.height), `${m.id}:x:utxo:${addr}`);
301
+ const heightOf = new Map();
302
+ for (const r of all) { const t = blockTxids.get(r.height)?.tx?.[r.pos]; if (t) heightOf.set(t, r.height); }
303
+ const sums = await fetchTxs(m, [...heightOf.keys()], null, null, `${m.id}:x:utxotx:${addr}`);
304
+ const cands = [];
305
+ // the height is the index's own row, not inferred from a confirmation count
306
+ for (const s of sums) if (!s.missing) for (const o of s.vout) if (o.address === addr && o.value != null) cands.push({ txid: s.txid, n: o.n, value: o.value, height: heightOf.get(s.txid) ?? s.height });
307
+ if (!cands.length) return [];
308
+ const got = await batch(m, cands.map((c) => ({ method: 'gettxout', params: [c.txid, c.n, true] })), `${m.id}:x:txout:${addr}`);
309
+ return cands.filter((c, i) => got[i]?.ok && got[i].result);
310
+ }
311
+
312
+ async function addressFromIndex(m0, addr, page, store, live = null) {
313
+ // THE BLOCKS AND TRANSACTIONS A PAGE NEEDS COME FROM THE FOLLOWER'S NODE when there is one. Confirmed
314
+ // chain data is the same on every node, and the follower's node is the one the index was built
315
+ // from -- here the local Core -- while the node selected on the page can be the slow one: measured
316
+ // just after a restart, the Umbrel answered RPC in 18-33 s and an address page waited 108-265 s in
317
+ // its queue, against ~1.5 s through the local node. The response names the node that answered.
318
+ const m = live?.rpc ? { ...m0, id: live.nodeId ?? m0.id, rpc: live.rpc } : m0;
319
+ const chain = m0.state?.chainInfo?.chain ?? m0.cfg?.chainHint ?? 'main';
320
+ const script = addressToScript(addr, chain);
321
+ const [va] = await batch(m, [{ method: 'validateaddress', params: [addr] }], `${m.id}:x:addr:${addr}`);
322
+ if (!script || (va.ok && va.result?.isvalid === false)) return bad(`"${addr}" is not a valid ${chain === 'main' ? 'mainnet ' : ''}address`);
323
+ if (store.manifest.chain && store.manifest.chain !== chain) return bad(`the address index on this node is for ${store.manifest.chain}, and this node is on ${chain}`);
324
+ const nodeTip = m0.state?.chainInfo?.blocks ?? null;
325
+ // rows above the node's tip are a reorganised-away tail the follower has not yet rolled back:
326
+ // counted in index.postTip, shown nowhere as history (audit 2026-09-14, M2)
327
+ const sum = store.summaryForKey(scriptKey(script), { limit: PAGE, skip: page * PAGE, maxHeight: nodeTip });
328
+ await blocksFor(m, sum.recent.map((r) => r.height), `${m.id}:x:a:${addr}`);
329
+ // the unspent outputs, for a history short enough to walk
330
+ const listable = sum.txCount <= UTXO_MAX_TXS;
331
+ const utxos = listable ? await unspentFor(m, store, scriptKey(script), addr, nodeTip) : null;
332
+ const txids = sum.recent.map((r) => blockTxids.get(r.height)?.tx?.[r.pos] ?? null);
333
+ const summaries = await fetchTxs(m, txids.filter(Boolean), null, null, `${m.id}:x:atx:${addr}:${page}`);
334
+ const byId = new Map(summaries.map((t) => [t.txid, t]));
335
+ const txs = sum.recent.map((r, i) => {
336
+ const s = txids[i] ? byId.get(txids[i]) : null;
337
+ // the amount is the index's own: what this transaction paid to the address minus what it spent
338
+ // from it, which the page shows even when the transaction itself could not be fetched
339
+ if (!s || s.missing) return { txid: txids[i], missing: true, height: r.height, delta: r.value };
340
+ return { ...brief(s), height: r.height, delta: r.value };
341
+ });
342
+ return {
343
+ ok: true, node: m0.id, dataNode: m.id, address: addr,
344
+ type: va.ok ? (va.result?.iswitness ? `witness v${va.result.witness_version ?? '?'}` : va.result?.isscript ? 'script' : 'legacy') : null,
345
+ scriptType: null,
346
+ indexed: true, source: 'local-index',
347
+ // received and sent are sums of each transaction's NET for this address (see IndexStore)
348
+ balance: { balance: sum.balance, received: sum.received, utxos: utxos ? utxos.length : null },
349
+ utxos, utxoNote: listable ? null : `not listed for an address with more than ${UTXO_MAX_TXS} transactions`,
350
+ txCount: sum.txCount,
351
+ // `tip` is how far the index reaches: the base, its layers and a follower's live tail together
352
+ index: {
353
+ tip: store.tip ?? store.manifest.tip.height, behind: nodeTip != null ? Math.max(0, nodeTip - (store.tip ?? store.manifest.tip.height)) : null,
354
+ builtAt: store.manifest.builtAt, following: !!live, stale: live?.stale ?? null, postTip: sum.postTip ?? 0,
355
+ },
356
+ page, pages: Math.max(1, Math.ceil(sum.txCount / PAGE)), txs, tip: nodeTip,
357
+ };
358
+ }
359
+
360
+ export async function xAddress(m, q) {
361
+ const addr = String(q?.addr ?? '').trim();
362
+ const page = pageOf(q);
363
+ if (!/^[A-Za-z0-9]{14,100}$/.test(addr)) return bad(`"${addr}" is not an address`);
364
+ const local = localIndex(m);
365
+ if (local?.store) return addressFromIndex(m, addr, page, local.store, local.live);
366
+ const now = clock();
367
+ const knownMissing = noIndex.has(m.id) && now - noIndex.get(m.id) < INDEX_RECHECK_MS;
368
+ let va, bal, ids;
369
+ if (knownMissing) {
370
+ [va] = await batch(m, [{ method: 'validateaddress', params: [addr] }], `${m.id}:x:addr:${addr}`);
371
+ // the refusal the node gave last time, marked as remembered rather than freshly asked
372
+ bal = ids = { ok: false, error: { code: -32601, message: 'Method not found', remembered: true } };
373
+ } else {
374
+ [va, bal, ids] = await batch(m, [
375
+ { method: 'validateaddress', params: [addr] },
376
+ { method: 'getaddressbalance', params: [{ addresses: [addr] }] },
377
+ { method: 'getaddresstxids', params: [{ addresses: [addr] }] },
378
+ ], `${m.id}:x:addr:${addr}`);
379
+ if (methodMissing(ids) && methodMissing(bal)) noIndex.set(m.id, now);
380
+ else if (ids.ok) noIndex.delete(m.id);
381
+ }
382
+ if (va.ok && va.result?.isvalid === false) return bad(`"${addr}" is not a valid address`);
383
+ // AN ABSENT INDEX IS NOT AN EMPTY ONE (operator, 2026-09-13: "fix broken search"). Measured
384
+ // against both configured nodes on 2026-09-13: getaddressbalance and getaddresstxids answer
385
+ // "Method not found" -- they are insight-style extensions that Core has never had, which
386
+ // docs/MEASUREMENTS.md already recorded as "Core has no such methods". validateaddress DOES
387
+ // answer on both (it is script parsing, no index), so the address itself can still be confirmed.
388
+ //
389
+ // The bug was here: a refused index became `[]`, which became txCount 0 and one empty page. The
390
+ // page then said "no transactions in this node's address index" -- indistinguishable from a real
391
+ // address with no history, and a figure this node never reported. A count nobody can answer is
392
+ // null, never zero.
393
+ const indexed = ids.ok && Array.isArray(ids.result);
394
+ const txids = indexed ? ids.result.slice().reverse() : []; // the index answers oldest first
395
+ const slice = txids.slice(page * PAGE, page * PAGE + PAGE);
396
+ const txs = (await fetchTxs(m, slice, null, null, `${m.id}:x:atx:${addr}:${page}`)).map((s) => {
397
+ if (s.missing) return s;
398
+ const delta = s.vout.reduce((a, o) => a + (o.address === addr ? o.value ?? 0 : 0), 0)
399
+ - s.vin.reduce((a, v) => a + (!v.coinbase && v.address === addr ? v.value ?? 0 : 0), 0);
400
+ return { ...brief(s), delta };
401
+ });
402
+ return {
403
+ ok: true, node: m.id, address: addr,
404
+ type: va.ok ? (va.result?.iswitness ? `witness v${va.result.witness_version ?? '?'}` : va.result?.isscript ? 'script' : 'legacy') : null,
405
+ scriptType: null,
406
+ balance: bal.ok ? bal.result : null, balanceError: bal.ok ? null : bal.error?.message ?? null,
407
+ // `indexed` is the honest flag the page renders from; txCount stays NULL when nothing can
408
+ // answer it, so no figure on screen is invented from a refusal
409
+ indexed,
410
+ txCount: indexed ? txids.length : null,
411
+ indexError: ids.ok ? null : ids.error?.message ?? null,
412
+ // a configured local index that could not be opened says why, rather than looking unconfigured
413
+ localIndexError: local?.error ?? null,
414
+ // ...and one the server is building right now says how far it has got
415
+ indexBuilding: local?.building ? { ...local.building } : null,
416
+ page, pages: indexed ? Math.max(1, Math.ceil(txids.length / PAGE)) : 1, txs, tip: m.state?.chainInfo?.blocks ?? null,
417
+ };
418
+ }
@@ -0,0 +1,77 @@
1
+ // THE GAME FILES (operator, 2026-09-15: "I've added doom_dos to the project directory. Get DOOM
2
+ // working as a diversion inside blockyard with zero dependancies"; later "move doom_dos out of the
3
+ // root and move into games", and "get Quake working as a diversion").
4
+ //
5
+ // The DOS Diversions run shareware DOOM and Quake in a PC emulated in the browser (public/js/x86.js,
6
+ // dospc.js, soundcard.js), and the emulator needs the games' own files. They live in games/, one
7
+ // directory a game, where the operator put them -- not in public/, which is the app and is stamped
8
+ // with a build id computed over every file in it (18 MB of PAK in that digest, re-hashed every two
9
+ // seconds of page loads, would be a cost paid by every page for two diversions).
10
+ //
11
+ // Served under /games/<game>/<path>, and only that: a game this file names, and a DOS path of at
12
+ // most one directory and an 8.3 name of a kind the games read (an executable, a WAD, a PAK, a
13
+ // config, Wolfenstein 3D's .WL1 data). No dots but the one in each name, so there is no path to
14
+ // traverse; the lookup is
15
+ // case-insensitive because DOS names are, and the files on disk are upper-case while a browser asks
16
+ // for whatever it was told.
17
+ import fsp from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { securityHeaders } from './static.js';
20
+
21
+ /** Each game's directory under the games root. */
22
+ export const GAME_DIRS = Object.freeze({ wolf3d: 'wolf3d_dos', doom: 'doom_dos', quake: 'quake_dos' });
23
+
24
+ export const GAME_PATH = /^\/games\/([a-z0-9]+)\/((?:[A-Za-z0-9_-]{1,8}\/)?[A-Za-z0-9_-]{1,8}\.(?:wad|exe|cfg|pak|wl1))$/i;
25
+
26
+ /** The file under `dir` at the DOS path `rel` ("ID1/PAK0.PAK"), matching each part ignoring case, or null. */
27
+ export async function findGameFile(dir, rel) {
28
+ let at = dir;
29
+ const parts = rel.split('/');
30
+ for (let i = 0; i < parts.length; i++) {
31
+ let names;
32
+ try { names = await fsp.readdir(at, { withFileTypes: true }); } catch { return null; }
33
+ const want = parts[i].toUpperCase(), last = i === parts.length - 1;
34
+ const hit = names.find((e) => (last ? e.isFile() : e.isDirectory()) && e.name.toUpperCase() === want);
35
+ if (!hit) return null;
36
+ at = path.join(at, hit.name);
37
+ }
38
+ return at;
39
+ }
40
+
41
+ /**
42
+ * Answer a /games/ request. Returns { status } when it answered, or null when the path is not one
43
+ * of ours (the caller's 404 applies).
44
+ */
45
+ export async function serveGame(req, res, urlPath, root, { tls = false, hstsMs = 0 } = {}) {
46
+ const m = GAME_PATH.exec(urlPath);
47
+ if (!m || !Object.hasOwn(GAME_DIRS, m[1])) return null;
48
+ const dirName = GAME_DIRS[m[1]];
49
+ const file = await findGameFile(path.join(root, dirName), m[2]);
50
+ const headers = securityHeaders({ tls, hstsMs });
51
+ if (!file) {
52
+ res.writeHead(404, { ...headers, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
53
+ res.end(req.method === 'HEAD' ? undefined : `${m[2].toUpperCase()} is not in games/${dirName}/`);
54
+ return { status: 404 };
55
+ }
56
+ const st = await fsp.stat(file);
57
+ const etag = `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
58
+ if (req.headers['if-none-match'] === etag) {
59
+ res.writeHead(304, { ...headers, ETag: etag, 'Cache-Control': 'no-cache' });
60
+ res.end();
61
+ return { status: 304 };
62
+ }
63
+ res.writeHead(200, {
64
+ ...headers,
65
+ 'Content-Type': 'application/octet-stream',
66
+ 'Content-Length': st.size,
67
+ 'Cache-Control': 'no-cache',
68
+ ETag: etag,
69
+ 'Last-Modified': new Date(st.mtimeMs).toUTCString(),
70
+ });
71
+ if (req.method === 'HEAD') { res.end(); return { status: 200 }; }
72
+ const { createReadStream } = await import('node:fs');
73
+ const stream = createReadStream(file);
74
+ stream.on('error', () => res.destroy());
75
+ stream.pipe(res);
76
+ return { status: 200 };
77
+ }