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.
- package/CHANGELOG.md +112 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +247 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1071 -0
- package/public/js/scorchedai.js +268 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1361 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +6 -5
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +432 -56
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- 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 =
|
|
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;
|
|
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:
|
|
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
|
-
|
|
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
|
-
.
|
|
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,
|
|
6
|
-
// input is
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
write(
|
|
97
|
-
write(path.join(dir, `seg-${
|
|
98
|
-
|
|
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".
|
|
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(/^(
|
|
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(/^(
|
|
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*([^,]
|
|
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*([^,]
|
|
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
|
-
|
|
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]+)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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*([^)]
|
|
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
|
-
|
|
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+)[^;]
|
|
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+)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
242
|
-
|
|
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);
|
package/server/collect/mining.js
CHANGED
|
@@ -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
|
|