blockyard 0.0.1 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// CORE'S BLOCK AND UNDO FILES, READ DIRECTLY (docs/DEFECTS.md: "we can read the block files after
|
|
2
|
+
// all -- -blocksxor, not an unknown format").
|
|
3
|
+
//
|
|
4
|
+
// blocks/blkNNNNN.dat holds blocks as Core received them: [magic][u32 size][block bytes], repeated.
|
|
5
|
+
// blocks/revNNNNN.dat holds the undo data Core wrote when it CONNECTED those blocks:
|
|
6
|
+
// [magic][u32 size][undo bytes][32-byte checksum]. Undo data is the one thing that turns a raw block
|
|
7
|
+
// into a full one -- for every input, the output it spent (value, script, height, coinbase or not).
|
|
8
|
+
// It is what `getblock <hash> 3` reads to report `prevout`, and it is what an address index needs for
|
|
9
|
+
// the spending side, without replaying the whole UTXO set.
|
|
10
|
+
//
|
|
11
|
+
// Since Core v28 both files are XOR-obfuscated at rest with the 8-byte key in blocks/xor.dat, applied
|
|
12
|
+
// by absolute file offset. An all-zero key is the old unobfuscated layout, so both read the same way.
|
|
13
|
+
//
|
|
14
|
+
// READ-ONLY, and it must stay so: these are the node's own files. Nothing here opens a file for
|
|
15
|
+
// writing, and the reader copes with the last file being appended to while it reads.
|
|
16
|
+
//
|
|
17
|
+
// Checked against the node: scripts/blockfile-measure.js --verify compares decoded undo coins with
|
|
18
|
+
// `getblock <hash> 3` prevouts for whole blocks.
|
|
19
|
+
import { openSync, readSync, closeSync, fstatSync, readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { hash256, classifyScript } from './tx.js';
|
|
22
|
+
|
|
23
|
+
export const MAGIC = Object.freeze({ main: 0xd9b4bef9, test: 0x0709110b, testnet4: 0x283f161c, signet: 0x40cf030a, regtest: 0xdab5bffa });
|
|
24
|
+
|
|
25
|
+
export function xorKey(blocksDir) {
|
|
26
|
+
try { return readFileSync(path.join(blocksDir, 'xor.dat')); } catch { return Buffer.alloc(8); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// XOR `buf` in place as the bytes that start at absolute file offset `offset`
|
|
30
|
+
export function unxor(buf, key, offset = 0) {
|
|
31
|
+
if (!key || key.every((b) => b === 0)) return buf;
|
|
32
|
+
for (let i = 0; i < buf.length; i++) buf[i] ^= key[(offset + i) % key.length];
|
|
33
|
+
return buf;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A whole file, de-obfuscated, as one Buffer. Files are ~128 MB; reading whole is the fast path. */
|
|
37
|
+
export function readChainFile(file, key) {
|
|
38
|
+
const fd = openSync(file, 'r');
|
|
39
|
+
try {
|
|
40
|
+
const size = fstatSync(fd).size;
|
|
41
|
+
const buf = Buffer.allocUnsafe(size);
|
|
42
|
+
let got = 0;
|
|
43
|
+
while (got < size) { const n = readSync(fd, buf, got, size - got, got); if (n === 0) break; got += n; }
|
|
44
|
+
return unxor(buf.subarray(0, got), key, 0);
|
|
45
|
+
} finally { closeSync(fd); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The records in a de-obfuscated blk or rev file: { offset, size, body } for each, where body is a
|
|
50
|
+
* subarray (no copy). `trailer` is 32 for rev files (the checksum after each record), 0 for blk.
|
|
51
|
+
* Pass the file's `key` so a preallocated tail is recognised after de-obfuscation.
|
|
52
|
+
* A zero magic is the unwritten tail Core preallocates, so framing stops there.
|
|
53
|
+
*/
|
|
54
|
+
export function* records(buf, magic = MAGIC.main, trailer = 0, key = null) {
|
|
55
|
+
let pos = 0;
|
|
56
|
+
while (pos + 8 <= buf.length) {
|
|
57
|
+
const m = buf.readUInt32LE(pos);
|
|
58
|
+
if (m === 0) return; // preallocated, never written
|
|
59
|
+
// THE UNWRITTEN TAIL IS ZEROS ON DISK, NOT OBFUSCATED ZEROS. Core preallocates the file it is
|
|
60
|
+
// appending to and never XORs the space it has not written, so once the buffer is de-obfuscated
|
|
61
|
+
// that tail reads as the key itself. Found on the live file (blk05755.dat) at the end of a full
|
|
62
|
+
// index build: "bad magic 716b8de5" is e5 8d 6b 71, the first half of xor.dat. With the key
|
|
63
|
+
// given, raw zeros are recognised as the end.
|
|
64
|
+
if (m !== magic && key && !key.every((b) => b === 0)) {
|
|
65
|
+
let raw0 = true;
|
|
66
|
+
for (let i = 0; i < 8 && raw0; i++) raw0 = (buf[pos + i] ^ key[(pos + i) % key.length]) === 0;
|
|
67
|
+
if (raw0) return;
|
|
68
|
+
}
|
|
69
|
+
if (m !== magic) throw new Error(`bad magic ${m.toString(16)} at offset ${pos}`);
|
|
70
|
+
const size = buf.readUInt32LE(pos + 4);
|
|
71
|
+
const start = pos + 8;
|
|
72
|
+
if (start + size + trailer > buf.length) return; // the file is still being written
|
|
73
|
+
yield { offset: pos, size, body: buf.subarray(start, start + size), ...(trailer ? { checksum: buf.subarray(start + size, start + size + trailer) } : {}) };
|
|
74
|
+
pos = start + size + trailer;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- undo data -----------------------------------------------------------
|
|
79
|
+
// Core's VARINT (serialize.h), which is NOT CompactSize: MSB base-128 with an offset per byte.
|
|
80
|
+
function readVarInt(buf, st) {
|
|
81
|
+
let n = 0;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const b = buf[st.pos++];
|
|
84
|
+
if (b === undefined) throw new RangeError('truncated VARINT');
|
|
85
|
+
n = n * 128 + (b & 0x7f);
|
|
86
|
+
if (b & 0x80) n += 1; else return n;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function readCompactSize(buf, st) {
|
|
90
|
+
const b = buf[st.pos++];
|
|
91
|
+
if (b < 0xfd) return b;
|
|
92
|
+
if (b === 0xfd) { const v = buf.readUInt16LE(st.pos); st.pos += 2; return v; }
|
|
93
|
+
if (b === 0xfe) { const v = buf.readUInt32LE(st.pos); st.pos += 4; return v; }
|
|
94
|
+
const v = Number(buf.readBigUInt64LE(st.pos)); st.pos += 8; return v;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// compressor.cpp DecompressAmount
|
|
98
|
+
export function decompressAmount(x) {
|
|
99
|
+
if (x === 0) return 0;
|
|
100
|
+
x -= 1;
|
|
101
|
+
let e = x % 10;
|
|
102
|
+
x = Math.floor(x / 10);
|
|
103
|
+
let n;
|
|
104
|
+
if (e < 9) { const d = (x % 9) + 1; x = Math.floor(x / 9); n = x * 10 + d; } else { n = x + 1; }
|
|
105
|
+
while (e > 0) { n *= 10; e--; }
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// secp256k1 point decompression, for the two compressed forms of an uncompressed-key P2PK script
|
|
110
|
+
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
|
|
111
|
+
function modpow(b, e, m) { let r = 1n; b %= m; while (e > 0n) { if (e & 1n) r = (r * b) % m; b = (b * b) % m; e >>= 1n; } return r; }
|
|
112
|
+
function decompressPubkey(prefix, x32) {
|
|
113
|
+
const x = BigInt('0x' + x32.toString('hex'));
|
|
114
|
+
let y = modpow((x * x * x + 7n) % P, (P + 1n) / 4n, P);
|
|
115
|
+
if ((y & 1n) !== BigInt(prefix & 1)) y = P - y;
|
|
116
|
+
return Buffer.concat([Buffer.from([0x04]), x32, Buffer.from(y.toString(16).padStart(64, '0'), 'hex')]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// compressor.cpp: sizes 0-5 are special script templates, 6+ a raw script of (size - 6) bytes
|
|
120
|
+
function readCompressedScript(buf, st) {
|
|
121
|
+
const size = readVarInt(buf, st);
|
|
122
|
+
const take = (n) => { const v = buf.subarray(st.pos, st.pos + n); st.pos += n; return v; };
|
|
123
|
+
switch (size) {
|
|
124
|
+
case 0: return Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), take(20), Buffer.from([0x88, 0xac])]);
|
|
125
|
+
case 1: return Buffer.concat([Buffer.from([0xa9, 0x14]), take(20), Buffer.from([0x87])]);
|
|
126
|
+
case 2: case 3: return Buffer.concat([Buffer.from([0x21, size]), take(32), Buffer.from([0xac])]);
|
|
127
|
+
case 4: case 5: return Buffer.concat([Buffer.from([0x41]), decompressPubkey(size - 2, take(32)), Buffer.from([0xac])]);
|
|
128
|
+
default: return Buffer.from(take(size - 6));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One block's undo record: for each non-coinbase transaction in block order, the coins its inputs
|
|
134
|
+
* spent, in input order -- { height, coinbase, value_sat, script }.
|
|
135
|
+
*/
|
|
136
|
+
export function decodeBlockUndo(body) {
|
|
137
|
+
const st = { pos: 0 };
|
|
138
|
+
const ntx = readCompactSize(body, st);
|
|
139
|
+
const txs = new Array(ntx);
|
|
140
|
+
for (let i = 0; i < ntx; i++) {
|
|
141
|
+
const nin = readCompactSize(body, st);
|
|
142
|
+
const coins = new Array(nin);
|
|
143
|
+
for (let j = 0; j < nin; j++) {
|
|
144
|
+
const code = readVarInt(body, st);
|
|
145
|
+
const height = Math.floor(code / 2);
|
|
146
|
+
if (height > 0) readVarInt(body, st); // the legacy nVersion placeholder, always 0
|
|
147
|
+
const value_sat = decompressAmount(readVarInt(body, st));
|
|
148
|
+
const script = readCompressedScript(body, st);
|
|
149
|
+
coins[j] = { height, coinbase: (code & 1) === 1, value_sat, script };
|
|
150
|
+
}
|
|
151
|
+
txs[i] = coins;
|
|
152
|
+
}
|
|
153
|
+
if (st.pos !== body.length) throw new RangeError(`${body.length - st.pos} bytes left after the undo record`);
|
|
154
|
+
return txs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The input counts an undo record describes, without building coins: cheap enough to match on. */
|
|
158
|
+
export function undoShape(body) {
|
|
159
|
+
const st = { pos: 0 };
|
|
160
|
+
const ntx = readCompactSize(body, st);
|
|
161
|
+
const counts = new Array(ntx);
|
|
162
|
+
for (let i = 0; i < ntx; i++) {
|
|
163
|
+
const nin = readCompactSize(body, st);
|
|
164
|
+
counts[i] = nin;
|
|
165
|
+
for (let j = 0; j < nin; j++) {
|
|
166
|
+
const height = Math.floor(readVarInt(body, st) / 2);
|
|
167
|
+
if (height > 0) readVarInt(body, st);
|
|
168
|
+
readVarInt(body, st);
|
|
169
|
+
const size = readVarInt(body, st);
|
|
170
|
+
st.pos += size === 0 || size === 1 ? 20 : size < 6 ? 32 : size - 6;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return counts;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Core's checksum over an undo record: hash256(previous block hash || undo bytes). */
|
|
177
|
+
export function undoChecksumMatches(record, prevHashHex) {
|
|
178
|
+
const prev = Buffer.from(prevHashHex, 'hex').reverse();
|
|
179
|
+
return hash256(Buffer.concat([prev, record.body])).equals(record.checksum);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Pair each block with its undo record. `blocks` are { hash, previousblockhash, ntx } in file order;
|
|
184
|
+
* the result is a Map from block index to undo record. Blocks with no undo (never connected, or
|
|
185
|
+
* stale) are simply absent. Every pairing is proven by Core's checksum, never assumed.
|
|
186
|
+
*
|
|
187
|
+
* IN CHAIN ORDER, NOT BY SEARCH. Core appends an undo record when it CONNECTS a block, so a rev file
|
|
188
|
+
* lists its blocks in chain order, while the blk file lists them as they arrived. Two searches were
|
|
189
|
+
* tried first and both were quadratic where the chain is dense with small blocks: trying every
|
|
190
|
+
* candidate of the same transaction count ran file 0 for 35 minutes without finishing, and hashing
|
|
191
|
+
* once per distinct body still took 230 s, because blocks of one or two ordinary transactions each
|
|
192
|
+
* have a unique record. Here each file's blocks are put in chain order by their previous-block
|
|
193
|
+
* links, the undo records are walked in their own order, and the next unpaired block of the right
|
|
194
|
+
* shape is checked first -- almost always the one. A miss (a reorg, a stale block) looks a little
|
|
195
|
+
* further ahead, and only then searches the whole shape group.
|
|
196
|
+
*/
|
|
197
|
+
export function pairBlocksWithUndo(blocks, undoRecords) {
|
|
198
|
+
const byHash = new Map(blocks.map((b, i) => [b.hash, i]));
|
|
199
|
+
// depth within this file along previous-block links: roots are blocks whose parent is elsewhere
|
|
200
|
+
const depth = new Array(blocks.length).fill(-1);
|
|
201
|
+
const depthOf = (i) => {
|
|
202
|
+
const path = [];
|
|
203
|
+
let j = i;
|
|
204
|
+
while (j !== undefined && depth[j] === -1) { path.push(j); depth[j] = -2; j = byHash.get(blocks[j].previousblockhash); }
|
|
205
|
+
let d = j === undefined || depth[j] < 0 ? -1 : depth[j];
|
|
206
|
+
for (let k = path.length - 1; k >= 0; k--) depth[path[k]] = ++d;
|
|
207
|
+
return depth[i];
|
|
208
|
+
};
|
|
209
|
+
for (let i = 0; i < blocks.length; i++) if (depth[i] === -1) depthOf(i);
|
|
210
|
+
const groups = new Map(); // non-coinbase tx count -> block indices in chain order
|
|
211
|
+
const order = blocks.map((_, i) => i).sort((x, y) => depth[x] - depth[y] || x - y);
|
|
212
|
+
for (const i of order) { const c = blocks[i].ntx - 1; (groups.get(c) ?? groups.set(c, { list: [], next: 0 }).get(c)).list.push(i); }
|
|
213
|
+
const taken = new Uint8Array(blocks.length);
|
|
214
|
+
const out = new Map();
|
|
215
|
+
const prevBuf = (i) => Buffer.from(blocks[i].previousblockhash, 'hex').reverse();
|
|
216
|
+
const matches = (i, u) => hash256(Buffer.concat([prevBuf(i), u.body])).equals(u.checksum);
|
|
217
|
+
for (const u of undoRecords) {
|
|
218
|
+
const g = groups.get(undoShape(u.body).length);
|
|
219
|
+
if (!g) continue;
|
|
220
|
+
while (g.next < g.list.length && taken[g.list[g.next]]) g.next++;
|
|
221
|
+
let found = -1;
|
|
222
|
+
for (let k = g.next, tried = 0; k < g.list.length && tried < 32; k++) {
|
|
223
|
+
const i = g.list[k];
|
|
224
|
+
if (taken[i]) continue;
|
|
225
|
+
tried++;
|
|
226
|
+
if (matches(i, u)) { found = i; break; }
|
|
227
|
+
}
|
|
228
|
+
if (found < 0) for (const i of g.list) if (!taken[i] && matches(i, u)) { found = i; break; }
|
|
229
|
+
if (found >= 0) { taken[found] = 1; out.set(found, u); }
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export { classifyScript };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// BUILD THE ADDRESS INDEX from the node's own block and undo files (docs/MEASUREMENTS.md §28-30).
|
|
2
|
+
//
|
|
3
|
+
// 1. heights the active chain's block hashes, 0..tip, from getblockhash in batches, into a table
|
|
4
|
+
// every worker shares -- a block not in it (stale, or newer than the build) is skipped
|
|
5
|
+
// 2. scan every blk/rev pair on a worker pool; each file's rows come back partitioned by the
|
|
6
|
+
// key's first byte and are appended to 256 bucket files
|
|
7
|
+
// 3. check every height 0..tip indexed exactly once, or the build stops rather than publish a hole
|
|
8
|
+
// 4. sort each bucket sorted (and de-duplicated) on the pool into seg-XX.rows + seg-XX.idx
|
|
9
|
+
// 5. manifest written last, atomically: an index without one is an unfinished build
|
|
10
|
+
//
|
|
11
|
+
// The node's files are only read. Output goes to `out`, which should be on a different device from
|
|
12
|
+
// the block files if one is available: the build reads ~880 GB and writes ~120 GB.
|
|
13
|
+
import { Worker } from 'node:worker_threads';
|
|
14
|
+
import { openSync, writeSync, closeSync, mkdirSync, readdirSync, rmSync, renameSync, writeFileSync, statSync } from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { HeightTable } from './heights.js';
|
|
18
|
+
import { xorKey } from '../blockfile.js';
|
|
19
|
+
import { ROW } from './rows.js';
|
|
20
|
+
|
|
21
|
+
export const FORMAT = 1;
|
|
22
|
+
export const BLOCK_ROWS = 4096;
|
|
23
|
+
|
|
24
|
+
async function chainHashes(rpc, tip, onProgress, pace = null) {
|
|
25
|
+
const table = new HeightTable(1 << 21);
|
|
26
|
+
const hashes = new Array(tip + 1);
|
|
27
|
+
// small batches at the lowest priority: the monitor's own polls interleave between them, and
|
|
28
|
+
// on a machine shared with the node a 5,000-call batch held the lane for seconds (2026-09-14)
|
|
29
|
+
const BATCH = 1000;
|
|
30
|
+
for (let from = 0; from <= tip; from += BATCH) {
|
|
31
|
+
if (pace) await pace(); // the batches load the node too: hold while it is slow (2026-09-14)
|
|
32
|
+
const calls = [];
|
|
33
|
+
for (let h = from; h <= Math.min(tip, from + BATCH - 1); h++) calls.push({ method: 'getblockhash', params: [h] });
|
|
34
|
+
const got = await rpc.batch(calls, { key: `index:hashes:${from}`, timeoutMs: 120_000, maxWaitMs: 600_000, priority: 9 });
|
|
35
|
+
got.forEach((g, i) => {
|
|
36
|
+
if (!g.ok) throw new Error(`getblockhash ${from + i}: ${g.error?.message}`);
|
|
37
|
+
hashes[from + i] = g.result;
|
|
38
|
+
table.set(g.result, from + i);
|
|
39
|
+
});
|
|
40
|
+
onProgress?.({ phase: 'heights', done: Math.min(tip + 1, from + BATCH), total: tip + 1 });
|
|
41
|
+
}
|
|
42
|
+
return { table, hashes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class Pool {
|
|
46
|
+
constructor(size, workerData) {
|
|
47
|
+
this.workers = Array.from({ length: size }, () => new Worker(new URL('./worker.js', import.meta.url), { workerData }));
|
|
48
|
+
}
|
|
49
|
+
// run jobs, at most one per worker; onResult may be async (it is awaited before that worker's next
|
|
50
|
+
// job); `pace`, if given, is awaited before each job is handed out -- the server's background build
|
|
51
|
+
// uses it to hold the workers while the node's RPC is slow, since they share its disk
|
|
52
|
+
async run(jobs, onResult, pace = null) {
|
|
53
|
+
let next = 0, failed = null;
|
|
54
|
+
await Promise.all(this.workers.map((w) => new Promise((resolve) => {
|
|
55
|
+
const go = async () => {
|
|
56
|
+
if (failed || next >= jobs.length) { resolve(); return; }
|
|
57
|
+
const job = jobs[next++];
|
|
58
|
+
if (pace) { try { await pace(); } catch (err) { failed = err; resolve(); return; } }
|
|
59
|
+
w.once('message', async (msg) => {
|
|
60
|
+
if (msg.type === 'error') { failed = new Error(`${JSON.stringify(msg.job)}: ${msg.message}`); resolve(); return; }
|
|
61
|
+
try { await onResult(msg); } catch (err) { failed = err; resolve(); return; }
|
|
62
|
+
go();
|
|
63
|
+
});
|
|
64
|
+
w.postMessage(job);
|
|
65
|
+
};
|
|
66
|
+
go();
|
|
67
|
+
})));
|
|
68
|
+
if (failed) throw failed;
|
|
69
|
+
}
|
|
70
|
+
close() { return Promise.all(this.workers.map((w) => w.terminate())); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Workers for a build on this machine: four cores left for the node, ~2.5 GB of memory each, sixteen at most. */
|
|
74
|
+
export function defaultWorkers(cpus = os.cpus().length, totalMem = os.totalmem()) {
|
|
75
|
+
return Math.max(1, Math.min(16, cpus - 4, Math.floor(totalMem / 2.5e9)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A pace for a build that shares its machine with the node (every build does now): before each
|
|
80
|
+
* file is handed to a worker, wait while the node's RPC is failing, its breaker is open, or its
|
|
81
|
+
* answers average more than `slowMs`; ease off (one file per `easeMs`) while they are merely slow.
|
|
82
|
+
* Reads from the lane's own telemetry, so it costs the node nothing to ask. (2026-09-14, the first
|
|
83
|
+
* Mac install: a build at full speed on the node's disk turned its RPC into 18 s answers and
|
|
84
|
+
* 90 s timeouts.)
|
|
85
|
+
*/
|
|
86
|
+
// `slowMs` is the monitor's own idea of slow (rpc.slowLatencyMs, 5 s by default): a node whose
|
|
87
|
+
// heavy reads take a second when perfectly well -- the first Mac -- was being held at one second
|
|
88
|
+
// and eased at a quarter of it, and a healthy build ran at a sixth of its speed (2026-09-14)
|
|
89
|
+
export function rpcPacer(rpc, { slowMs = 5000, easeMs = 250, holdMs = 10_000, onChange = null } = {}) {
|
|
90
|
+
let held = false;
|
|
91
|
+
return async () => {
|
|
92
|
+
for (;;) {
|
|
93
|
+
const t = rpc?.telemetry?.() ?? {};
|
|
94
|
+
const failing = !!t.breakerOpen || (t.lastError && (!t.lastGoodAt || t.lastError.at > t.lastGoodAt) && Date.now() - t.lastError.at < 60_000);
|
|
95
|
+
const avg = Number.isFinite(t.avgLatencyMs) ? t.avgLatencyMs : 0;
|
|
96
|
+
if (failing || avg > slowMs) {
|
|
97
|
+
if (!held) { held = true; onChange?.(true, t); }
|
|
98
|
+
await new Promise((r) => setTimeout(r, holdMs));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (held) { held = false; onChange?.(false, t); }
|
|
102
|
+
if (avg > slowMs * 0.4) await new Promise((r) => setTimeout(r, easeMs));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function buildIndex({ rpc, blocksDir, out, workers = defaultWorkers(), files = null, onProgress = () => {}, pace = null }) {
|
|
109
|
+
const t0 = performance.now();
|
|
110
|
+
const stats = { format: FORMAT, workers, phases: {} };
|
|
111
|
+
const info = await rpc.batch([{ method: 'getblockchaininfo', params: [] }], { key: 'index:info', timeoutMs: 60_000 });
|
|
112
|
+
if (!info[0].ok) throw new Error(`getblockchaininfo: ${info[0].error?.message}`);
|
|
113
|
+
const { chain, blocks: tip } = info[0].result;
|
|
114
|
+
if (chain !== 'main') throw new Error(`only mainnet block files are framed here so far (the node is on ${chain})`);
|
|
115
|
+
|
|
116
|
+
let t = performance.now();
|
|
117
|
+
const { table, hashes } = await chainHashes(rpc, tip, onProgress, pace);
|
|
118
|
+
stats.phases.heightsSec = (performance.now() - t) / 1000;
|
|
119
|
+
|
|
120
|
+
rmSync(out, { recursive: true, force: true });
|
|
121
|
+
mkdirSync(out, { recursive: true });
|
|
122
|
+
const all = readdirSync(blocksDir).filter((f) => /^blk\d{5}\.dat$/.test(f)).map((f) => Number(f.slice(3, 8))).sort((a, b) => a - b);
|
|
123
|
+
const fileList = files ?? all;
|
|
124
|
+
const pool = new Pool(workers, { blocksDir, key: xorKey(blocksDir), heightsBuffer: table.buffer, heightsCapacity: table.capacity, blockRowsPerIndex: BLOCK_ROWS });
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// --- scan -------------------------------------------------------------------
|
|
128
|
+
t = performance.now();
|
|
129
|
+
// AT MOST 64 BUCKET FILES OPEN AT ONCE (2026-09-14): 256 held open for the whole scan is the
|
|
130
|
+
// entire soft limit on a stock macOS (`ulimit -n` 256). The least recently written is closed
|
|
131
|
+
// and reopened for append when a 65th is needed -- a few thousand extra opens over a build.
|
|
132
|
+
const fds = new Array(256).fill(null), lru = [];
|
|
133
|
+
const MAX_OPEN = 64;
|
|
134
|
+
const bucketFile = (b) => path.join(out, `bucket-${b.toString(16).padStart(2, '0')}.unsorted`);
|
|
135
|
+
const fdFor = (b) => {
|
|
136
|
+
if (fds[b] === null) {
|
|
137
|
+
if (lru.length >= MAX_OPEN) { const old = lru.shift(); closeSync(fds[old]); fds[old] = null; }
|
|
138
|
+
fds[b] = openSync(bucketFile(b), 'a');
|
|
139
|
+
} else lru.splice(lru.indexOf(b), 1);
|
|
140
|
+
lru.push(b);
|
|
141
|
+
return fds[b];
|
|
142
|
+
};
|
|
143
|
+
const seen = new Uint8Array(tip + 1);
|
|
144
|
+
let rows = 0, scanned = 0, readMs = 0, workMs = 0, stale = 0, missingUndo = 0, dupHeights = 0;
|
|
145
|
+
await pool.run(fileList.map((file) => ({ type: 'scan', file })), (msg) => {
|
|
146
|
+
const data = Buffer.from(msg.data);
|
|
147
|
+
for (const [b, from, to] of msg.parts) {
|
|
148
|
+
const fd = fdFor(b);
|
|
149
|
+
for (let o = from; o < to;) o += writeSync(fd, data, o, to - o);
|
|
150
|
+
}
|
|
151
|
+
for (const h of msg.heights) { if (seen[h]) dupHeights++; seen[h] = 1; }
|
|
152
|
+
rows += msg.rows; scanned++; readMs += msg.readMs; workMs += msg.ms; stale += msg.stale; missingUndo += msg.missingUndo;
|
|
153
|
+
onProgress({ phase: 'scan', done: scanned, total: fileList.length, rows, file: msg.file });
|
|
154
|
+
}, pace);
|
|
155
|
+
for (const fd of fds) if (fd !== null) closeSync(fd);
|
|
156
|
+
stats.phases.scanSec = (performance.now() - t) / 1000;
|
|
157
|
+
stats.scan = { files: scanned, rawRows: rows, workerReadSec: readMs / 1000, workerCpuSec: workMs / 1000, staleBlocks: stale, missingUndo, duplicateHeights: dupHeights };
|
|
158
|
+
|
|
159
|
+
// --- check ------------------------------------------------------------------
|
|
160
|
+
let missing = 0, firstMissing = -1;
|
|
161
|
+
for (let h = 0; h <= tip; h++) if (!seen[h]) { missing++; if (firstMissing < 0) firstMissing = h; }
|
|
162
|
+
stats.check = { tip, missingHeights: missing, firstMissing };
|
|
163
|
+
if (!files && missing) throw new Error(`${missing} heights were not indexed (first ${firstMissing}); refusing to publish an index with holes`);
|
|
164
|
+
|
|
165
|
+
// --- sort -------------------------------------------------------------------
|
|
166
|
+
t = performance.now();
|
|
167
|
+
const buckets = [];
|
|
168
|
+
for (let b = 0; b < 256; b++) { try { if (statSync(bucketFile(b)).size) buckets.push(b); } catch { /* an empty bucket */ } }
|
|
169
|
+
const counts = new Array(256).fill(0);
|
|
170
|
+
let sortedRows = 0, dupes = 0, sortCpu = 0;
|
|
171
|
+
await pool.run(buckets.map((bucket) => ({ type: 'sort', bucket, dir: out })), (msg) => {
|
|
172
|
+
counts[msg.bucket] = msg.rows; sortedRows += msg.rows; dupes += msg.dupes; sortCpu += msg.ms;
|
|
173
|
+
onProgress({ phase: 'sort', done: buckets.indexOf(msg.bucket) + 1, total: buckets.length, rows: sortedRows });
|
|
174
|
+
});
|
|
175
|
+
stats.phases.sortSec = (performance.now() - t) / 1000;
|
|
176
|
+
stats.sort = { rows: sortedRows, duplicateRowsDropped: dupes, workerCpuSec: sortCpu / 1000 };
|
|
177
|
+
|
|
178
|
+
// --- manifest ---------------------------------------------------------------
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for (const f of readdirSync(out)) bytes += statSync(path.join(out, f)).size;
|
|
181
|
+
stats.totalSec = (performance.now() - t0) / 1000;
|
|
182
|
+
const manifest = {
|
|
183
|
+
format: FORMAT, rowBytes: ROW, blockRows: BLOCK_ROWS, chain,
|
|
184
|
+
tip: { height: tip, hash: hashes[tip] }, files: files ? fileList : 'all',
|
|
185
|
+
rows: sortedRows, bucketRows: counts, bytes, builtAt: new Date().toISOString(), stats,
|
|
186
|
+
};
|
|
187
|
+
writeFileSync(path.join(out, 'manifest.json.tmp'), JSON.stringify(manifest, null, 1) + '\n');
|
|
188
|
+
renameSync(path.join(out, 'manifest.json.tmp'), path.join(out, 'manifest.json'));
|
|
189
|
+
return manifest;
|
|
190
|
+
} finally {
|
|
191
|
+
await pool.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Block hash -> height for the chain an index build covers, in a SharedArrayBuffer so every build
|
|
2
|
+
// worker reads the same table without a copy (966,000 entries is ~70 MB as a Map of hex strings,
|
|
3
|
+
// and there are sixteen workers). Open addressing on the first 8 bytes of the block hash; a hash
|
|
4
|
+
// prefix collision among a million blocks is odds of ~1 in 3.7e7, and a collision would put two
|
|
5
|
+
// blocks at one height -- which the build checks for, since it counts every height exactly once.
|
|
6
|
+
// The LAST 16 hex digits of the displayed hash, not the first: a displayed hash begins with the
|
|
7
|
+
// proof of work, so a recent block's first 16 digits are all zero and every one of them would collide.
|
|
8
|
+
export class HeightTable {
|
|
9
|
+
constructor(capacity = 1 << 21) {
|
|
10
|
+
this.capacity = capacity;
|
|
11
|
+
this.buffer = new SharedArrayBuffer(capacity * 12);
|
|
12
|
+
this._bind();
|
|
13
|
+
}
|
|
14
|
+
static attach(buffer, capacity) {
|
|
15
|
+
const t = Object.create(HeightTable.prototype);
|
|
16
|
+
t.capacity = capacity; t.buffer = buffer; t._bind();
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
_bind() {
|
|
20
|
+
this.keys = new BigUint64Array(this.buffer, 0, this.capacity);
|
|
21
|
+
this.values = new Int32Array(this.buffer, this.capacity * 8, this.capacity);
|
|
22
|
+
}
|
|
23
|
+
static #prefix(hashHex) { return BigInt('0x' + hashHex.slice(-16)) | 1n; } // never the empty 0
|
|
24
|
+
set(hashHex, height) {
|
|
25
|
+
const k = HeightTable.#prefix(hashHex);
|
|
26
|
+
let i = Number(k % BigInt(this.capacity));
|
|
27
|
+
while (this.keys[i] !== 0n && this.keys[i] !== k) i = (i + 1) % this.capacity;
|
|
28
|
+
this.keys[i] = k; this.values[i] = height;
|
|
29
|
+
}
|
|
30
|
+
get(hashHex) {
|
|
31
|
+
const k = HeightTable.#prefix(hashHex);
|
|
32
|
+
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; }
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
}
|