blockyard 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +108 -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 +239 -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 +1051 -0
  32. package/public/js/scorchedai.js +227 -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 +1338 -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 +21 -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
@@ -23,7 +23,7 @@ import path from 'node:path';
23
23
  import { crc32 } from 'node:zlib';
24
24
  import { IndexStore } from './store.js';
25
25
  import { ROW, RowSink, verboseBlockRows } from './rows.js';
26
- import { BLOCK_ROWS } from './build.js';
26
+ import { BLOCK_ROWS, openTempFile } from './build.js';
27
27
 
28
28
  export const CONFIRMATIONS = 100;
29
29
  export const FOLD_BLOCKS = 144;
@@ -59,10 +59,11 @@ export function sortRows(buf, blockRows = BLOCK_ROWS) {
59
59
  return { rows: out.subarray(0, w), idx: Buffer.from(new BigUint64Array(sparse).buffer) };
60
60
  }
61
61
 
62
+ // The temporary name is unlinked and created afresh with O_EXCL, owner-only: a symlink planted at
63
+ // live.log.tmp or tips.json.tmp is never followed (audit 2026-09-16, L10)
62
64
  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);
65
+ const fd = openTempFile(file + '.tmp');
66
+ try { for (let o = 0; o < data.length;) o += writeSync(fd, data, o, data.length - o); fsyncSync(fd); } finally { closeSync(fd); }
66
67
  renameSync(file + '.tmp', file);
67
68
  }
68
69
 
