blockyard 0.1.0 → 0.1.2

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 (71) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +247 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1071 -0
  32. package/public/js/scorchedai.js +268 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1361 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +27 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. package/systemd/blockyard.service +34 -3
@@ -8,10 +8,15 @@
8
8
  // 4. sort each bucket sorted (and de-duplicated) on the pool into seg-XX.rows + seg-XX.idx
9
9
  // 5. manifest written last, atomically: an index without one is an unfinished build
10
10
  //
11
+ // An interrupted build resumes: build-journal.json records the block files scanned and the buckets
12
+ // sorted, and nothing it cannot prove (THE BUILD JOURNAL, below).
13
+ //
11
14
  // The node's files are only read. Output goes to `out`, which should be on a different device from
12
15
  // the block files if one is available: the build reads ~880 GB and writes ~120 GB.
13
16
  import { Worker } from 'node:worker_threads';
14
- import { openSync, writeSync, closeSync, mkdirSync, readdirSync, rmSync, renameSync, writeFileSync, statSync } from 'node:fs';
17
+ import { openSync, writeSync, closeSync, mkdirSync, readdirSync, rmSync, renameSync, statSync, lstatSync, realpathSync, readFileSync, fsyncSync, ftruncateSync, unlinkSync } from 'node:fs';
18
+ import { createHash, randomUUID } from 'node:crypto';
19
+ import { crc32 } from 'node:zlib';
15
20
  import os from 'node:os';
16
21
  import path from 'node:path';
17
22
  import { HeightTable } from './heights.js';
@@ -22,7 +27,7 @@ export const FORMAT = 1;
22
27
  export const BLOCK_ROWS = 4096;
23
28
 
