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,276 @@
1
+ // THE ADDRESS INDEX FOLLOWS THE CHAIN (operator, 2026-09-14: "keep the index current as new blocks
2
+ // arrive"). The base index is immutable and covers the chain up to the block it was built at; this
3
+ // carries it forward, and back again when the chain reorganises.
4
+ //
5
+ // poll ask the node for its tip; if a block we hold is no longer on the node's chain, roll back
6
+ // to the fork; then fetch each block past our tip with getblock <hash> 3 (every input's
7
+ // prevout included, so it works for a node whose block files are elsewhere) and turn it
8
+ // into rows -- verboseBlockRows, checked row for row against the file builder
9
+ // log every block is appended to <index>/live.log BEFORE it is served, and so is every
10
+ // rollback. A record is [magic][type][length][payload][crc32]; replay stops at the first
11
+ // record that is short or fails its checksum, which is exactly a write the process did
12
+ // not finish, and the log is truncated there
13
+ // tail blocks not yet folded live in memory: their rows, and a key -> rows map for lookups
14
+ // fold once FOLD_BLOCKS blocks are at least CONFIRMATIONS deep, they are sorted into an
15
+ // immutable layer (layers/L<from>-<to>) and the log is rewritten without them. A block
16
+ // that deep is past any reorganisation this code will follow
17
+ // merge more than MAX_LAYERS layers are merged into one, so a lookup's cost stays bounded
18
+ //
19
+ // A reorganisation deeper than the tail -- into a layer or the base -- is not repaired: the index
20
+ // says it is stale and a rebuild is needed, rather than serving a history for a chain that is gone.
21
+ import { openSync, writeSync, closeSync, readFileSync, writeFileSync, renameSync, mkdirSync, unlinkSync, fsyncSync, existsSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { crc32 } from 'node:zlib';
24
+ import { IndexStore } from './store.js';
25
+ import { ROW, RowSink, verboseBlockRows } from './rows.js';
26
+ import { BLOCK_ROWS } from './build.js';
27
+
28
+ export const CONFIRMATIONS = 100;
29
+ export const FOLD_BLOCKS = 144;
30
+ export const MAX_LAYERS = 32;
31
+ const MAGIC = 0x42594c47; // 'BYLG'
32
+ const T_BLOCK = 1, T_ROLLBACK = 2;
33
+
34
+ // Sort rows by (key, height, position) and build the sparse index -- the same order the base uses.
35
+ export function sortRows(buf, blockRows = BLOCK_ROWS) {
36
+ const n = buf.length / ROW;
37
+ const sub = (i) => (buf[i * ROW] << 8) | buf[i * ROW + 1];
38
+ const counts = new Uint32Array(65537);
39
+ for (let i = 0; i < n; i++) counts[sub(i) + 1]++;
40
+ for (let s = 0; s < 65536; s++) counts[s + 1] += counts[s];
41
+ const order = new Uint32Array(n), fill = counts.slice(0, 65536);
42
+ for (let i = 0; i < n; i++) order[fill[sub(i)]++] = i;
43
+ const hi = new Float64Array(n), lo = new Float64Array(n);
44
+ for (let i = 0; i < n; i++) { const at = i * ROW; hi[i] = buf.readUIntBE(at + 2, 6); lo[i] = buf.readUIntBE(at + 8, 3) * 65536 + buf.readUInt16BE(at + 11); }
45
+ for (let s = 0; s < 65536; s++) {
46
+ const part = order.subarray(counts[s], counts[s + 1]);
47
+ if (part.length > 1) part.sort((x, y) => hi[x] - hi[y] || lo[x] - lo[y]);
48
+ }
49
+ const out = Buffer.allocUnsafe(buf.length);
50
+ const sparse = [];
51
+ let w = 0;
52
+ for (let k = 0; k < n; k++) {
53
+ const at = order[k] * ROW;
54
+ if (w > 0 && buf.compare(out, w - ROW, w - ROW + 13, at, at + 13) === 0) continue;
55
+ if ((w / ROW) % blockRows === 0) sparse.push(buf.readBigUInt64BE(at));
56
+ buf.copy(out, w, at, at + ROW);
57
+ w += ROW;
58
+ }
59
+ return { rows: out.subarray(0, w), idx: Buffer.from(new BigUint64Array(sparse).buffer) };
60
+ }
61
+
62
+ function writeAtomic(file, data) {
63
+ const fd = openSync(file + '.tmp', 'w');
64
+ for (let o = 0; o < data.length;) o += writeSync(fd, data, o, data.length - o);
65
+ fsyncSync(fd); closeSync(fd);
66
+ renameSync(file + '.tmp', file);
67
+ }
68
+
69
+ function record(type, payload) {
70
+ const head = Buffer.alloc(9);
71
+ head.writeUInt32LE(MAGIC, 0); head.writeUInt8(type, 4); head.writeUInt32LE(payload.length, 5);
72
+ const tail = Buffer.alloc(4); tail.writeUInt32LE(crc32(payload), 0);
73
+ return Buffer.concat([head, payload, tail]);
74
+ }
75
+
76
+ export class LiveIndex {
77
+ /**
78
+ * @param dir a built index directory
79
+ * @param rpc { batch(calls, opts) } -- the node that tells us about new blocks
80
+ */
81
+ constructor(dir, { rpc, nodeId = null, log = null, confirmations = CONFIRMATIONS, foldBlocks = FOLD_BLOCKS, maxLayers = MAX_LAYERS, maxBlocksPerPoll = 50 } = {}) {
82
+ this.dir = dir; this.rpc = rpc; this.nodeId = nodeId; this.log = log;
83
+ this.confirmations = confirmations; this.foldBlocks = foldBlocks; this.maxLayers = maxLayers; this.maxBlocksPerPoll = maxBlocksPerPoll;
84
+ this.store = new IndexStore(dir);
85
+ this.blocks = new Map(); // height -> { hash, rows: Buffer }
86
+ this.byKey = new Map(); // key hex (16) -> [Buffer, offset, Buffer, offset, ...]
87
+ this.stale = null; // a reason, once the chain has left us behind
88
+ this.lastError = null; this.lastPollAt = null; this.polling = false;
89
+ this.logFile = path.join(dir, 'live.log');
90
+ this.store.tail = this;
91
+ this.#replay();
92
+ }
93
+
94
+ get tip() { return this.blocks.size ? Math.max(...this.blocks.keys()) : this.store.sortedTip; }
95
+
96
+ status() {
97
+ return { baseTip: this.store.manifest.tip.height, sortedTip: this.store.sortedTip, tip: this.tip, tailBlocks: this.blocks.size, layers: this.store.layers.length, stale: this.stale, lastError: this.lastError, lastPollAt: this.lastPollAt };
98
+ }
99
+
100
+ // --- the tail -------------------------------------------------------------------------------
101
+ scan(key, visit) {
102
+ const list = this.byKey.get(key.toString(16).padStart(16, '0'));
103
+ if (!list) return;
104
+ for (let i = 0; i < list.length; i += 2) visit(list[i], list[i + 1]);
105
+ }
106
+
107
+ #add(height, hash, rows) {
108
+ this.blocks.set(height, { hash, rows });
109
+ for (let at = 0; at < rows.length; at += ROW) {
110
+ const k = rows.toString('hex', at, at + 8);
111
+ const list = this.byKey.get(k);
112
+ if (list) list.push(rows, at); else this.byKey.set(k, [rows, at]);
113
+ }
114
+ }
115
+
116
+ #drop(height) {
117
+ const b = this.blocks.get(height);
118
+ if (!b) return;
119
+ for (let at = 0; at < b.rows.length; at += ROW) {
120
+ const k = b.rows.toString('hex', at, at + 8);
121
+ const list = this.byKey.get(k);
122
+ if (!list) continue;
123
+ const kept = [];
124
+ for (let i = 0; i < list.length; i += 2) if (list[i] !== b.rows) kept.push(list[i], list[i + 1]);
125
+ if (kept.length) this.byKey.set(k, kept); else this.byKey.delete(k);
126
+ }
127
+ this.blocks.delete(height);
128
+ }
129
+
130
+ // --- the log --------------------------------------------------------------------------------
131
+ #replay() {
132
+ if (!existsSync(this.logFile)) return;
133
+ const buf = readFileSync(this.logFile);
134
+ let pos = 0, good = 0;
135
+ while (pos + 13 <= buf.length) {
136
+ if (buf.readUInt32LE(pos) !== MAGIC) break;
137
+ const type = buf.readUInt8(pos + 4), len = buf.readUInt32LE(pos + 5);
138
+ if (pos + 9 + len + 4 > buf.length) break;
139
+ const payload = buf.subarray(pos + 9, pos + 9 + len);
140
+ if (crc32(payload) !== buf.readUInt32LE(pos + 9 + len)) break;
141
+ if (type === T_BLOCK) {
142
+ const height = payload.readUInt32LE(0), hash = payload.toString('hex', 4, 36);
143
+ if (height === this.tip + 1) this.#add(height, hash, Buffer.from(payload.subarray(36)));
144
+ } else if (type === T_ROLLBACK) {
145
+ const to = payload.readUInt32LE(0);
146
+ for (const h of [...this.blocks.keys()]) if (h > to) this.#drop(h);
147
+ }
148
+ pos += 9 + len + 4; good = pos;
149
+ }
150
+ if (good < buf.length) {
151
+ // a record the process did not finish writing: cut it off so the next append follows good data
152
+ this.log?.warn?.(`live index log: dropping ${buf.length - good} bytes after the last complete record`);
153
+ writeAtomic(this.logFile, buf.subarray(0, good));
154
+ }
155
+ }
156
+
157
+ #append(rec) {
158
+ const fd = openSync(this.logFile, 'a');
159
+ try { writeSync(fd, rec); fsyncSync(fd); } finally { closeSync(fd); }
160
+ }
161
+
162
+ // --- following the node ---------------------------------------------------------------------
163
+ async #call(method, params, timeoutMs = 120_000) {
164
+ const [r] = await this.rpc.batch([{ method, params }], { key: `live-index:${method}:${params[0]}`, timeoutMs, maxWaitMs: 300_000, priority: 6 });
165
+ if (!r?.ok) throw new Error(`${method}: ${r?.error?.message ?? 'no reply'}`);
166
+ return r.result;
167
+ }
168
+
169
+ /** One round: detect a reorg, catch up to the node's tip (at most maxBlocksPerPoll blocks), fold. */
170
+ async poll() {
171
+ if (this.polling || this.stale) return this.status();
172
+ this.polling = true;
173
+ try {
174
+ const nodeTip = await this.#call('getblockcount', []);
175
+ // REORG: walk down from our tip while our block is not the node's block at that height
176
+ let top = this.tip;
177
+ while (top > this.store.sortedTip && top <= nodeTip + 0 && this.blocks.has(top)) {
178
+ const theirs = await this.#call('getblockhash', [top]).catch(() => null);
179
+ if (theirs === this.blocks.get(top).hash) break;
180
+ top--;
181
+ }
182
+ // a tip above the node's (the node rolled back past it) is also a fork
183
+ while (top > nodeTip && top > this.store.sortedTip) top--;
184
+ // WALKED DOWN TO WHAT IS FOLDED: that block must still be the node's too, or the reorganisation
185
+ // went below the tail. Checked every poll -- a tail can be empty, with everything folded.
186
+ if (top === this.store.sortedTip) {
187
+ const known = this.#foldedHash(top);
188
+ const theirs = top <= nodeTip ? await this.#call('getblockhash', [top]).catch(() => null) : null;
189
+ if (known && theirs !== known) { this.stale = `the chain reorganised at or below block ${top}, which is already folded into the index; rebuild the index`; return this.status(); }
190
+ if (!known) top = Math.min(top, this.store.sortedTip);
191
+ }
192
+ if (top < this.tip) {
193
+ if (top < this.store.sortedTip) { this.stale = `the chain reorganised below block ${this.store.sortedTip}, which is already folded; rebuild the index`; return this.status(); }
194
+ const payload = Buffer.alloc(4); payload.writeUInt32LE(top, 0);
195
+ this.#append(record(T_ROLLBACK, payload));
196
+ for (const h of [...this.blocks.keys()]) if (h > top) this.#drop(h);
197
+ this.log?.info?.(`live index: rolled back to block ${top}`);
198
+ }
199
+ // CATCH UP
200
+ for (let h = this.tip + 1, n = 0; h <= nodeTip && n < this.maxBlocksPerPoll; h++, n++) {
201
+ const hash = await this.#call('getblockhash', [h]);
202
+ const block = await this.#call('getblock', [hash, 3], 300_000);
203
+ if (block.height !== h) throw new Error(`getblock ${hash} answered height ${block.height}, expected ${h}`);
204
+ if (h > 0 && this.blocks.has(h - 1) && block.previousblockhash !== this.blocks.get(h - 1).hash) break; // a reorg mid-catch-up: next poll rolls back
205
+ const sink = new RowSink(1 << 14);
206
+ verboseBlockRows(block, h, sink);
207
+ const rows = Buffer.from(sink.bytes());
208
+ const head = Buffer.alloc(36); head.writeUInt32LE(h, 0); Buffer.from(hash, 'hex').copy(head, 4);
209
+ this.#append(record(T_BLOCK, Buffer.concat([head, rows])));
210
+ this.#add(h, hash, rows);
211
+ }
212
+ this.#fold(nodeTip);
213
+ this.lastError = null;
214
+ } catch (err) {
215
+ this.lastError = err.message;
216
+ this.log?.warn?.(`live index: ${err.message}`);
217
+ } finally {
218
+ this.polling = false;
219
+ this.lastPollAt = Date.now();
220
+ }
221
+ return this.status();
222
+ }
223
+
224
+ // The hash of the highest folded block: the base's from its manifest, a layer's from layers/tips.json
225
+ // (written with the layer, since a layer file carries rows and no block hashes).
226
+ #foldedHash(height) {
227
+ if (height === this.store.manifest.tip.height) return this.store.manifest.tip.hash;
228
+ try { return JSON.parse(readFileSync(path.join(this.dir, 'layers', 'tips.json'), 'utf8'))[height] ?? null; } catch { return null; }
229
+ }
230
+
231
+ // --- folding --------------------------------------------------------------------------------
232
+ #fold(nodeTip) {
233
+ const deep = [...this.blocks.keys()].filter((h) => h <= nodeTip - this.confirmations).sort((a, b) => a - b);
234
+ if (deep.length < this.foldBlocks) return;
235
+ const from = deep[0], to = deep[deep.length - 1];
236
+ const { rows, idx } = sortRows(Buffer.concat(deep.map((h) => this.blocks.get(h).rows)), this.store.blockRows);
237
+ const dir = path.join(this.dir, 'layers');
238
+ mkdirSync(dir, { recursive: true });
239
+ const base = path.join(dir, `L${from}-${to}`);
240
+ // the top block's hash first, so a layer is never on disk without it
241
+ let tips = {};
242
+ try { tips = JSON.parse(readFileSync(path.join(dir, 'tips.json'), 'utf8')); } catch { /* the first layer */ }
243
+ tips[to] = this.blocks.get(to).hash;
244
+ writeAtomic(path.join(dir, 'tips.json'), Buffer.from(JSON.stringify(tips)));
245
+ writeAtomic(base + '.idx', idx);
246
+ writeAtomic(base + '.rows', rows);
247
+ this.store.reloadLayers();
248
+ // the log keeps only what is still in memory: rewritten, then swapped in whole
249
+ for (const h of deep) this.#drop(h);
250
+ const keep = [...this.blocks.keys()].sort((a, b) => a - b).map((h) => {
251
+ const b = this.blocks.get(h);
252
+ const head = Buffer.alloc(36); head.writeUInt32LE(h, 0); Buffer.from(b.hash, 'hex').copy(head, 4);
253
+ return record(T_BLOCK, Buffer.concat([head, b.rows]));
254
+ });
255
+ writeAtomic(this.logFile, Buffer.concat(keep));
256
+ this.log?.info?.(`live index: folded blocks ${from}..${to} (${rows.length / ROW} rows) into a layer`);
257
+ if (this.store.layers.length > this.maxLayers) this.#merge();
258
+ }
259
+
260
+ #merge() {
261
+ const layers = this.store.layers;
262
+ const from = layers[0].from, to = layers[layers.length - 1].to;
263
+ const dir = path.join(this.dir, 'layers');
264
+ const parts = layers.map((l) => readFileSync(path.join(dir, `L${l.from}-${l.to}.rows`)));
265
+ const { rows, idx } = sortRows(Buffer.concat(parts), this.store.blockRows);
266
+ const base = path.join(dir, `L${from}-${to}`);
267
+ // the merged layer under its final name first, then the old ones removed. A crash in between leaves
268
+ // the old layers AND the merged one on disk; the store ignores a layer whose range lies inside
269
+ // another's (IndexStore.reloadLayers), so nothing is counted twice, and the next merge tidies up.
270
+ writeAtomic(base + '.idx', idx);
271
+ writeAtomic(base + '.rows', rows);
272
+ for (const l of layers) { unlinkSync(path.join(dir, `L${l.from}-${l.to}.rows`)); unlinkSync(path.join(dir, `L${l.from}-${l.to}.idx`)); }
273
+ this.store.reloadLayers();
274
+ this.log?.info?.(`live index: merged ${layers.length} layers into L${from}-${to}`);
275
+ }
276
+ }
@@ -0,0 +1,145 @@
1
+ // ADDRESS INDEX ROWS, straight from block and undo bytes (docs/MEASUREMENTS.md §28-29).
2
+ //
3
+ // One row per (script, transaction that touched it), 21 bytes, big-endian so that byte order IS sort
4
+ // order:
5
+ //
6
+ // sh8 8 the first 8 bytes of sha256(scriptPubKey) -- an address, whatever its type
7
+ // height 3 the block
8
+ // pos 2 the transaction's position in that block
9
+ // value 8 signed satoshis: what the transaction paid to the script, minus what it spent from it
10
+ //
11
+ // A transaction that both pays and spends one script is ONE row with the net, not two. A script
12
+ // paid by an output is taken from the block; a script spent by an input is taken from the undo
13
+ // record, which is how the spending side is known without replaying the UTXO set.
14
+ //
15
+ // LEAN ON PURPOSE. server/chain/tx.js builds Core's verbose shape -- hex strings, classified types,
16
+ // encoded addresses -- and measured, that was 5.9 of the 9.5 single-core hours (§28). The index needs
17
+ // none of it: only each output's value and script bytes, and each spent coin's. So this walks the
18
+ // transaction bytes itself and hashes script bytes where they lie. It is checked row-for-row against
19
+ // rows built from the full decoder (test/chain-index.test.js), so lean never means different.
20
+ //
21
+ // OP_RETURN outputs are skipped: provably unspendable, never an address. Core names only the
22
+ // push-only ones `nulldata`; the rest are `nonstandard` there, and just as unspendable.
23
+ import { createHash } from 'node:crypto';
24
+ import { decodeBlockUndo } from '../blockfile.js';
25
+
26
+ export const ROW = 21;
27
+ export const MAX_POS = 0xffff;
28
+ export const MAX_HEIGHT = 0xffffff;
29
+
30
+ function varint(buf, st) {
31
+ const b = buf[st.pos++];
32
+ if (b < 0xfd) return b;
33
+ if (b === 0xfd) { const v = buf.readUInt16LE(st.pos); st.pos += 2; return v; }
34
+ if (b === 0xfe) { const v = buf.readUInt32LE(st.pos); st.pos += 4; return v; }
35
+ const v = Number(buf.readBigUInt64LE(st.pos)); st.pos += 8; return v;
36
+ }
37
+
38
+ /** sha256(script)'s first 8 bytes, as an unsigned BigInt. */
39
+ export function scriptKey(script) {
40
+ return createHash('sha256').update(script).digest().readBigUInt64BE(0);
41
+ }
42
+
43
+ /**
44
+ * Walk one transaction at st.pos: calls onOutput(value_sat, scriptSubarray) per output, returns the
45
+ * number of inputs. Leaves st.pos after the transaction.
46
+ */
47
+ function walkTx(buf, st, onOutput) {
48
+ st.pos += 4; // version
49
+ let segwit = false;
50
+ if (buf[st.pos] === 0 && buf[st.pos + 1] === 1) { segwit = true; st.pos += 2; }
51
+ const nin = varint(buf, st);
52
+ for (let i = 0; i < nin; i++) { st.pos += 36; const len = varint(buf, st); st.pos += len + 4; }
53
+ const nout = varint(buf, st);
54
+ for (let o = 0; o < nout; o++) {
55
+ const value = Number(buf.readBigUInt64LE(st.pos)); st.pos += 8;
56
+ const len = varint(buf, st);
57
+ onOutput(value, buf.subarray(st.pos, st.pos + len));
58
+ st.pos += len;
59
+ }
60
+ if (segwit) for (let i = 0; i < nin; i++) { const items = varint(buf, st); for (let k = 0; k < items; k++) { const len = varint(buf, st); st.pos += len; } }
61
+ st.pos += 4; // locktime
62
+ return nin;
63
+ }
64
+
65
+ /**
66
+ * Index rows for one block. `body` is the raw block, `undoBody` its undo record (null for genesis,
67
+ * whose coinbase spends nothing). Appends rows to `out` (a RowSink) and returns the row count.
68
+ */
69
+ export function blockRows(body, undoBody, height, out) {
70
+ if (height > MAX_HEIGHT) throw new RangeError(`height ${height} does not fit the row format`);
71
+ const undo = undoBody ? decodeBlockUndo(undoBody) : [];
72
+ const st = { pos: 80 };
73
+ const ntx = varint(body, st);
74
+ if (ntx - 1 > MAX_POS) throw new RangeError(`block ${height} has ${ntx} transactions, past the row format's position field`);
75
+ let rows = 0;
76
+ const moved = new Map();
77
+ for (let p = 0; p < ntx; p++) {
78
+ moved.clear();
79
+ walkTx(body, st, (value, script) => {
80
+ if (script.length > 0 && script[0] === 0x6a) return; // OP_RETURN: unspendable, not an address
81
+ const k = scriptKey(script);
82
+ moved.set(k, (moved.get(k) ?? 0) + value);
83
+ });
84
+ if (p > 0) {
85
+ const coins = undo[p - 1];
86
+ if (!coins) throw new Error(`block ${height}: no undo for transaction ${p}`);
87
+ for (const c of coins) {
88
+ const k = scriptKey(c.script);
89
+ moved.set(k, (moved.get(k) ?? 0) - c.value_sat);
90
+ }
91
+ }
92
+ for (const [k, v] of moved) { out.push(k, height, p, v); rows++; }
93
+ }
94
+ if (st.pos !== body.length) throw new RangeError(`block ${height}: ${body.length - st.pos} bytes left after the last transaction`);
95
+ return rows;
96
+ }
97
+
98
+ /** Packs rows into a growable Buffer of 21-byte records. */
99
+ export class RowSink {
100
+ constructor(initialRows = 1 << 16) { this.buf = Buffer.allocUnsafe(initialRows * ROW); this.n = 0; }
101
+ push(key, height, pos, value) {
102
+ const at = this.n * ROW;
103
+ if (at + ROW > this.buf.length) { const next = Buffer.allocUnsafe(this.buf.length * 2); this.buf.copy(next, 0, 0, at); this.buf = next; }
104
+ this.buf.writeBigUInt64BE(key, at);
105
+ this.buf.writeUIntBE(height, at + 8, 3);
106
+ this.buf.writeUInt16BE(pos, at + 11);
107
+ this.buf.writeBigInt64BE(BigInt(value), at + 13);
108
+ this.n++;
109
+ }
110
+ bytes() { return this.buf.subarray(0, this.n * ROW); }
111
+ }
112
+
113
+ export function readRow(buf, at = 0) {
114
+ return { key: buf.readBigUInt64BE(at), height: buf.readUIntBE(at + 8, 3), pos: buf.readUInt16BE(at + 11), value: Number(buf.readBigInt64BE(at + 13)) };
115
+ }
116
+
117
+ /**
118
+ * The same rows from the node's own decoding: `getblock <hash> 3`, which carries every input's prevout.
119
+ * This is how the live index follows the chain -- through RPC, one block at a time as they arrive,
120
+ * rather than re-reading the files -- and it must agree with blockRows row for row, which is checked on
121
+ * real blocks (test/chain-index-live.test.js and scripts/index-live-check.js). Amounts arrive as BTC
122
+ * floats and are rounded to satoshis, which is exact for every amount Core can express.
123
+ */
124
+ export function verboseBlockRows(block, height, out) {
125
+ if (height > MAX_HEIGHT) throw new RangeError(`height ${height} does not fit the row format`);
126
+ if (block.tx.length - 1 > MAX_POS) throw new RangeError(`block ${height} has ${block.tx.length} transactions, past the row format's position field`);
127
+ let rows = 0;
128
+ const moved = new Map();
129
+ block.tx.forEach((tx, p) => {
130
+ moved.clear();
131
+ for (const o of tx.vout) {
132
+ const hex = o.scriptPubKey.hex;
133
+ if (hex.startsWith('6a')) continue;
134
+ const k = scriptKey(Buffer.from(hex, 'hex'));
135
+ moved.set(k, (moved.get(k) ?? 0) + Math.round(o.value * 1e8));
136
+ }
137
+ if (p > 0) for (const v of tx.vin) {
138
+ if (!v.prevout) throw new Error(`block ${height} tx ${p}: no prevout -- getblock must be called with verbosity 3`);
139
+ const k = scriptKey(Buffer.from(v.prevout.scriptPubKey.hex, 'hex'));
140
+ moved.set(k, (moved.get(k) ?? 0) - Math.round(v.prevout.value * 1e8));
141
+ }
142
+ for (const [k, v] of moved) { out.push(k, height, p, v); rows++; }
143
+ });
144
+ return rows;
145
+ }
@@ -0,0 +1,154 @@
1
+ // READING THE ADDRESS INDEX (server/chain/index/build.js writes the base; live.js adds to it).
2
+ //
3
+ // Three kinds of source, read in height order so a history comes out oldest first:
4
+ // base 256 immutable segments from the full build: seg-XX.rows sorted 21-byte rows, seg-XX.idx
5
+ // the first key of every BLOCK_ROWS-th row (a few megabytes in all, held in memory)
6
+ // layers layers/L<from>-<to>.rows + .idx: the same sorted rows for a run of later blocks, folded
7
+ // out of the live tail once they are deep enough never to be reorganised away
8
+ // tail the newest blocks, in memory (live.js), handed in as an object with scan(key, visit)
9
+ // A lookup binary-searches each sorted source's sparse keys and reads only the row blocks that can
10
+ // hold its key.
11
+ import { openSync, readSync, closeSync, readFileSync, statSync, readdirSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { ROW, scriptKey, readRow } from './rows.js';
14
+ import { FORMAT } from './build.js';
15
+
16
+ const LAYER = /^L(\d+)-(\d+)\.rows$/;
17
+ const RING_MAX_BLIND = 4096; // rows (86 KB) a page may keep without first counting the history
18
+
19
+ // NO FILE IS HELD OPEN (2026-09-14): a store used to keep one descriptor per segment and layer --
20
+ // 256 and more -- for the life of the process, which is the whole soft limit on a stock macOS
21
+ // (`ulimit -n` 256) before the server has opened a socket. A lookup opens the one file it reads
22
+ // and closes it: three syscalls on a 0.25 ms lookup.
23
+ function openSorted(rowsFile, idxFile) {
24
+ const raw = readFileSync(idxFile);
25
+ const idx = new BigUint64Array(raw.buffer, raw.byteOffset, raw.length / 8).slice();
26
+ return { idx, file: rowsFile, rows: statSync(rowsFile).size / ROW };
27
+ }
28
+
29
+ export class IndexStore {
30
+ constructor(dir) {
31
+ this.dir = dir;
32
+ this.manifest = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
33
+ if (this.manifest.format !== FORMAT) throw new Error(`index format ${this.manifest.format}, this code reads ${FORMAT}`);
34
+ this.blockRows = this.manifest.blockRows;
35
+ this.segments = Array.from({ length: 256 }, (_, b) => {
36
+ const hex = b.toString(16).padStart(2, '0');
37
+ try { return openSorted(path.join(dir, `seg-${hex}.rows`), path.join(dir, `seg-${hex}.idx`)); } catch { return null; }
38
+ });
39
+ this.layers = [];
40
+ this.reloadLayers();
41
+ this.tail = null;
42
+ this.scratch = Buffer.allocUnsafe(ROW * this.blockRows);
43
+ }
44
+
45
+ /** Re-read layers/ (live.js calls this after folding or merging). */
46
+ reloadLayers() {
47
+ const old = this.layers;
48
+ const dir = path.join(this.dir, 'layers');
49
+ let names = [];
50
+ try { names = readdirSync(dir).filter((f) => LAYER.test(f)); } catch { /* no layers yet */ }
51
+ // a layer whose blocks lie inside another layer's range is a leftover from a merge that was
52
+ // interrupted after the merged layer was written: skipped, or its rows would be counted twice
53
+ const ranges = names.map((f) => { const [, from, to] = f.match(LAYER); return { f, from: Number(from), to: Number(to) }; });
54
+ const live = ranges.filter((r) => !ranges.some((o) => o !== r && o.from <= r.from && o.to >= r.to && (o.to - o.from) > (r.to - r.from)));
55
+ const idxOf = (f) => path.join(dir, f.replace(/\.rows$/, '.idx'));
56
+ this.layers = live.filter((r) => { try { readFileSync(idxOf(r.f), { flag: 'r' }); return true; } catch { return false; } })
57
+ .map((r) => ({ from: r.from, to: r.to, ...openSorted(path.join(dir, r.f), idxOf(r.f)) }))
58
+ .sort((a, b) => a.from - b.from);
59
+ }
60
+
61
+ /** The highest block the base and its contiguous layers cover (the tail, if any, continues it). */
62
+ get sortedTip() {
63
+ let tip = this.manifest.tip.height;
64
+ for (const l of this.layers) if (l.from === tip + 1) tip = l.to;
65
+ return tip;
66
+ }
67
+
68
+ get tip() {
69
+ return this.tail?.tip != null && this.tail.tip > this.sortedTip ? this.tail.tip : this.sortedTip;
70
+ }
71
+
72
+ close() { /* nothing is held open; kept for callers */ }
73
+
74
+ // Visit every row for a key in one sorted source, without allocating per row. Keys are compared as
75
+ // two unsigned 32-bit halves read straight from the page -- a BigInt per row was most of a lookup's
76
+ // time for an address with many rows.
77
+ #scanSorted(src, key, visit) {
78
+ if (!src || !src.idx.length) return;
79
+ let lo = 0, hi = src.idx.length - 1;
80
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (src.idx[mid] < key) lo = mid; else hi = mid - 1; }
81
+ const kh = Number(key >> 32n), kl = Number(key & 0xffffffffn);
82
+ const fd = openSync(src.file, 'r');
83
+ try {
84
+ for (let block = lo; block < src.idx.length; block++) {
85
+ const start = block * this.blockRows;
86
+ const want = Math.min(this.blockRows, src.rows - start) * ROW;
87
+ const got = readSync(fd, this.scratch, 0, want, start * ROW);
88
+ for (let at = 0; at < got; at += ROW) {
89
+ const h = this.scratch.readUInt32BE(at);
90
+ if (h < kh) continue;
91
+ if (h > kh) return;
92
+ const l = this.scratch.readUInt32BE(at + 4);
93
+ if (l < kl) continue;
94
+ if (l > kl) return;
95
+ visit(this.scratch, at);
96
+ }
97
+ }
98
+ } finally { closeSync(fd); }
99
+ }
100
+
101
+ /** Every row for a key across base, layers and tail, oldest block first. */
102
+ forEachRow(key, visit) {
103
+ this.#scanSorted(this.segments[Number(key >> 56n)], key, visit);
104
+ for (const l of this.layers) this.#scanSorted(l, key, visit);
105
+ this.tail?.scan(key, visit);
106
+ }
107
+
108
+ /** Every row for a key (a BigInt from scriptKey), oldest first. */
109
+ rowsForKey(key) {
110
+ const out = [];
111
+ this.forEachRow(key, (buf, at) => out.push(readRow(buf, at)));
112
+ return out;
113
+ }
114
+
115
+ rowsForScript(script) { return this.rowsForKey(scriptKey(script)); }
116
+
117
+ /**
118
+ * What an address page needs: transaction count, balance, and `limit` rows newest first after
119
+ * skipping the newest `skip` (a page). Balance is the sum of every row's net; `received` and `sent`
120
+ * are sums of those nets by sign, so a transaction that both paid and spent a script counts once,
121
+ * by its net -- not the gross figures an explorer that stores every output separately would show.
122
+ */
123
+ summaryForKey(key, { limit = 25, skip = 0, maxHeight = null } = {}) {
124
+ let txCount = 0, balance = 0, received = 0, sent = 0, postTip = 0;
125
+ // ROWS ABOVE maxHeight ARE NOT HISTORY (audit 2026-09-14, M2): after a reorganisation the tail
126
+ // can hold blocks the node no longer has until the follower's next poll; the caller passes the
127
+ // node's tip and those rows are counted in `postTip`, never in the balance or the page
128
+ const above = (buf, at) => maxHeight != null && buf.readUIntBE(at + 8, 3) > maxHeight;
129
+ // the newest skip+limit rows in a ring of raw bytes: no object per row, whatever the address's
130
+ // size; a page deep into a huge history keeps skip+limit rows, never the whole history.
131
+ // THE RING IS NEVER LARGER THAN THE HISTORY (audit 2026-09-14, M1: a page number is a request
132
+ // parameter, and `page=999999` sized a 525 MB ring for an address with two rows). A deep page
133
+ // counts the rows first, which is the same walk again, and sizes the ring to what exists.
134
+ let keep = Math.max(0, skip) + Math.max(0, limit);
135
+ if (keep > RING_MAX_BLIND) {
136
+ let n = 0;
137
+ this.forEachRow(key, (buf, at) => { if (!above(buf, at)) n++; });
138
+ keep = Math.min(keep, n);
139
+ }
140
+ const ring = Buffer.allocUnsafe(Math.max(1, keep) * ROW);
141
+ this.forEachRow(key, (buf, at) => {
142
+ if (above(buf, at)) { postTip++; return; }
143
+ const hi = buf.readInt32BE(at + 13), lo = buf.readUInt32BE(at + 17);
144
+ const v = hi * 4294967296 + lo;
145
+ txCount++; balance += v; if (v > 0) received += v; else sent -= v;
146
+ if (keep > 0) buf.copy(ring, ((txCount - 1) % keep) * ROW, at, at + ROW);
147
+ });
148
+ const recent = [];
149
+ for (let k = skip; k < Math.min(keep, txCount); k++) recent.push(readRow(ring, ((((txCount - 1 - k) % keep) + keep) % keep) * ROW));
150
+ return { txCount, balance, received, sent, recent, postTip };
151
+ }
152
+
153
+ summary(script, opts) { return this.summaryForKey(scriptKey(script), opts); }
154
+ }