@@ -81,7 +82,7 @@ export class LiveIndex {
81
82
  constructor(dir, { rpc, nodeId = null, log = null, confirmations = CONFIRMATIONS, foldBlocks = FOLD_BLOCKS, maxLayers = MAX_LAYERS, maxBlocksPerPoll = 50 } = {}) {
82
83
  this.dir = dir; this.rpc = rpc; this.nodeId = nodeId; this.log = log;
83
84
  this.confirmations = confirmations; this.foldBlocks = foldBlocks; this.maxLayers = maxLayers; this.maxBlocksPerPoll = maxBlocksPerPoll;
84
- this.store = new IndexStore(dir);
85
+ this.store = new IndexStore(dir, { log });
85
86
  this.blocks = new Map(); // height -> { hash, rows: Buffer }
86
87
  this.byKey = new Map(); // key hex (16) -> [Buffer, offset, Buffer, offset, ...]
87
88
  this.stale = null; // a reason, once the chain has left us behind
@@ -138,6 +139,11 @@ export class LiveIndex {
138
139
  if (pos + 9 + len + 4 > buf.length) break;
139
140
  const payload = buf.subarray(pos + 9, pos + 9 + len);
140
141
  if (crc32(payload) !== buf.readUInt32LE(pos + 9 + len)) break;
142
+ // A RECORD WHOSE CHECKSUM HOLDS BUT WHOSE SHAPE DOES NOT is treated like a torn one: the log is cut
143
+ // there (audit 2026-09-16, I4). A block record shorter than its height and hash, or with rows
144
+ // that are not whole 21-byte rows, threw from this constructor and the follower never started.
145
+ if (type === T_BLOCK && (len < 36 || (len - 36) % ROW !== 0)) break;
146
+ if (type === T_ROLLBACK && len < 4) break;
141
147
  if (type === T_BLOCK) {
142
148
  const height = payload.readUInt32LE(0), hash = payload.toString('hex', 4, 36);
143
149
  if (height === this.tip + 1) this.#add(height, hash, Buffer.from(payload.subarray(36)));
@@ -155,7 +161,7 @@ export class LiveIndex {
155
161
  }
156
162
 
157
163
  #append(rec) {
158
- const fd = openSync(this.logFile, 'a');
164
+ const fd = openSync(this.logFile, 'a', 0o600); // owner-only (audit 2026-09-16, L10)
159
165
  try { writeSync(fd, rec); fsyncSync(fd); } finally { closeSync(fd); }
160
166
  }
161
167
 
@@ -235,7 +241,7 @@ export class LiveIndex {
235
241
  const from = deep[0], to = deep[deep.length - 1];
236
242
  const { rows, idx } = sortRows(Buffer.concat(deep.map((h) => this.blocks.get(h).rows)), this.store.blockRows);
237
243
  const dir = path.join(this.dir, 'layers');
238
- mkdirSync(dir, { recursive: true });
244
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
239
245
  const base = path.join(dir, `L${from}-${to}`);
240
246
  // the top block's hash first, so a layer is never on disk without it
241
247
  let tips = {};
@@ -76,7 +76,7 @@ export function blockRows(body, undoBody, height, out) {
76
76
  const moved = new Map();
77
77
  for (let p = 0; p < ntx; p++) {
78
78
  moved.clear();
79
- walkTx(body, st, (value, script) => {
79
+ const nin = walkTx(body, st, (value, script) => {
80
80
  if (script.length > 0 && script[0] === 0x6a) return; // OP_RETURN: unspendable, not an address
81
81
  const k = scriptKey(script);
82
82
  moved.set(k, (moved.get(k) ?? 0) + value);
@@ -84,6 +84,11 @@ export function blockRows(body, undoBody, height, out) {
84
84
  if (p > 0) {
85
85
  const coins = undo[p - 1];
86
86
  if (!coins) throw new Error(`block ${height}: no undo for transaction ${p}`);
87
+ // ONE SPENT COIN PER INPUT, OR THIS IS NOT THE BLOCK'S UNDO (audit 2026-09-16, L8). Blocks and
88
+ // undo records are paired by hash256(prevhash || undo), which does not commit to the block, so
89
+ // two sibling blocks in one file can each match the other's record. The coin count was trusted,
90
+ // and a two-input transaction given a one-coin undo wrote rows with the wrong spent scripts.
91
+ if (coins.length !== nin) throw new Error(`block ${height}: transaction ${p} has ${nin} inputs and its undo record ${coins.length} spent coins -- a block paired with another block's undo`);
87
92
  for (const c of coins) {
88
93
  const k = scriptKey(c.script);
89
94
  moved.set(k, (moved.get(k) ?? 0) - c.value_sat);
@@ -14,7 +14,9 @@ import { ROW, scriptKey, readRow } from './rows.js';
14
14
  import { FORMAT } from './build.js';
15
15
 
16
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
17
+ const RING_MAX_BLIND = 4096;
18
+ // the most rows to a sparse-index block a manifest may name: it sizes a read buffer (4,096 is written)
19
+ export const MAX_BLOCK_ROWS = 1 << 16; // rows (86 KB) a page may keep without first counting the history
18
20
 
19
21
  // NO FILE IS HELD OPEN (2026-09-14): a store used to keep one descriptor per segment and layer --
20
22
  // 256 and more -- for the life of the process, which is the whole soft limit on a stock macOS
@@ -22,16 +24,24 @@ const RING_MAX_BLIND = 4096; // rows (86 KB) a page may keep without first cou
22
24
  // and closes it: three syscalls on a 0.25 ms lookup.
23
25
  function openSorted(rowsFile, idxFile) {
24
26
  const raw = readFileSync(idxFile);
27
+ // whole 8-byte keys and whole rows, or the source is not one this code wrote (audit 2026-09-16, I4)
28
+ if (raw.length % 8 !== 0) throw new Error(`${path.basename(idxFile)} is ${raw.length} bytes, not a whole number of 8-byte keys`);
29
+ const size = statSync(rowsFile).size;
30
+ if (size % ROW !== 0) throw new Error(`${path.basename(rowsFile)} is ${size} bytes, not a whole number of ${ROW}-byte rows`);
25
31
  const idx = new BigUint64Array(raw.buffer, raw.byteOffset, raw.length / 8).slice();
26
- return { idx, file: rowsFile, rows: statSync(rowsFile).size / ROW };
32
+ return { idx, file: rowsFile, rows: size / ROW };
27
33
  }
28
34
 
29
35
  export class IndexStore {
30
- constructor(dir) {
36
+ constructor(dir, { log = null } = {}) {
31
37
  this.dir = dir;
38
+ this.log = log;
32
39
  this.manifest = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
33
40
  if (this.manifest.format !== FORMAT) throw new Error(`index format ${this.manifest.format}, this code reads ${FORMAT}`);
34
- this.blockRows = this.manifest.blockRows;
41
+ // blockRows sizes the read buffer below: checked before it allocates anything (audit 2026-09-16, I4)
42
+ const br = this.manifest.blockRows;
43
+ if (!Number.isSafeInteger(br) || br < 1 || br > MAX_BLOCK_ROWS) throw new Error(`index manifest names ${JSON.stringify(br)} rows to a block; this code reads 1 to ${MAX_BLOCK_ROWS}`);
44
+ this.blockRows = br;
35
45
  this.segments = Array.from({ length: 256 }, (_, b) => {
36
46
  const hex = b.toString(16).padStart(2, '0');
37
47
  try { return openSorted(path.join(dir, `seg-${hex}.rows`), path.join(dir, `seg-${hex}.idx`)); } catch { return null; }
@@ -53,8 +63,21 @@ export class IndexStore {
53
63
  const ranges = names.map((f) => { const [, from, to] = f.match(LAYER); return { f, from: Number(from), to: Number(to) }; });
54
64
  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
65
  const idxOf = (f) => path.join(dir, f.replace(/\.rows$/, '.idx'));
66
+ // A LAYER THAT CANNOT BE READ IS SKIPPED, as a segment is (audit 2026-09-16, I4): an .idx of the
67
+ // wrong length threw from here, out of a fold, and out of the constructor. It is named in
68
+ // `badLayers` and said once, and a missing .idx (a fold interrupted between its files) stays quiet.
69
+ this.badLayers = [];
56
70
  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)) }))
71
+ .flatMap((r) => {
72
+ try { return [{ from: r.from, to: r.to, ...openSorted(path.join(dir, r.f), idxOf(r.f)) }]; } catch (err) {
73
+ this.badLayers.push({ layer: r.f, error: err.message });
74
+ if (!(this.warned ??= new Set()).has(r.f)) {
75
+ this.warned.add(r.f);
76
+ this.log?.warn?.(`address index: skipping layer ${r.f}: ${err.message}`);
77
+ }
78
+ return [];
79
+ }
80
+ })
58
81
  .sort((a, b) => a.from - b.from);
59
82
  }
60
83
 
@@ -2,15 +2,19 @@
2
2
  //
3
3
  // scan one blk/rev file pair -> the file's index rows, partitioned by the key's first byte into
4
4
  // 256 buffers (a counting sort), so the main thread only appends each to its bucket file
5
- // sort one bucket file -> sorted, de-duplicated rows plus a sparse index, then the unsorted
6
- // input is removed
5
+ // sort one bucket file -> sorted, de-duplicated rows plus a sparse index, each written to a
6
+ // temporary name, fsynced and renamed. The input is first checked against the length and
7
+ // CRC-32 the scan journaled, and it is left in place: the main thread removes it once the
8
+ // build journal records the bucket sorted (build.js, THE BUILD JOURNAL)
7
9
  import { parentPort, workerData } from 'node:worker_threads';
8
- import { openSync, readSync, writeSync, closeSync, fstatSync, unlinkSync, renameSync } from 'node:fs';
10
+ import { openSync, readSync, writeSync, closeSync, fstatSync, fsyncSync, renameSync } from 'node:fs';
11
+ import { crc32 } from 'node:zlib';
9
12
  import path from 'node:path';
10
13
  import { Reader, readHeader } from '../tx.js';
11
14
  import { readChainFile, records, pairBlocksWithUndo, MAGIC } from '../blockfile.js';
12
15
  import { blockRows, RowSink, ROW } from './rows.js';
13
16
  import { HeightTable } from './heights.js';
17
+ import { openTempFile } from './build.js';
14
18
 
15
19
  const { blocksDir, key, heightsBuffer, heightsCapacity, blockRowsPerIndex } = workerData;
16
20
  const heights = HeightTable.attach(heightsBuffer, heightsCapacity);
@@ -26,7 +30,7 @@ function scan(file) {
26
30
  for (const r of records(blk, MAGIC.main, 0, xor)) {
27
31
  const rd = new Reader(r.body);
28
32
  const h = readHeader(rd);
29
- blocks.push({ body: r.body, hash: h.hash, previousblockhash: h.previousblockhash, ntx: rd.varint() });
33
+ blocks.push({ offset: r.offset, body: r.body, hash: h.hash, previousblockhash: h.previousblockhash, ntx: rd.varint() });
30
34
  }
31
35
  const pairs = pairBlocksWithUndo(blocks, [...records(rev, MAGIC.main, 32, xor)]);
32
36
  const sink = new RowSink(1 << 18);
@@ -37,7 +41,12 @@ function scan(file) {
37
41
  if (height < 0) { stale++; return; } // not on the chain this build covers
38
42
  const undo = pairs.get(i);
39
43
  if (!undo && height !== 0) { missingUndo++; return; }
40
- blockRows(b.body, undo ? undo.body : null, height, sink);
44
+ // A CORRUPT OR MISPAIRED RECORD FAILS THE FILE, AND SO THE BUILD -- fail-safe, and kept that way
45
+ // (audit 2026-09-16, I4) -- but the message names the files and offsets to look at
46
+ try { blockRows(b.body, undo ? undo.body : null, height, sink); } catch (err) {
47
+ err.message = `blk${id}.dat offset ${b.offset}${undo ? `, rev${id}.dat offset ${undo.offset}` : ''} (block ${height} ${b.hash}): ${err.message}`;
48
+ throw err;
49
+ }
41
50
  indexed.push(height);
42
51
  });
43
52
  // counting sort by the first key byte: 256 buffers the main thread appends as they are
@@ -57,7 +66,7 @@ function scan(file) {
57
66
  };
58
67
  }
59
68
 
60
- function sortBucket(bucket, dir) {
69
+ function sortBucket(bucket, dir, expectSize = null, expectCrc = null) {
61
70
  const t0 = performance.now();
62
71
  const name = (s) => path.join(dir, `bucket-${bucket.toString(16).padStart(2, '0')}${s}`);
63
72
  const fd = openSync(name('.unsorted'), 'r');
@@ -65,6 +74,9 @@ function sortBucket(bucket, dir) {
65
74
  const buf = Buffer.allocUnsafe(size);
66
75
  for (let got = 0; got < size;) { const k = readSync(fd, buf, got, size - got, got); if (!k) break; got += k; }
67
76
  closeSync(fd);
77
+ const hex = bucket.toString(16).padStart(2, '0');
78
+ if (expectSize != null && size !== expectSize) throw new Error(`bucket ${hex} does not hold what the scan wrote: ${size} bytes, and the scan wrote ${expectSize}`);
79
+ if (expectCrc != null && crc32(buf) !== expectCrc) throw new Error(`bucket ${hex} does not hold what the scan wrote: its CRC-32 is ${crc32(buf)}, and the scan's was ${expectCrc}`);
68
80
  const n = size / ROW;
69
81
  // order by the next 16 key bits into sub-buckets, then compare the rest numerically:
70
82
  // hi = key bytes 3..7 (40 bits), lo = height (24 bits) and position (16 bits)
@@ -92,16 +104,16 @@ function sortBucket(bucket, dir) {
92
104
  w += ROW;
93
105
  }
94
106
  const idx = Buffer.from(new BigUint64Array(sparse).buffer);
95
- const write = (file, data) => { const f = openSync(file + '.tmp', 'w'); for (let o = 0; o < data.length;) o += writeSync(f, data, o, data.length - o); closeSync(f); renameSync(file + '.tmp', file); };
96
- write(path.join(dir, `seg-${bucket.toString(16).padStart(2, '0')}.rows`), out.subarray(0, w));
97
- write(path.join(dir, `seg-${bucket.toString(16).padStart(2, '0')}.idx`), idx);
98
- unlinkSync(name('.unsorted'));
107
+ // a temporary name is unlinked and created afresh, owner-only: never a planted symlink followed (audit 2026-09-16, L10)
108
+ const write = (file, data) => { const f = openTempFile(file + '.tmp'); try { for (let o = 0; o < data.length;) o += writeSync(f, data, o, data.length - o); fsyncSync(f); } finally { closeSync(f); } renameSync(file + '.tmp', file); };
109
+ write(path.join(dir, `seg-${hex}.rows`), out.subarray(0, w));
110
+ write(path.join(dir, `seg-${hex}.idx`), idx);
99
111
  return { msg: { type: 'sorted', bucket, rows: w / ROW, dupes, ms: performance.now() - t0 }, transfer: [] };
100
112
  }
101
113
 
102
114
  parentPort.on('message', (job) => {
103
115
  try {
104
- const r = job.type === 'scan' ? scan(job.file) : sortBucket(job.bucket, job.dir);
116
+ const r = job.type === 'scan' ? scan(job.file) : sortBucket(job.bucket, job.dir, job.size, job.crc);
105
117
  parentPort.postMessage(r.msg, r.transfer);
106
118
  } catch (err) {
107
119
  parentPort.postMessage({ type: 'error', job, message: err.stack || err.message });
@@ -17,12 +17,14 @@
17
17
  const TS_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.(\d{3}) /;
18
18
  const TAG_RE = /^\[[a-z0-9_]+(?::\d+)?\]\s*/;
19
19
 
20
- // "1.0KB", "32.0 KB/s", "0.0B", "2.3MB", "128 KB". The node prints decimal
20
+ // "1.0KB", "32.0 KB/s", "0.0B", "2.3MB", "128 KB". (2026-09-16, audit L6: the number is
21
+ // `\d*\.\d+|\d+`, the same numbers as the old `[0-9]*\.?[0-9]+` without the two digit
22
+ // runs that could split a long string of digits every possible way.) The node prints decimal
21
23
  // units (4096 bytes renders as "4.0KB"), so decode the same way back.
22
24
  const SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, PB: 1e15 };
23
25
  export function parseSize(text) {
24
26
  if (text == null) return null;
25
- const m = String(text).trim().match(/^([0-9]*\.?[0-9]+)\s*(B|KB|MB|GB|TB|PB)(?:\/s)?$/i);
27
+ const m = String(text).trim().match(/^(\d*\.\d+|\d+)\s*(B|KB|MB|GB|TB|PB)(?:\/s)?$/i);
26
28
  if (!m) return null;
27
29
  const unit = m[2].toUpperCase();
28
30
  return Math.round(parseFloat(m[1]) * SIZE_UNITS[unit]);
@@ -30,7 +32,7 @@ export function parseSize(text) {
30
32
 
31
33
  export function parseRate(text) {
32
34
  if (text == null) return null;
33
- const m = String(text).trim().match(/^([0-9]*\.?[0-9]+)\s*(B|KB|MB|GB|TB)\/s$/i);
35
+ const m = String(text).trim().match(/^(\d*\.\d+|\d+)\s*(B|KB|MB|GB|TB)\/s$/i);
34
36
  if (!m) return null;
35
37
  return parseFloat(m[1]) * SIZE_UNITS[m[2].toUpperCase()];
36
38
  }
@@ -64,6 +66,11 @@ function addrParts(s) {
64
66
  return { host: s, port: null, addr: s };
65
67
  }
66
68
 
69
+ // 2026-09-16 (audit L6): a `\s*` in front of a capture such as `([^,]+)` that can itself
70
+ // start with a space lets the engine split one run of spaces between the two in every
71
+ // possible way -- quadratic on a long run, and each rule pays it on every line. Those
72
+ // captures now start with a character `\s` cannot match (`[^\s,][^,]*`), which matches
73
+ // the same real lines and leaves only one way to read the spaces.
67
74
  const RULES = [
68
75
  // [dlc] -- network recv this tick: 4.0KB (405.0B/s) | total recv: 2.3MB || disk write this tick: 0.0B (0.0B/s) | total written: 1.9MB --
69
76
  {
@@ -90,7 +97,7 @@ const RULES = [
90
97
  // [dlc] -- dead-weight floor this tick: 32.0 KB/s (pool median 0.0 KB/s, absolute 32.0 KB/s) --
91
98
  {
92
99
  name: 'deadweight',
93
- re: /\[dlc\]\s*--\s*dead-weight floor this tick:\s*([^\s]+(?:\s?[KMG]?B\/s)?)\s*\(pool median\s*([^,]+),\s*absolute\s*([^)]+)\)/,
100
+ re: /\[dlc\]\s*--\s*dead-weight floor this tick:\s*([^\s]+(?:\s?[KMG]?B\/s)?)\s*\(pool median\s*([^\s,][^,]*),\s*absolute\s*([^\s)][^)]*)\)/,
94
101
  apply(m) {
95
102
  return { kind: 'deadweight', floor: parseRate(m[1].trim()), poolMedian: parseRate(m[2].trim()), absolute: parseRate(m[3].trim()) };
96
103
  },
@@ -100,7 +107,7 @@ const RULES = [
100
107
  // [dlc] ranked 116 live peer(s) by a 2000-header sample in 44.6s: 39 answered, best 94 KB/s, median 63 KB/s, slowest answering 48 KB/s; the 77 silent rank last
101
108
  {
102
109
  name: 'ranking',
103
- re: /\[dlc\]\s*ranked\s*(\d+)\s*live peer\(s\)[^.]*in\s*([\d.]+)s:\s*(\d+)\s*answered,\s*best\s*([^,]+),\s*median\s*([^,]+),\s*slowest answering\s*([^;]+);\s*the\s*(\d+)\s*silent/,
110
+ re: /\[dlc\]\s*ranked\s*(\d+)\s*live peer\(s\)[^.]*in\s*([\d.]+)s:\s*(\d+)\s*answered,\s*best\s*([^\s,][^,]*),\s*median\s*([^\s,][^,]*),\s*slowest answering\s*([^\s;][^;]*);\s*the\s*(\d+)\s*silent/,
104
111
  apply(m) {
105
112
  return {
106
113
  kind: 'peer_ranking', live: +m[1], sampleSecs: +m[2], answered: +m[3],
@@ -131,7 +138,7 @@ const RULES = [
131
138
  // [block] stored height=965923 hash=0000000000000000.. bytes=1464177 tx=6866 (via 193.223.81.8:8333)
132
139
  {
133
140
  name: 'blockStored',
134
- re: /\[block\]\s*stored\s+height=(\d+)\s+hash=([0-9a-f.]+)\s+bytes=(\d+)\s+tx=(\d+)(?:\s*\(via\s*([^\)]+)\))?/,
141
+ re: /\[block\]\s*stored\s+height=(\d+)\s+hash=([0-9a-f.]+)\s+bytes=(\d+)\s+tx=(\d+)(?:\s*\(via\s*([^\s)][^)]*)\))?/,
135
142
  apply(m) {
136
143
  const via = addrParts(m[5]);
137
144
  return { kind: 'block_stored', height: +m[1], hashPrefix: m[2].replace(/\.$/, ''), bytes: +m[3], txs: +m[4], via: via?.addr ?? null, viaHost: via?.host ?? null };
@@ -198,7 +205,10 @@ const RULES = [
198
205
  // [mux:7] leg replaced: connected next pool peer 208.161.116.211:8333 (fd 266) addrv2=1
199
206
  { name: 'legReplaced', re: /\[mux:(\d+)\]\s*leg replaced:\s*connected next pool peer\s*(\S+)\s*\(fd\s*(\d+)\)\s*addrv2=(\d)/, apply: (m) => ({ kind: 'peer_connect', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, fd: +m[3], addrv2: m[4] === '1', reason: 'leg replaced' }) },
200
207
  // [mux:9] next peer 86.147.78.44:8333 unreachable: connect: Operation now in progress (leg stays down)
201
- { name: 'legDown', re: /\[mux:(\d+)\]\s*next peer\s*(\S+)\s+unreachable:\s*(.+?)\s*\(leg stays down\)/, apply: (m) => ({ kind: 'peer_unreachable', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, reason: m[3].trim() }) },
208
+ // 2026-09-16 (audit L6): was `unreachable:\s*(.+?)\s*\(leg stays down\)`, which is cubic
209
+ // on a long run of spaces -- 20,000 of them did not finish in 300 s. The lazy capture
210
+ // now meets a literal straight away, and the apply trims what the two `\s*` used to.
211
+ { name: 'legDown', re: /\[mux:(\d+)\]\s*next peer\s*(\S+)\s+unreachable:(.+?)\(leg stays down\)/, apply: (m) => ({ kind: 'peer_unreachable', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, reason: m[3].trim() }) },
202
212
  // [dl:7] 209.38.162.73:8333 connection dropped (revents 0x11); re-dialing
203
213
  { name: 'legDropped', re: /\[dl:(\d+)\]\s*(\S+?)\s+connection dropped\s*\(revents\s*(\S+?)\)(?:;\s*(\S+))?/, apply: (m) => ({ kind: 'peer_drop', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, revents: m[3], follow: m[4] || null }) },
204
214
  // [net] feeler 47.232.103.88:8333 -> dead
@@ -217,7 +227,7 @@ const RULES = [
217
227
  },
218
228
  },
219
229
  // [mempool] recent-rejects filter: 128 KB shared (...)
220
- { name: 'rejectFilter', re: /\[mempool\]\s*recent-rejects filter:\s*([^\s]+)\s*(?:KB|MB|B)?\s+shared/, apply: (m) => ({ kind: 'reject_filter', size: parseSize(m[1] + (/\s?(KB|MB|B)$/i.test(m[1]) ? '' : 'KB')) }) },
230
+ { name: 'rejectFilter', re: /\[mempool\]\s*recent-rejects filter:\s*([^\s]+)(?:\s*(?:KB|MB|B))?\s+shared/, apply: (m) => ({ kind: 'reject_filter', size: parseSize(m[1] + (/\s?(KB|MB|B)$/i.test(m[1]) ? '' : 'KB')) }) },
221
231
 
222
232
  // ---- the 2026-09-08 bench build (v0.0.1, built 03:02) rewrote these lines ----
223
233
  // Measured the same day against that node's own log: of 1,702 lines the rules
@@ -237,16 +247,21 @@ const RULES = [
237
247
  // unit to fill a gap.
238
248
  {
239
249
  name: 'bandwidthTick',
240
- re: /\[dlc\]\s*--\s*recv\s*([^\s(]+)\s*\(avg\s*([^)]+)\)\s*\|\s*write\s*([^\s(]+)\s*\(avg\s*([^)]+)\)\s*\|\s*floor\s*([^()]+?)\s*\(median\s*([^)]+)\)\s*\|\s*banned\s*(\d+)\/(\d+)(?:\s*\|\s*(events.*?))?\s*--/,
250
+ // 2026-09-16 (audit L6): every `\s*` in front of a capture that could also match
251
+ // spaces now hands over to a first character that cannot (`[^\s)]`), and no lazy
252
+ // capture is followed by `\s*` -- the captures keep their trailing spaces and the
253
+ // apply trims them. The old form retried the same run of spaces from both sides and
254
+ // went quadratic on a long one.
255
+ re: /\[dlc\]\s*--\s*recv\s*([^\s(]+)\s*\(avg\s*([^\s)][^)]*)\)\s*\|\s*write\s*([^\s(]+)\s*\(avg\s*([^\s)][^)]*)\)\s*\|\s*floor\s*([^\s()][^()]*?)\(median\s*([^\s)][^)]*)\)\s*\|\s*banned\s*(\d+)\/(\d+)(?:\s*\|\s*(events.*?)|\s*)--/,
241
256
  apply(m) {
242
257
  const counters = {};
243
258
  for (const [, k, v] of (m[9] || '').matchAll(/\b(events|rot|wait|help|fail)\s+(\d+)/g)) counters[k] = +v;
244
259
  return {
245
260
  kind: 'bandwidth',
246
261
  netRate: parseRate(m[1]),
247
- avgNetRate: parseRate(m[2]),
262
+ avgNetRate: parseRate(m[2].trim()),
248
263
  diskRate: parseRate(m[3]),
249
- avgDiskRate: parseRate(m[4]),
264
+ avgDiskRate: parseRate(m[4].trim()),
250
265
  floor: parseRate((m[5] || '').trim()),
251
266
  poolMedianText: (m[6] || '').trim() || null,
252
267
  banned: +m[7],
@@ -274,7 +289,10 @@ const RULES = [
274
289
  // floor this tick:`, `average since start:`) stay with their own rules.
275
290
  {
276
291
  name: 'bandwidthTickFields',
277
- re: /\[dlc\]\s*--\s*(.+?)\s*--/,
292
+ // 2026-09-16 (audit L6): `\s*(.+?)\s*--` retried every space run from both sides
293
+ // (quadratic). The capture now starts on a non-space and runs to the first `--`; its
294
+ // trailing spaces are trimmed with each segment below, so the fields are the same.
295
+ re: /\[dlc\]\s*--\s*(\S.*?)--/,
278
296
  apply(m) {
279
297
  const out = { kind: 'bandwidth', extraFields: [], extraValues: null };
280
298
  let claims = 0;
@@ -331,7 +349,8 @@ const RULES = [
331
349
  // the stored/applied figures survive whatever the node does to the prose.
332
350
  {
333
351
  name: 'dlcProgressFields',
334
- re: /\[dlc\]\s*==\s*(.+?)\s*==/,
352
+ // 2026-09-16 (audit L6): same rewrite as bandwidthTickFields, same reason.
353
+ re: /\[dlc\]\s*==\s*(\S.*?)==/,
335
354
  apply(m) {
336
355
  const out = { kind: 'dlc_progress', extraFields: [] };
337
356
  let claims = 0;
@@ -429,7 +448,7 @@ const RULES = [
429
448
  // re-windowed, and that is decoded as negative rather than dropped.
430
449
  {
431
450
  name: 'dlcWorkerPeer',
432
- re: /\[dlc\]\s*w(\d+)\s+(\S+?)\s+chunks=(\d+)\s+blocks=(\d+)\s*\(\+(-?\d+) blk\/s,\s*([^)]+)\)(?:\s*[\[(]([^\])]*?)[\])])?/,
451
+ re: /\[dlc\]\s*w(\d+)\s+(\S+?)\s+chunks=(\d+)\s+blocks=(\d+)\s*\(\+(-?\d+) blk\/s,\s*([^\s)][^)]*)\)(?:\s*[\[(]([^\])]*?)[\])])?/,
433
452
  apply(m) {
434
453
  const a = addrParts(m[2]);
435
454
  const note = (m[7] || '').trim() || null;
@@ -475,7 +494,11 @@ const RULES = [
475
494
  // Again: stored beside the others, not averaged with them.
476
495
  {
477
496
  name: 'catchupProgress',
478
- re: /\[utxo_live\]\s*catchup progress:\s*height=(\d+)\/(\d+)\s*\((\d+\.?\d*)%\)\s*([\d.]+)\s*blk\/s\s*\(avg\s*([\d.]+)\)\s*eta\s*(\S+)\s*\|\s*(.*?)\s*\(([\d.]+)\s*ms\/blk over (\d+)\)/,
497
+ // 2026-09-16 (audit L6): the phase text was `\|\s*(.*?)\s*\(`, quadratic on spaces;
498
+ // it is now everything between the bar and the parenthesis. Only the `name N%` pairs
499
+ // are read out of it, so the spaces it now keeps change nothing. The eta stops at a
500
+ // bar, so a run of bars is not re-scanned once for every bar in it.
501
+ re: /\[utxo_live\]\s*catchup progress:\s*height=(\d+)\/(\d+)\s*\((\d+\.?\d*)%\)\s*([\d.]+)\s*blk\/s\s*\(avg\s*([\d.]+)\)\s*eta\s*([^\s|]+)\s*\|(.*?)\(([\d.]+)\s*ms\/blk over (\d+)\)/,
479
502
  apply(m) {
480
503
  // Groups: 1 height 2 of 3 pct 4 blk/s 5 avg 6 eta 7 phase text 8 ms/blk 9 samples.
481
504
  // An earlier cut of this apply() read 8/9/10 and returned msPerBlk 155 for a
@@ -496,7 +519,7 @@ const RULES = [
496
519
  // 12 input run(s) unlinked; apply never waited
497
520
  {
498
521
  name: 'utxoCompaction',
499
- re: /\[utxo_live\]\s*compaction done in\s*([\d.]+)s\s*\((\d+) run\(s\)\s*\[[^\]]*\)\s*of\s*(\d+)[^;]*;\s*started at height\s*(\d+)\)?:\s*manifest_n\s*(\d+)\s*->\s*(\d+),\s*merged into run\s*(\d+),\s*(\d+) flushed meanwhile,\s*(\d+) input run\(s\) unlinked;\s*apply\s*(never waited|waited[^;,]*)/,
522
+ re: /\[utxo_live\]\s*compaction done in\s*([\d.]+)s\s*\((\d+) run\(s\)\s*\[[^\]]*\)\s*of\s*(\d+)(?:[^;\d][^;]*)?;\s*started at height\s*(\d+)\)?:\s*manifest_n\s*(\d+)\s*->\s*(\d+),\s*merged into run\s*(\d+),\s*(\d+) flushed meanwhile,\s*(\d+) input run\(s\) unlinked;\s*apply\s*(never waited|waited[^;,]*)/,
500
523
  apply(m) {
501
524
  const waited = m[10] !== 'never waited';
502
525
  return {
@@ -575,7 +598,7 @@ const RULES = [
575
598
  // median 59 s, p95 393 s apart on production.
576
599
  {
577
600
  name: 'dialTopUpFails',
578
- re: /\[dl\]\s*outbound top-up:\s*(\d+)\s*dial\(s\) failed(?:,\s*first\s+(\S+):\s*(.+?))?\s*$/,
601
+ re: /\[dl\]\s*outbound top-up:\s*(\d+)\s*dial\(s\) failed(?:,\s*first\s+(\S+):(.+))?\s*$/,
579
602
  apply(m) {
580
603
  const a = m[2] ? addrParts(m[2]) : null;
581
604
  return {
@@ -625,7 +648,9 @@ const RULES = [
625
648
  // event list, which is what it is for.
626
649
  {
627
650
  name: 'dialBackgroundFail',
628
- re: /\[dial\]\s*(\S+?):\s*background dial failed:\s*(.+?)\s*$/,
651
+ // 2026-09-16 (audit L6): `:\s*(.+?)\s*$` became `:(.+)$` (here and in dialTopUpFails):
652
+ // the same text once trimmed, and one pass to the end instead of one per space.
653
+ re: /\[dial\]\s*(\S+?):\s*background dial failed:(.+)$/,
629
654
  apply(m) {
630
655
  const a = addrParts(m[1]);
631
656
  return { kind: 'peer_reject', direction: 'outbound', addr: a?.addr ?? m[1], host: a?.host ?? m[1], reason: m[2].trim(), severity: 'warn' };
@@ -708,9 +733,19 @@ function localFromLogTs(dateTime, ms) {
708
733
  return new Date(+y, +mo - 1, +d, +h, +mi, +s, +ms).getTime();
709
734
  }
710
735
 
736
+ // LONG LINES (2026-09-16, audit L6). Every rule runs unanchored on the server's main
737
+ // thread, so a line's length is a cost every rule pays. No line this node prints comes
738
+ // near 8 KB (the longest in the fixtures is a few hundred bytes); anything past that is
739
+ // cut before matching, and the event says so (`truncated`: the characters dropped).
740
+ // Together with the rewritten patterns above, a 20,000-space line parses in
741
+ // a few milliseconds where `legDown` alone used to not finish in 300 s.
742
+ export const MAX_LINE = 8192;
743
+
711
744
  // One line in, one event out (or null for a continuation/blank line).
712
745
  export function parseLine(line) {
713
746
  if (!line) return null;
747
+ let cut = 0;
748
+ if (line.length > MAX_LINE) { cut = line.length - MAX_LINE; line = line.slice(0, MAX_LINE); }
714
749
  const trimmed = line.replace(/\r$/, '');
715
750
  if (!trimmed.trim()) return null;
716
751
 
@@ -738,6 +773,7 @@ export function parseLine(line) {
738
773
  out.text = rest.trim();
739
774
  out.rule = rule.name;
740
775
  out.severity = out.severity ?? classify(tagBase, rest);
776
+ if (cut) out.truncated = cut;
741
777
  return out;
742
778
  }
743
779
  }
@@ -751,14 +787,25 @@ export function parseLine(line) {
751
787
  text: rest.trim(),
752
788
  severity: classify(tagBase, rest),
753
789
  rule: null,
790
+ ...(cut ? { truncated: cut } : {}),
754
791
  };
755
792
  }
756
793
 
757
794
  // Lines arrive in chunks; keep the trailing partial line for the next round.
795
+ //
796
+ // 2026-09-16 (audit L6): the partial line used to grow without limit, so a log that
797
+ // never printed a newline was held whole in memory and handed to every rule at once.
798
+ // `carry` now keeps at most MAX_LINE characters -- the front of the line, where the
799
+ // timestamp and tag are -- and parseLine would cut it there anyway. What is dropped is
800
+ // counted in `state.carryDropped`, so a mangled log shows up as a number, not silence.
758
801
  export function splitLines(buf, state = { carry: '' }) {
759
802
  const text = state.carry + buf;
760
803
  const parts = text.split('\n');
761
804
  state.carry = parts.pop() ?? '';
805
+ if (state.carry.length > MAX_LINE) {
806
+ state.carryDropped = (state.carryDropped ?? 0) + state.carry.length - MAX_LINE;
807
+ state.carry = state.carry.slice(0, MAX_LINE);
808
+ }
762
809
  const out = [];
763
810
  for (const p of parts) { const e = parseLine(p); if (e) out.push(e); }
764
811
  return out;
@@ -106,6 +106,22 @@ export const BOOKS = {
106
106
  export const DEPTH_STEP = 50; // dollars a level on the depth grid: fixed, so snapshots compare as the price moves
107
107
  export const DEPTH_SPAN = 0.12; // how far either side of the mid the grid reaches
108
108
  export const DEPTH_AGOS = [60, 300, 600, 1800, 3600];
109
+ // BOUNDS ON WHAT AN EXCHANGE CAN MAKE US DO (2026-09-16, audit L5). The grid size was
110
+ // ceil(mid x 0.24 / 50) with the mid taken from the books themselves, so one absurd book --
111
+ // bid 60,000, ask 2e10, answering alone -- made 48 million levels, blocked pollBooks for
112
+ // 10 s and held 5 GB for the hour the snapshot is kept. None of that takes a malicious
113
+ // exchange, only a broken, compromised or intercepted one. So:
114
+ // - a book whose mid is more than DEPTH_SANE from the median of every other book and
115
+ // every recent ticker is not drawn; its row says why (an exchange answering nonsense
116
+ // is reported, never silently dropped -- rule 8);
117
+ // - the grid never exceeds DEPTH_MAX_LEVELS (at $50 a level that is a mid near $400,000
118
+ // before it bites; today's is about 370 levels);
119
+ // - a reply body is read up to MAX_BODY_BYTES and no further (Coinbase's whole book, the
120
+ // largest, measured 1.1 MB on 2026-09-11), and a redirect is an error, not a new host.
121
+ export const DEPTH_SANE = 0.2;
122
+ export const DEPTH_MAX_LEVELS = 2000;
123
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024;
124
+ const TICKER_REF_MS = 600_000; // how old a ticker may be and still vouch for a book's price
109
125
  const r3 = (v) => Math.round(v * 1000) / 1000;
110
126
 
111
127
  // A book as cumulative depth on the grid p0 + i*step: bids[i] is the BTC bid at or above that
@@ -136,11 +152,36 @@ export function depthOf(book, p0, n, step = DEPTH_STEP) {
136
152
  }
137
153
 
138
154
  const UA = 'BlockYard (self-hosted Bitcoin node monitor)';
155
+
156
+ // A reply body, read no further than `limit` bytes (2026-09-16, audit L5). A declared
157
+ // Content-Length past the limit is refused before reading; otherwise the stream is read
158
+ // chunk by chunk and cancelled the moment it passes the limit, so a hostile or broken
159
+ // endpoint costs at most `limit` bytes of memory, not whatever it cares to send inside
160
+ // the 8 s timeout. A reply object with no stream (the tests' stubs) falls back to json().
161
+ export async function readJsonCapped(r, limit = MAX_BODY_BYTES) {
162
+ const declared = Number(r.headers?.get?.('content-length'));
163
+ if (Number.isFinite(declared) && declared > limit) throw new Error(`reply of ${declared} bytes is over the ${limit}-byte limit`);
164
+ const reader = r.body?.getReader?.();
165
+ if (!reader) return r.json();
166
+ const chunks = [];
167
+ let total = 0;
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (done) break;
171
+ total += value.byteLength;
172
+ if (total > limit) {
173
+ reader.cancel().catch(() => {});
174
+ throw new Error(`reply over the ${limit}-byte limit`);
175
+ }
176
+ chunks.push(value);
177
+ }
178
+ return JSON.parse(Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8'));
179
+ }
139
180
  const median = (xs) => (xs.length ? (xs.length % 2 ? xs[(xs.length - 1) / 2] : (xs[xs.length / 2 - 1] + xs[xs.length / 2]) / 2) : null);
140
181
 
141
182
  export class MarketFeed {
142
183
  constructor(cfg = {}, { log = () => {}, fetchImpl = globalThis.fetch, now = Date.now, exchanges = EXCHANGES } = {}) {
143
- this.cfg = { tickerMs: 15_000, candleMs: 300_000, bookMs: 30_000, idleAfterMs: 600_000, timeoutMs: 8_000, candles: 168, ...cfg };
184
+ this.cfg = { tickerMs: 15_000, candleMs: 300_000, bookMs: 30_000, idleAfterMs: 600_000, timeoutMs: 8_000, candles: 168, maxBodyBytes: MAX_BODY_BYTES, ...cfg };
144
185
  this.log = log;
145
186
  this.fetch = fetchImpl;
146
187
  this.now = now;
@@ -180,9 +221,11 @@ export class MarketFeed {
180
221
  }
181
222
 
182
223
  async get(url) {
183
- const r = await this.fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, signal: AbortSignal.timeout(this.cfg.timeoutMs) });
224
+ // redirect 'error' (2026-09-16, audit L5): every URL above is the exchange's own API
225
+ // host; a 3xx pointing somewhere else is not followed.
226
+ const r = await this.fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(this.cfg.timeoutMs) });
184
227
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
185
- return r.json();
228
+ return readJsonCapped(r, this.cfg.maxBodyBytes);
186
229
  }
187
230
 
188
231
  async pollTickers() {
@@ -231,15 +274,41 @@ export class MarketFeed {
231
274
  }
232
275
  }));
233
276
  if (!books.size) return;
234
- const mids = [...books.values()].map((b) => {
277
+ const midOf = (b) => {
235
278
  let bb = -Infinity, ba = Infinity;
236
279
  for (const [p] of b.bids) if (p > bb) bb = p;
237
280
  for (const [p] of b.asks) if (p < ba) ba = p;
238
281
  return (bb + ba) / 2;
239
- }).sort((a, b) => a - b);
282
+ };
283
+ const bookMids = new Map([...books].map(([id, b]) => [id, midOf(b)]));
284
+ // Sanity (2026-09-16, audit L5): each book against everyone else -- the other books'
285
+ // mids and every ticker younger than ten minutes. With one book answering, the tickers
286
+ // are the check; with nothing to compare against, the book stands as before.
287
+ const tickers = this.exchanges.map((ex) => this.rows.get(ex.id)?.ticker)
288
+ .filter((t) => t?.last > 0 && this.now() - t.at <= TICKER_REF_MS).map((t) => t.last);
289
+ for (const [id, m] of bookMids) {
290
+ const others = [...[...bookMids].filter(([k]) => k !== id).map(([, v]) => v), ...tickers].filter((v) => Number.isFinite(v) && v > 0).sort((a, b) => a - b);
291
+ const ref = median(others);
292
+ const bad = !(Number.isFinite(m) && m > 0) ? 'no usable mid price'
293
+ : ref != null && Math.abs(m - ref) / ref > DEPTH_SANE ? `mid ${Math.round(m)} is more than ${DEPTH_SANE * 100}% from the other exchanges (${Math.round(ref)}); book not drawn`
294
+ : null;
295
+ if (bad) {
296
+ books.delete(id);
297
+ const row = this.rows.get(id);
298
+ if (row) row.bookError = { message: bad, at: this.now() };
299
+ }
300
+ }
301
+ if (!books.size) return;
302
+ const mids = [...books.keys()].map((id) => bookMids.get(id)).sort((a, b) => a - b);
240
303
  const mid = median(mids);
241
- const p0 = Math.floor((mid * (1 - DEPTH_SPAN)) / DEPTH_STEP) * DEPTH_STEP;
242
- const n = Math.ceil((mid * 2 * DEPTH_SPAN) / DEPTH_STEP) + 1;
304
+ let p0 = Math.floor((mid * (1 - DEPTH_SPAN)) / DEPTH_STEP) * DEPTH_STEP;
305
+ let n = Math.ceil((mid * 2 * DEPTH_SPAN) / DEPTH_STEP) + 1;
306
+ if (n > DEPTH_MAX_LEVELS) {
307
+ // Clamped: keep the mid in the middle of the grid rather than letting the grid stop
308
+ // short of it.
309
+ n = DEPTH_MAX_LEVELS;
310
+ p0 = Math.max(0, Math.floor((mid - (n / 2) * DEPTH_STEP) / DEPTH_STEP) * DEPTH_STEP);
311
+ }
243
312
  const snap = { at: this.now(), mid, p0, n, ex: {} };
244
313
  for (const [id, bk] of books) snap.ex[id] = depthOf(bk, p0, n);
245
314
  this.depthHist.push(snap);
@@ -43,6 +43,16 @@ export function parsePushes(hex) {
43
43
  const op = bytes[i];
44
44
  let n = op;
45
45
  let head = 1;
46
+ // 2026-09-16 (audit L4): a scriptSig may END on an OP_PUSHDATA opcode with its length
47
+ // bytes missing -- consensus allows any bytes after the BIP34 height, so a pool can
48
+ // put this in its own block for free. readUInt16LE/readUInt32LE past the end threw
49
+ // ERR_OUT_OF_RANGE, the monitor's lane retried that block forever and every later
50
+ // block waited behind it. The length is read only when all of its bytes are there;
51
+ // a truncated frame header ends the walk like any other unparseable frame.
52
+ if (op === 0x4c) head = 2;
53
+ else if (op === 0x4d) head = 3;
54
+ else if (op === 0x4e) head = 5;
55
+ if (i + head > bytes.length) break;
46
56
  if (op === 0x4c) { n = bytes[i + 1]; head = 2; }
47
57
  else if (op === 0x4d) { n = bytes.readUInt16LE(i + 1); head = 3; }
48
58
  else if (op === 0x4e) { n = bytes.readUInt32LE(i + 1); head = 5; }
@@ -54,6 +64,26 @@ export function parsePushes(hex) {
54
64
  return { consumed: i, total: bytes.length, pushes: out };
55
65
  }
56
66
 
67
+ /**
68
+ * decodeCoinbase that cannot throw (2026-09-16, audit L4). The decoder is meant never to
69
+ * throw on any bytes, and the fuzz test holds it to that; this is the belt to that pair of
70
+ * braces for the two lanes that call it on untrusted blocks. A coinbase that still fails
71
+ * to decode is a fact about THAT block -- permanent, so it is recorded as unparseable (an
72
+ * unknown pool, `decodeError` saying why) and the lane moves on. Retrying it would only
73
+ * stall every block queued behind it, which is exactly what L4 found.
74
+ */
75
+ export function decodeCoinbaseSafe(hex) {
76
+ try {
77
+ return decodeCoinbase(hex);
78
+ } catch (err) {
79
+ return {
80
+ parseable: false, truncatedAt: 0, height: null, tagText: null, tag: null,
81
+ commitment: null, extraNonce: null, raw: typeof hex === 'string' ? hex : '',
82
+ decodeError: String(err?.message ?? err),
83
+ };
84
+ }
85
+ }
86
+
57
87
  /**
58
88
  * The longest printable prefix of a push. Pool tags and extra nonces share one frame
59
89
  * more often than they get a frame each, so "is this frame text" is the wrong question;
@@ -257,6 +287,8 @@ export function minerRow({ height, hash, decoded, stats, at }) {
257
287
  extraNonce: decoded?.extraNonce ?? null,
258
288
  commitment: decoded?.commitment ?? null,
259
289
  rawCoinbase: decoded?.raw ?? null,
290
+ // 2026-09-16 (audit L4): present only when the coinbase could not be decoded at all.
291
+ ...(decoded?.decodeError ? { decodeError: decoded.decodeError } : {}),
260
292
  };
261
293
  }
262
294