24
29
  async function chainHashes(rpc, tip, onProgress, pace = null) {
25
- const table = new HeightTable(1 << 21);
30
+ const table = HeightTable.forTip(tip); // sized from the tip, not a fixed 2^21 (audit 2026-09-16, L7)
26
31
  const hashes = new Array(tip + 1);
27
32
  // small batches at the lowest priority: the monitor's own polls interleave between them, and
28
33
  // on a machine shared with the node a 5,000-call batch held the lane for seconds (2026-09-14)
@@ -44,7 +49,13 @@ async function chainHashes(rpc, tip, onProgress, pace = null) {
44
49
 
45
50
  export class Pool {
46
51
  constructor(size, workerData, script = new URL('./worker.js', import.meta.url)) {
47
- this.workers = Array.from({ length: size }, () => new Worker(script, { workerData }));
52
+ this.closing = false;
53
+ this.lost = null;
54
+ this.workers = Array.from({ length: size }, () => {
55
+ const w = new Worker(script, { workerData });
56
+ w.on('exit', (code) => { if (!this.closing) this.lost ??= `exited with code ${code}`; });
57
+ return w;
58
+ });
48
59
  }
49
60
  // run jobs, at most one per worker; onResult may be async (it is awaited before that worker's next
50
61
  // job); `pace`, if given, is awaited before each job is handed out -- the server's background build
@@ -56,7 +67,11 @@ export class Pool {
56
67
  // node -- answered nothing, its job was never finished and never reported, and the other workers
57
68
  // drained the list and left the run waiting for a reply that could not come. `exit` and `error`
58
69
  // are the reply now: the run rejects, naming the job and the way to run again.
70
+ //
71
+ // A worker that exits BETWEEN jobs fails the run too: it was waited on for a reply to a job it was
72
+ // about to be given, and a later run on the same pool (the sort, after the scan) would hand it one.
59
73
  async run(jobs, onResult, pace = null) {
74
+ if (this.lost) throw new Error(`an index worker ${this.lost} between jobs -- if the machine ran out of memory, run again with fewer workers (addressIndexWorkers in config/local.json; each needs about 2.5 GB)`);
60
75
  let next = 0, failed = null;
61
76
  await Promise.all(this.workers.map((w) => new Promise((resolve) => {
62
77
  let current = null;
@@ -65,17 +80,18 @@ export class Pool {
65
80
  failed = new Error(`an index worker ${why} while on ${current ? JSON.stringify(current) : 'no job'} -- if the machine ran out of memory, run again with fewer workers (addressIndexWorkers in config/local.json; each needs about 2.5 GB)`);
66
81
  resolve();
67
82
  };
68
- w.on('exit', (code) => { if (current) die(`exited with code ${code}`); });
83
+ w.on('exit', (code) => { if (!this.closing) die(`exited with code ${code}`); });
69
84
  w.on('error', (err) => die(`threw: ${err?.message ?? err}`));
70
85
  const go = async () => {
71
86
  if (failed || next >= jobs.length) { current = null; resolve(); return; }
72
87
  const job = jobs[next++];
73
- if (pace) { try { await pace(); } catch (err) { failed = err; resolve(); return; } }
88
+ if (pace) { try { await pace(); } catch (err) { failed ??= err; resolve(); return; } }
89
+ if (failed) { resolve(); return; }
74
90
  current = job;
75
91
  w.once('message', async (msg) => {
76
92
  current = null;
77
- if (msg.type === 'error') { failed = new Error(`${JSON.stringify(msg.job)}: ${msg.message}`); resolve(); return; }
78
- try { await onResult(msg); } catch (err) { failed = err; resolve(); return; }
93
+ if (msg.type === 'error') { failed ??= new Error(`${JSON.stringify(msg.job)}: ${msg.message}`); resolve(); return; }
94
+ try { await onResult(msg); } catch (err) { failed ??= err; resolve(); return; }
79
95
  go();
80
96
  });
81
97
  w.postMessage(job);
@@ -84,7 +100,7 @@ export class Pool {
84
100
  })));
85
101
  if (failed) throw failed;
86
102
  }
87
- close() { return Promise.all(this.workers.map((w) => w.terminate())); }
103
+ close() { this.closing = true; return Promise.all(this.workers.map((w) => w.terminate())); }
88
104
  }
89
105
 
90
106
  /** Workers for a build on this machine: four cores left for the node, ~2.5 GB of memory each, sixteen at most. */
@@ -122,87 +138,447 @@ export function rpcPacer(rpc, { slowMs = 5000, easeMs = 250, holdMs = 10_000, on
122
138
  };
123
139
  }
124
140
 
125
- export async function buildIndex({ rpc, blocksDir, out, workers = defaultWorkers(), files = null, onProgress = () => {}, pace = null }) {
141
+ // ---------------------------------------------------------------------------------------------------
142
+ // THE BUILD JOURNAL: an interrupted build resumes (2026-09-16). The build reads ~880 GB and runs for
143
+ // hours, and a stop used to throw all of it away. `build-journal.json`, next to the partial output,
144
+ // records what is PROVEN done, and nothing else:
145
+ //
146
+ // scan per block file. After a file's rows are appended, the bucket files hold exactly the rows of
147
+ // the files finished so far -- the main thread appends one whole file at a time -- so a
148
+ // checkpoint is: fsync every bucket written since the last one, then write the journal with
149
+ // the set of finished files, each bucket's length and a running CRC-32 of its bytes, the
150
+ // heights seen and the counters. On resume every bucket is cut back to its journaled length,
151
+ // which removes whatever an unfinished file had appended; that file is scanned again.
152
+ // sort per bucket. The worker writes seg-XX.rows/.idx to a temporary name, fsyncs and renames;
153
+ // the journal then records the bucket sorted, and only after that is the unsorted input
154
+ // deleted. A bucket not in the journal is sorted again from its unsorted file, which the
155
+ // worker first checks against the journaled length and CRC -- corrupt input is never sorted.
156
+ //
157
+ // A checkpoint is written at most once a `checkpointMs` (a minute by default: an fsync of 256 files
158
+ // for every one of 5,700 block files would cost more than it saves), at the end of the scan, after
159
+ // every sorted bucket and when the scan fails in an orderly way. The journal is written to a temporary
160
+ // file, fsynced and renamed, and carries a SHA-256 of its own body: a torn or edited journal is not
161
+ // trusted. It identifies the build it belongs to -- journal version, index format, row and block
162
+ // sizes, chain, tip height and hash, the --files selection -- and a resume that does not match all of
163
+ // them, whose tip the node no longer has on its active chain, or whose files on disk disagree with it,
164
+ // discards the partial output and starts over, saying why. The build's tip is the journal's, not the
165
+ // node's newer one: the resumed index is the index an uninterrupted build started then would have
166
+ // written, row for row, and the follower catches it up to the node as it does any finished build.
167
+ //
168
+ // The manifest is still written last and atomically, and the journal is removed after it: readers
169
+ // never see an index without a manifest, and a manifest is never beside a journal that is still needed.
170
+ export const JOURNAL = 'build-journal.json';
171
+
172
+ // ---------------------------------------------------------------------------------------------------
173
+ // THE OUTPUT DIRECTORY IS CHECKED, AND ONLY THE INDEX'S OWN FILES ARE EVER REMOVED (audit 2026-09-16,
174
+ // M3). A fresh build used to `rmSync(out, { recursive: true })`: whatever `addressIndex` or `--out`
175
+ // named was deleted whole, minutes after start, with nobody watching. A test pointed it at a folder
176
+ // holding `.ssh/id_x`, and the key was gone. `addressIndex: "/home/bitcoin"`, `"."`, `--out ~` are
177
+ // one typo each. Now the directory must be missing, empty, or hold nothing but names an index writes;
178
+ // it must not be a symlink, the filesystem root, the home directory, the working directory, or the
179
+ // node's blocks directory or anything that contains it. A build clears index names and nothing else.
180
+ export const INDEX_ENTRY = /^(?:manifest\.json|build-journal\.json|build\.lock|live\.log|layers|bucket-[0-9a-f]{2}\.unsorted|seg-[0-9a-f]{2}\.(?:rows|idx))(?:\.tmp)?$/;
181
+
182
+ export function checkOutputDir(out, { blocksDir = null } = {}) {
183
+ const abs = path.resolve(out);
184
+ let st;
185
+ try { st = lstatSync(abs); } catch (err) {
186
+ if (err.code !== 'ENOENT') throw err;
187
+ mkdirSync(abs, { recursive: true, mode: 0o700 }); // owner-only (audit 2026-09-16, L10)
188
+ return abs;
189
+ }
190
+ if (st.isSymbolicLink()) throw new Error(`refusing to build the address index into ${abs}: it is a symlink; name the real directory`);
191
+ if (!st.isDirectory()) throw new Error(`refusing to build the address index into ${abs}: it is not a directory`);
192
+ const real = realpathSync(abs);
193
+ const realOr = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
194
+ const forbidden = new Map([[path.parse(real).root, 'the filesystem root'], [realOr(os.homedir()), 'the home directory'], [realOr(process.cwd()), 'the working directory']]);
195
+ if (forbidden.has(real)) throw new Error(`refusing to build the address index into ${abs}: it is ${forbidden.get(real)}`);
196
+ if (blocksDir) {
197
+ const blocks = realOr(blocksDir);
198
+ if (blocks === real || blocks.startsWith(real + path.sep)) throw new Error(`refusing to build the address index into ${abs}: it is, or contains, the node's blocks directory`);
199
+ }
200
+ const foreign = readdirSync(abs).filter((f) => !INDEX_ENTRY.test(f));
201
+ if (foreign.length) {
202
+ throw new Error(`refusing to build the address index into ${abs}: it holds files an index does not write (${foreign.slice(0, 5).join(', ')}${foreign.length > 5 ? ', …' : ''}); name an empty or new directory`);
203
+ }
204
+ return abs;
205
+ }
206
+
207
+ /** Remove the index's own entries from `out`, and nothing else (checkOutputDir has run). The build's lock is never one of them. */
208
+ function clearIndexEntries(out, keep = new Set()) {
209
+ for (const f of readdirSync(out)) if (INDEX_ENTRY.test(f) && f !== LOCK && !keep.has(f)) rmSync(path.join(out, f), { recursive: true, force: true });
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------------------------------
213
+ // ONE BUILD TO A DIRECTORY (audit 2026-09-16, L9). The server's background build and
214
+ // `scripts/index-build.js --out <the same dir>` could run at once -- the server's own failure message
215
+ // suggests that command, and a restart re-enters the build -- and each deleted, truncated and renamed
216
+ // the other's files. `build.lock` is created with O_EXCL and holds the owner's PID; a lock whose
217
+ // process is gone is taken over. A lock naming THIS process but not held by it is stale too: a
218
+ // restarted container often gets the PID the killed one had. Released in a finally, and a
219
+ // release removes only the lock this build wrote.
220
+ export const LOCK = 'build.lock';
221
+ const heldLocks = new Set();
222
+
223
+ function pidAlive(pid) {
224
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
225
+ }
226
+
227
+ /** Take the build lock in `out` (which exists), or throw naming the build that holds it. Returns release(). */
228
+ export function lockOutputDir(out) {
229
+ const file = path.join(out, LOCK);
230
+ const key = path.resolve(file);
231
+ if (heldLocks.has(key)) throw new Error(`an address index build is already running into ${out} in this process`);
232
+ const token = JSON.stringify({ pid: process.pid, token: randomUUID(), startedAt: new Date().toISOString() });
233
+ for (let attempt = 0; ; attempt++) {
234
+ let fd;
235
+ try { fd = openSync(file, 'wx', 0o600); } catch (err) {
236
+ if (err.code !== 'EEXIST' || attempt >= 2) throw err;
237
+ let holder = null;
238
+ try { holder = JSON.parse(readFileSync(file, 'utf8')); } catch { /* torn or foreign: a build that died writing it */ }
239
+ const pid = Number.isSafeInteger(holder?.pid) && holder.pid > 0 ? holder.pid : null;
240
+ if (pid != null && pid !== process.pid && pidAlive(pid)) throw new Error(`another address index build (process ${pid}, started ${holder.startedAt ?? 'at an unknown time'}) is writing ${out}; wait for it to finish, or stop it`);
241
+ unlinkSync(file); // a lock left by a process that is gone
242
+ continue;
243
+ }
244
+ try { writeSync(fd, token); fsyncSync(fd); } finally { closeSync(fd); }
245
+ heldLocks.add(key);
246
+ return () => {
247
+ heldLocks.delete(key);
248
+ try { if (readFileSync(file, 'utf8') === token) unlinkSync(file); } catch { /* already gone */ }
249
+ };
250
+ }
251
+ }
252
+ const JOURNAL_VERSION = 1;
253
+
254
+ function syncDir(dir) {
255
+ try { const fd = openSync(dir, 'r'); try { fsyncSync(fd); } finally { closeSync(fd); } } catch { /* a directory cannot be fsynced everywhere */ }
256
+ }
257
+
258
+ /**
259
+ * Open a temporary file for writing, owner-only, without following a symlink planted at its name
260
+ * (audit 2026-09-16, L10: flag `w` followed one, so anyone who could create `manifest.json.tmp` in
261
+ * the directory had another file overwritten as the service account). Whatever is at the name is
262
+ * unlinked -- a link, not its target -- and the file is created with O_EXCL.
263
+ */
264
+ export function openTempFile(tmp) {
265
+ try { unlinkSync(tmp); } catch (err) { if (err.code !== 'ENOENT') throw err; }
266
+ return openSync(tmp, 'wx', 0o600);
267
+ }
268
+
269
+ /** Write a file durably and atomically: a temporary name, fsync, rename, fsync the directory. */
270
+ export function writeFileAtomic(file, data) {
271
+ const tmp = `${file}.tmp`;
272
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
273
+ const fd = openTempFile(tmp);
274
+ try { for (let o = 0; o < buf.length;) o += writeSync(fd, buf, o, buf.length - o); fsyncSync(fd); } finally { closeSync(fd); }
275
+ renameSync(tmp, file);
276
+ syncDir(path.dirname(file));
277
+ }
278
+
279
+ // a sorted set of integers as [first, last] runs: the files scanned are thousands of numbers but a few runs
280
+ const toRuns = (sorted) => { const r = []; for (const n of sorted) { if (r.length && r[r.length - 1][1] === n - 1) r[r.length - 1][1] = n; else r.push([n, n]); } return r; };
281
+ const fromRuns = (runs) => { const out = []; for (const [a, b] of runs) for (let n = a; n <= b; n++) out.push(n); return out; };
282
+ const isInt = (n, min = 0) => Number.isSafeInteger(n) && n >= min;
283
+ const isRuns = (r, max) => Array.isArray(r) && r.every((x, i) => Array.isArray(x) && x.length === 2 && isInt(x[0]) && isInt(x[1]) && x[0] <= x[1] && x[1] <= max && (i === 0 || x[0] > r[i - 1][1] + 1));
284
+
285
+ /** A journal body as the file holds it: the body's JSON text and the SHA-256 of that text. */
286
+ export function journalText(body) {
287
+ const text = JSON.stringify(body);
288
+ return JSON.stringify({ sha256: createHash('sha256').update(text).digest('hex'), body: text }) + '\n';
289
+ }
290
+
291
+ /**
292
+ * The journal in `out`, checked: { journal } when it is whole and well formed, { none } when there is
293
+ * none, { bad: why } when it cannot be trusted. Whether it belongs to THIS build is buildIndex's question.
294
+ */
295
+ export function readJournal(out) {
296
+ let raw;
297
+ try { raw = readFileSync(path.join(out, JOURNAL), 'utf8'); } catch (err) { return err.code === 'ENOENT' ? { none: true } : { bad: `the journal could not be read (${err.code ?? err.message})` }; }
298
+ let j;
299
+ try {
300
+ const outer = JSON.parse(raw);
301
+ if (typeof outer?.body !== 'string' || typeof outer.sha256 !== 'string') return { bad: 'the journal is not a journal' };
302
+ if (createHash('sha256').update(outer.body).digest('hex') !== outer.sha256) return { bad: 'the journal\'s checksum does not match its contents (a torn or edited write)' };
303
+ j = JSON.parse(outer.body);
304
+ } catch (err) { return { bad: `the journal is not readable JSON (${err.message}) -- a torn write` }; }
305
+ const tipH = j?.tip?.height;
306
+ const ok = !!j && j.journal === JOURNAL_VERSION && isInt(j.format, 1) && isInt(j.rowBytes, 1) && isInt(j.blockRows, 1)
307
+ && typeof j.chain === 'string' && isInt(tipH) && /^[0-9a-f]{64}$/.test(j.tip.hash ?? '')
308
+ && (j.files === 'all' || (Array.isArray(j.files) && j.files.every((f) => isInt(f))))
309
+ && Array.isArray(j.fileList) && j.fileList.every((f) => isInt(f))
310
+ && (j.phase === 'scan' || j.phase === 'sort') && isRuns(j.scanned, 99999) && isRuns(j.seen, tipH)
311
+ && Array.isArray(j.buckets) && j.buckets.length === 256 && j.buckets.every((b) => Array.isArray(b) && b.length === 2 && isInt(b[0]) && b[0] % j.rowBytes === 0 && isInt(b[1]) && b[1] <= 0xffffffff)
312
+ && Array.isArray(j.sorted) && j.sorted.length === 256 && j.sorted.every((s, b) => s === null || (j.phase === 'sort' && Array.isArray(s) && s.length === 3 && isInt(s[0]) && isInt(s[1]) && Number.isFinite(s[2]) && (s[0] + s[1]) * j.rowBytes === j.buckets[b][0]))
313
+ && !!j.counters && ['rows', 'stale', 'missingUndo', 'dupHeights', 'readMs', 'workMs'].every((k) => Number.isFinite(j.counters[k]) && j.counters[k] >= 0)
314
+ && isInt(j.resumes);
315
+ return ok ? { journal: j } : { bad: 'the journal is whole but not in the shape this code writes' };
316
+ }
317
+
318
+ /**
319
+ * Build the index into `out`, resuming an interrupted build there when its journal proves it can.
320
+ * `log(text)` hears why a journal was discarded or where a build resumed. `hook(point, info)` is for
321
+ * tests: it is called at the points where an interruption matters, and a throw there stops the build
322
+ * as a crash would.
323
+ */
324
+ export async function buildIndex({ rpc, blocksDir, out, workers = defaultWorkers(), files = null, onProgress = () => {}, pace = null, log = () => {}, checkpointMs = 60_000, hook = null }) {
325
+ // a pool of no workers scans nothing and would publish an empty index (audit 2026-09-16, I3)
326
+ if (!Number.isSafeInteger(workers) || workers < 1) throw new Error(`an address index build needs at least one worker, not ${JSON.stringify(workers)}`);
126
327
  const t0 = performance.now();
127
328
  const stats = { format: FORMAT, workers, phases: {} };
128
329
  const info = await rpc.batch([{ method: 'getblockchaininfo', params: [] }], { key: 'index:info', timeoutMs: 60_000 });
129
330
  if (!info[0].ok) throw new Error(`getblockchaininfo: ${info[0].error?.message}`);
130
- const { chain, blocks: tip } = info[0].result;
331
+ const { chain, blocks: nodeTip } = info[0].result;
131
332
  if (chain !== 'main') throw new Error(`only mainnet block files are framed here so far (the node is on ${chain})`);
333
+ const selection = files ? [...files] : 'all';
334
+ const discarding = (why) => log(`address index build: discarding the interrupted build in ${out} and starting over: ${why}`);
335
+ checkOutputDir(out, { blocksDir });
336
+ // the lock is taken after the directory is checked, so a refused directory is left untouched (L9)
337
+ const unlock = lockOutputDir(out);
338
+ try {
339
+ return await buildLocked({ rpc, blocksDir, out, workers, files, onProgress, pace, log, checkpointMs, hook, t0, stats, chain, nodeTip, selection, discarding });
340
+ } finally {
341
+ unlock();
342
+ }
343
+ }
344
+
345
+ async function buildLocked({ rpc, blocksDir, out, workers, files, onProgress, pace, log, checkpointMs, hook, t0, stats, chain, nodeTip, selection, discarding }) {
346
+ // --- resume? --------------------------------------------------------------
347
+ // The journal's identity is checked here, cheaply (one getblockhash); the files on disk are checked
348
+ // against it after the heights phase, where a fresh build clears the directory.
349
+ let journal = null;
350
+ let hasManifest = false;
351
+ try { statSync(path.join(out, 'manifest.json')); hasManifest = true; } catch { /* no finished index */ }
352
+ const found = hasManifest ? { none: true } : readJournal(out);
353
+ if (found.bad) discarding(found.bad);
354
+ else if (found.journal) {
355
+ const j = found.journal;
356
+ let why = null;
357
+ if (j.format !== FORMAT || j.rowBytes !== ROW || j.blockRows !== BLOCK_ROWS) why = `the journal is for index format ${j.format} (${j.rowBytes}-byte rows, ${j.blockRows} to a block) and this code writes format ${FORMAT} (${ROW}, ${BLOCK_ROWS})`;
358
+ else if (j.chain !== chain) why = `the journal is for chain ${j.chain} and the node is on ${chain}`;
359
+ else if (JSON.stringify(j.files) !== JSON.stringify(selection)) why = `the journal is for files ${JSON.stringify(j.files)} and this build is for ${JSON.stringify(selection)}`;
360
+ else if (nodeTip < j.tip.height) why = `the journal's tip is block ${j.tip.height} and the node is at ${nodeTip} (a reindex, or a different node)`;
361
+ else {
362
+ const got = await rpc.batch([{ method: 'getblockhash', params: [j.tip.height] }], { key: 'index:resume', timeoutMs: 60_000, maxWaitMs: 600_000, priority: 9 });
363
+ if (!got[0].ok) throw new Error(`getblockhash ${j.tip.height}: ${got[0].error?.message}`);
364
+ if (got[0].result !== j.tip.hash) why = `block ${j.tip.height} is ${got[0].result} on the node's chain now, not the journal's ${j.tip.hash} (a reorganisation below the build's tip)`;
365
+ }
366
+ if (why) discarding(why); else journal = j;
367
+ }
368
+ const tip = journal ? journal.tip.height : nodeTip;
132
369
 
133
370
  let t = performance.now();
134
371
  const { table, hashes } = await chainHashes(rpc, tip, onProgress, pace);
135
372
  stats.phases.heightsSec = (performance.now() - t) / 1000;
373
+ if (journal && hashes[tip] !== journal.tip.hash) { discarding(`block ${tip} changed on the node's chain while its hashes were read`); journal = null; }
374
+
375
+ const hex2 = (b) => b.toString(16).padStart(2, '0');
376
+ const bucketName = (b) => `bucket-${hex2(b)}.unsorted`;
377
+ const bucketFile = (b) => path.join(out, bucketName(b));
378
+ const sizeOf = (f) => { try { return statSync(path.join(out, f)).size; } catch { return -1; } };
379
+
380
+ // the files on disk must be what the journal says, or the journal is not trusted either
381
+ if (journal) {
382
+ const keep = new Set([JOURNAL]);
383
+ let why = null;
384
+ for (let b = 0; b < 256 && !why; b++) {
385
+ const len = journal.buckets[b][0], sorted = journal.sorted[b];
386
+ if (sorted) {
387
+ const rows = sorted[0];
388
+ if (sizeOf(`seg-${hex2(b)}.rows`) !== rows * ROW || sizeOf(`seg-${hex2(b)}.idx`) !== Math.ceil(rows / BLOCK_ROWS) * 8) why = `bucket ${hex2(b)} is journaled as sorted but its segment files are not the journaled size`;
389
+ keep.add(`seg-${hex2(b)}.rows`); keep.add(`seg-${hex2(b)}.idx`);
390
+ } else if (len > 0) {
391
+ // A BUCKET IS NEVER A SYMLINK (audit 2026-09-16, L10): a forged journal beside a planted
392
+ // bucket-XX.unsorted link made a resume truncate the link's target
393
+ let link = false;
394
+ try { link = lstatSync(bucketFile(b)).isSymbolicLink(); } catch { /* missing: the size check says so */ }
395
+ if (link) throw new Error(`refusing to resume the address index build in ${out}: ${bucketName(b)} is a symlink, which a build never writes`);
396
+ const size = sizeOf(bucketName(b));
397
+ if (size < len) why = `bucket ${hex2(b)} holds ${Math.max(0, size)} bytes and the journal proves ${len}`;
398
+ else if (journal.phase === 'sort' && size !== len) why = `bucket ${hex2(b)} grew after its scan was finished`;
399
+ keep.add(bucketName(b));
400
+ }
401
+ }
402
+ if (why) { discarding(why); journal = null; }
403
+ else {
404
+ // cut every bucket back to what is proven, and drop everything not proven: rows an unfinished
405
+ // file appended, a segment the journal never recorded, a temporary file, an input already sorted
406
+ for (let b = 0; b < 256; b++) {
407
+ if (journal.sorted[b] || journal.buckets[b][0] === 0) continue;
408
+ const fd = openSync(bucketFile(b), 'r+');
409
+ try { ftruncateSync(fd, journal.buckets[b][0]); fsyncSync(fd); } finally { closeSync(fd); }
410
+ }
411
+ clearIndexEntries(out, keep);
412
+ syncDir(out);
413
+ }
414
+ }
415
+ if (!journal) {
416
+ clearIndexEntries(out);
417
+ syncDir(out);
418
+ }
136
419
 
137
- rmSync(out, { recursive: true, force: true });
138
- mkdirSync(out, { recursive: true });
139
420
  const all = readdirSync(blocksDir).filter((f) => /^blk\d{5}\.dat$/.test(f)).map((f) => Number(f.slice(3, 8))).sort((a, b) => a - b);
140
- const fileList = files ?? all;
421
+ // a resume scans the files it listed and did not finish, and any file that has appeared since: its
422
+ // blocks above the tip are skipped as in any build, and a block at or below it that is stored twice
423
+ // is de-duplicated by the sort
424
+ const fileList = journal ? [...new Set([...journal.fileList, ...(files ? [] : all)])].sort((a, b) => a - b) : (files ?? all);
425
+ const scannedSet = new Set(journal ? fromRuns(journal.scanned) : []);
426
+ const state = {
427
+ phase: journal?.phase ?? 'scan',
428
+ bucketLen: journal ? journal.buckets.map((b) => b[0]) : new Array(256).fill(0),
429
+ bucketCrc: journal ? journal.buckets.map((b) => b[1]) : new Array(256).fill(0),
430
+ sorted: journal ? journal.sorted.slice() : new Array(256).fill(null),
431
+ counters: journal ? { ...journal.counters } : { rows: 0, stale: 0, missingUndo: 0, dupHeights: 0, readMs: 0, workMs: 0 },
432
+ resumes: journal ? journal.resumes + 1 : 0,
433
+ };
434
+ const seen = new Uint8Array(tip + 1);
435
+ if (journal) for (const [a, b] of journal.seen) seen.fill(1, a, b + 1);
436
+ const writeJournal = () => {
437
+ const seenRuns = [];
438
+ for (let h = 0; h <= tip; h++) if (seen[h]) { const last = seenRuns[seenRuns.length - 1]; if (last && last[1] === h - 1) last[1] = h; else seenRuns.push([h, h]); }
439
+ writeFileAtomic(path.join(out, JOURNAL), journalText({
440
+ journal: JOURNAL_VERSION, format: FORMAT, rowBytes: ROW, blockRows: BLOCK_ROWS, chain,
441
+ tip: { height: tip, hash: hashes[tip] }, files: selection, fileList, phase: state.phase,
442
+ scanned: toRuns([...scannedSet].sort((a, b) => a - b)), seen: seenRuns,
443
+ buckets: state.bucketLen.map((len, b) => [len, state.bucketCrc[b]]), sorted: state.sorted,
444
+ counters: state.counters, resumes: state.resumes, writtenAt: new Date().toISOString(),
445
+ }));
446
+ hook?.('journal', { phase: state.phase, scanned: scannedSet.size, sorted: state.sorted.filter(Boolean).length });
447
+ };
448
+ if (journal) {
449
+ log(`address index build: resuming the interrupted build in ${out} at block ${tip} -- ${state.phase === 'scan' ? `${scannedSet.size} of ${fileList.length} block files already scanned` : `scan finished, ${state.sorted.filter(Boolean).length} buckets already sorted`}`);
450
+ stats.resumed = { times: state.resumes, filesSkipped: scannedSet.size, bucketsSkipped: state.sorted.filter(Boolean).length };
451
+ } else writeJournal();
452
+
141
453
  const pool = new Pool(workers, { blocksDir, key: xorKey(blocksDir), heightsBuffer: table.buffer, heightsCapacity: table.capacity, blockRowsPerIndex: BLOCK_ROWS });
454
+ const c = state.counters;
455
+ hook?.('pool', { pool });
142
456
 
143
457
  try {
144
458
  // --- scan -------------------------------------------------------------------
145
459
  t = performance.now();
146
- // AT MOST 64 BUCKET FILES OPEN AT ONCE (2026-09-14): 256 held open for the whole scan is the
147
- // entire soft limit on a stock macOS (`ulimit -n` 256). The least recently written is closed
148
- // and reopened for append when a 65th is needed -- a few thousand extra opens over a build.
149
- const fds = new Array(256).fill(null), lru = [];
150
- const MAX_OPEN = 64;
151
- const bucketFile = (b) => path.join(out, `bucket-${b.toString(16).padStart(2, '0')}.unsorted`);
152
- const fdFor = (b) => {
153
- if (fds[b] === null) {
154
- if (lru.length >= MAX_OPEN) { const old = lru.shift(); closeSync(fds[old]); fds[old] = null; }
155
- fds[b] = openSync(bucketFile(b), 'a');
156
- } else lru.splice(lru.indexOf(b), 1);
157
- lru.push(b);
158
- return fds[b];
159
- };
160
- const seen = new Uint8Array(tip + 1);
161
- let rows = 0, scanned = 0, readMs = 0, workMs = 0, stale = 0, missingUndo = 0, dupHeights = 0;
162
- await pool.run(fileList.map((file) => ({ type: 'scan', file })), (msg) => {
163
- const data = Buffer.from(msg.data);
164
- for (const [b, from, to] of msg.parts) {
165
- const fd = fdFor(b);
166
- for (let o = from; o < to;) o += writeSync(fd, data, o, to - o);
460
+ if (state.phase === 'scan') {
461
+ // AT MOST 64 BUCKET FILES OPEN AT ONCE (2026-09-14): 256 held open for the whole scan is the
462
+ // entire soft limit on a stock macOS (`ulimit -n` 256). The least recently written is closed
463
+ // and reopened for append when a 65th is needed -- a few thousand extra opens over a build.
464
+ const fds = new Array(256).fill(null), lru = [];
465
+ const MAX_OPEN = 64;
466
+ const fdFor = (b) => {
467
+ if (fds[b] === null) {
468
+ if (lru.length >= MAX_OPEN) { const old = lru.shift(); closeSync(fds[old]); fds[old] = null; }
469
+ fds[b] = openSync(bucketFile(b), 'a', 0o600); // owner-only (audit 2026-09-16, L10)
470
+ } else lru.splice(lru.indexOf(b), 1);
471
+ lru.push(b);
472
+ return fds[b];
473
+ };
474
+ const dirty = new Set();
475
+ // AN APPEND THAT FAILED PART WAY POISONS THE SCAN: the lengths in memory then include part of a
476
+ // file that is not finished, and no checkpoint may be written from them
477
+ let poisoned = false, lastCheckpoint = Date.now();
478
+ const checkpoint = () => {
479
+ if (poisoned) return;
480
+ for (const b of dirty) {
481
+ if (fds[b] !== null) fsyncSync(fds[b]);
482
+ else { const fd = openSync(bucketFile(b), 'r+'); try { fsyncSync(fd); } finally { closeSync(fd); } }
483
+ }
484
+ dirty.clear();
485
+ syncDir(out);
486
+ writeJournal();
487
+ lastCheckpoint = Date.now();
488
+ };
489
+ const todo = fileList.filter((f) => !scannedSet.has(f));
490
+ const from = scannedSet.size;
491
+ onProgress({ phase: 'scan', done: from, from, total: fileList.length, rows: c.rows });
492
+ try {
493
+ await pool.run(todo.map((file) => ({ type: 'scan', file })), (msg) => {
494
+ if (poisoned) throw new Error(`block file ${msg.file} was scanned after an append failed`);
495
+ poisoned = true;
496
+ const data = Buffer.from(msg.data);
497
+ msg.parts.forEach(([b, lo, hi], i) => {
498
+ const fd = fdFor(b);
499
+ for (let o = lo; o < hi;) o += writeSync(fd, data, o, hi - o);
500
+ state.bucketLen[b] += hi - lo;
501
+ state.bucketCrc[b] = crc32(data.subarray(lo, hi), state.bucketCrc[b]);
502
+ dirty.add(b);
503
+ hook?.('scan-part', { file: msg.file, part: i, parts: msg.parts.length });
504
+ });
505
+ for (const h of msg.heights) { if (seen[h]) c.dupHeights++; seen[h] = 1; }
506
+ c.rows += msg.rows; c.readMs += msg.readMs; c.workMs += msg.ms; c.stale += msg.stale; c.missingUndo += msg.missingUndo;
507
+ scannedSet.add(msg.file);
508
+ poisoned = false;
509
+ onProgress({ phase: 'scan', done: scannedSet.size, from, total: fileList.length, rows: c.rows, file: msg.file });
510
+ if (Date.now() - lastCheckpoint >= checkpointMs) checkpoint();
511
+ hook?.('scanned', { file: msg.file, done: scannedSet.size, total: fileList.length });
512
+ }, pace);
513
+ } catch (err) {
514
+ // an orderly failure (a worker that died, a node that went away) keeps the files finished so far
515
+ try { checkpoint(); } catch { /* the error that stopped the scan is the one to report */ }
516
+ throw err;
517
+ } finally {
518
+ for (let b = 0; b < 256; b++) if (fds[b] !== null) { closeSync(fds[b]); fds[b] = null; }
167
519
  }
168
- for (const h of msg.heights) { if (seen[h]) dupHeights++; seen[h] = 1; }
169
- rows += msg.rows; scanned++; readMs += msg.readMs; workMs += msg.ms; stale += msg.stale; missingUndo += msg.missingUndo;
170
- onProgress({ phase: 'scan', done: scanned, total: fileList.length, rows, file: msg.file });
171
- }, pace);
172
- for (const fd of fds) if (fd !== null) closeSync(fd);
173
- stats.phases.scanSec = (performance.now() - t) / 1000;
174
- stats.scan = { files: scanned, rawRows: rows, workerReadSec: readMs / 1000, workerCpuSec: workMs / 1000, staleBlocks: stale, missingUndo, duplicateHeights: dupHeights };
175
520
 
176
- // --- check ------------------------------------------------------------------
521
+ // --- check ------------------------------------------------------------------
522
+ let missing = 0, firstMissing = -1;
523
+ for (let h = 0; h <= tip; h++) if (!seen[h]) { missing++; if (firstMissing < 0) firstMissing = h; }
524
+ if (!files && missing) {
525
+ // nothing to resume: the same files would leave the same holes, so the next start scans afresh
526
+ rmSync(path.join(out, JOURNAL), { force: true });
527
+ throw new Error(`${missing} heights were not indexed (first ${firstMissing}); refusing to publish an index with holes`);
528
+ }
529
+ state.phase = 'sort';
530
+ checkpoint();
531
+ }
532
+ stats.phases.scanSec = (performance.now() - t) / 1000;
533
+ stats.scan = { files: scannedSet.size, rawRows: c.rows, workerReadSec: c.readMs / 1000, workerCpuSec: c.workMs / 1000, staleBlocks: c.stale, missingUndo: c.missingUndo, duplicateHeights: c.dupHeights };
177
534
  let missing = 0, firstMissing = -1;
178
535
  for (let h = 0; h <= tip; h++) if (!seen[h]) { missing++; if (firstMissing < 0) firstMissing = h; }
179
536
  stats.check = { tip, missingHeights: missing, firstMissing };
180
- if (!files && missing) throw new Error(`${missing} heights were not indexed (first ${firstMissing}); refusing to publish an index with holes`);
181
537
 
182
538
  // --- sort -------------------------------------------------------------------
183
539
  t = performance.now();
184
- const buckets = [];
185
- for (let b = 0; b < 256; b++) { try { if (statSync(bucketFile(b)).size) buckets.push(b); } catch { /* an empty bucket */ } }
186
- const counts = new Array(256).fill(0);
187
- let sortedRows = 0, dupes = 0, sortCpu = 0;
188
- await pool.run(buckets.map((bucket) => ({ type: 'sort', bucket, dir: out })), (msg) => {
189
- counts[msg.bucket] = msg.rows; sortedRows += msg.rows; dupes += msg.dupes; sortCpu += msg.ms;
190
- onProgress({ phase: 'sort', done: buckets.indexOf(msg.bucket) + 1, total: buckets.length, rows: sortedRows });
191
- });
540
+ const nonEmpty = [];
541
+ for (let b = 0; b < 256; b++) if (state.bucketLen[b] > 0) nonEmpty.push(b);
542
+ const todo = nonEmpty.filter((b) => !state.sorted[b]);
543
+ const from = nonEmpty.length - todo.length;
544
+ let sortedDone = from;
545
+ const sortedRows = () => state.sorted.reduce((a, s) => a + (s ? s[0] : 0), 0);
546
+ onProgress({ phase: 'sort', done: sortedDone, from, total: nonEmpty.length, rows: sortedRows() });
547
+ try {
548
+ await pool.run(todo.map((bucket) => ({ type: 'sort', bucket, dir: out, size: state.bucketLen[bucket], crc: state.bucketCrc[bucket] })), (msg) => {
549
+ hook?.('sort-result', { bucket: msg.bucket }); // segments renamed, not yet journaled
550
+ state.sorted[msg.bucket] = [msg.rows, msg.dupes, msg.ms];
551
+ writeJournal(); // proven sorted, and only then...
552
+ rmSync(bucketFile(msg.bucket), { force: true }); // ...is its input removed
553
+ sortedDone++;
554
+ onProgress({ phase: 'sort', done: sortedDone, from, total: nonEmpty.length, rows: sortedRows() });
555
+ hook?.('sorted', { bucket: msg.bucket, done: sortedDone, total: nonEmpty.length });
556
+ });
557
+ } catch (err) {
558
+ if (/does not hold what the scan wrote/.test(err.message)) {
559
+ // the proven input is not on the disk any more: nothing the journal says can be trusted
560
+ rmSync(path.join(out, JOURNAL), { force: true });
561
+ discarding(err.message.split('\n')[0]);
562
+ }
563
+ throw err;
564
+ }
565
+ const counts = state.sorted.map((s) => (s ? s[0] : 0));
192
566
  stats.phases.sortSec = (performance.now() - t) / 1000;
193
- stats.sort = { rows: sortedRows, duplicateRowsDropped: dupes, workerCpuSec: sortCpu / 1000 };
567
+ stats.sort = { rows: sortedRows(), duplicateRowsDropped: state.sorted.reduce((a, s) => a + (s ? s[1] : 0), 0), workerCpuSec: state.sorted.reduce((a, s) => a + (s ? s[2] : 0), 0) / 1000 };
194
568
 
195
569
  // --- manifest ---------------------------------------------------------------
570
+ hook?.('manifest', {});
196
571
  let bytes = 0;
197
- for (const f of readdirSync(out)) bytes += statSync(path.join(out, f)).size;
572
+ for (const f of readdirSync(out)) if (f !== JOURNAL && f !== LOCK) bytes += statSync(path.join(out, f)).size;
198
573
  stats.totalSec = (performance.now() - t0) / 1000;
199
574
  const manifest = {
200
575
  format: FORMAT, rowBytes: ROW, blockRows: BLOCK_ROWS, chain,
201
576
  tip: { height: tip, hash: hashes[tip] }, files: files ? fileList : 'all',
202
- rows: sortedRows, bucketRows: counts, bytes, builtAt: new Date().toISOString(), stats,
577
+ rows: sortedRows(), bucketRows: counts, bytes, builtAt: new Date().toISOString(), stats,
203
578
  };
204
- writeFileSync(path.join(out, 'manifest.json.tmp'), JSON.stringify(manifest, null, 1) + '\n');
205
- renameSync(path.join(out, 'manifest.json.tmp'), path.join(out, 'manifest.json'));
579
+ writeFileAtomic(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 1) + '\n');
580
+ rmSync(path.join(out, JOURNAL), { force: true });
581
+ syncDir(out);
206
582
  return manifest;
207
583
  } finally {
208
584
  await pool.close();
@@ -5,15 +5,32 @@
5
5
  // blocks at one height -- which the build checks for, since it counts every height exactly once.
6
6
  // The LAST 16 hex digits of the displayed hash, not the first: a displayed hash begins with the
7
7
  // proof of work, so a recent block's first 16 digits are all zero and every one of them would collide.
8
+ //
9
+ // THE TABLE IS SIZED FROM THE TIP, AND NEVER LOOPS WHEN FULL (audit 2026-09-16, L7). The capacity was
10
+ // fixed at 2^21 and nothing checked the load, so the 2,097,153rd block -- mainnet reaches it around
11
+ // 2046 -- sent `set` round the table forever, on the server's main thread. Now a build sizes it at
12
+ // least twice the heights it will hold (HeightTable.forTip), `set` refuses past a load of 0.75, and
13
+ // both `set` and `get` give up after one lap of the table whatever its state.
14
+ export const MAX_LOAD = 0.75;
15
+
8
16
  export class HeightTable {
9
17
  constructor(capacity = 1 << 21) {
18
+ if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 2 ** 30 || (capacity & (capacity - 1)) !== 0) throw new RangeError(`height table capacity must be a power of two, not ${capacity}`);
10
19
  this.capacity = capacity;
11
20
  this.buffer = new SharedArrayBuffer(capacity * 12);
21
+ this.count = 0;
12
22
  this._bind();
13
23
  }
24
+ /** A table for heights 0..tip: at least twice tip + 1 slots, a power of two, never under 1,024. */
25
+ static forTip(tip) {
26
+ if (!Number.isSafeInteger(tip) || tip < 0) throw new RangeError(`a height table needs a tip height, not ${tip}`);
27
+ let capacity = 1024;
28
+ while (capacity < 2 * (tip + 1)) capacity *= 2;
29
+ return new HeightTable(capacity);
30
+ }
14
31
  static attach(buffer, capacity) {
15
32
  const t = Object.create(HeightTable.prototype);
16
- t.capacity = capacity; t.buffer = buffer; t._bind();
33
+ t.capacity = capacity; t.buffer = buffer; t.count = null; t._bind();
17
34
  return t;
18
35
  }
19
36
  _bind() {
@@ -24,13 +41,22 @@ export class HeightTable {
24
41
  set(hashHex, height) {
25
42
  const k = HeightTable.#prefix(hashHex);
26
43
  let i = Number(k % BigInt(this.capacity));
27
- while (this.keys[i] !== 0n && this.keys[i] !== k) i = (i + 1) % this.capacity;
44
+ for (let probes = 0; this.keys[i] !== 0n && this.keys[i] !== k; probes++) {
45
+ if (probes >= this.capacity) throw new RangeError(`the height table is full (${this.capacity} slots)`);
46
+ i = (i + 1) % this.capacity;
47
+ }
48
+ if (this.keys[i] === 0n) {
49
+ // an attached table counts what it holds the first time it is written to
50
+ if (this.count == null) { let n = 0; for (let j = 0; j < this.capacity; j++) if (this.keys[j] !== 0n) n++; this.count = n; }
51
+ if (this.count + 1 > this.capacity * MAX_LOAD) throw new RangeError(`the height table is too small: ${this.count + 1} heights would load its ${this.capacity} slots past ${MAX_LOAD} (size it with HeightTable.forTip)`);
52
+ this.count++;
53
+ }
28
54
  this.keys[i] = k; this.values[i] = height;
29
55
  }
30
56
  get(hashHex) {
31
57
  const k = HeightTable.#prefix(hashHex);
32
58
  let i = Number(k % BigInt(this.capacity));
33
- while (this.keys[i] !== 0n) { if (this.keys[i] === k) return this.values[i]; i = (i + 1) % this.capacity; }
59
+ for (let probes = 0; probes < this.capacity && this.keys[i] !== 0n; probes++) { if (this.keys[i] === k) return this.values[i]; i = (i + 1) % this.capacity; }
34
60
  return -1;
35
61
  }
36
62
  }