blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,333 @@
1
+ // Who mined a block, and what that pool's blocks look like.
2
+ //
3
+ // Pure functions only -- no I/O, no RPC -- so the decoders are testable against real
4
+ // coinbase bytes frozen in test/fixtures. The caller (monitor) owns the lane and the
5
+ // ledger; this module only turns bytes into facts and folds facts into counters.
6
+ //
7
+ // WHERE THIS COMES FROM, measured 2026-09-09 through the monitor's own read-only
8
+ // console on height 966257:
9
+ // getblock <hash> 1 -> 259,891 bytes, 8 ms (size, weight, strippedsize and
10
+ // the txid list; tx[0] is the
11
+ // coinbase)
12
+ // getrawtransaction <cb> 2 -> 2,915 bytes, 63 ms (vin[0].coinbase = the scriptSig
13
+ // as hex)
14
+ // Both are cheap enough for the serialized lane at one block per tick; neither is
15
+ // anything like getblock verbosity 2, which costs 11 MB per block (MEASUREMENTS 6).
16
+ //
17
+ // NO INVENTED NAMES. The coinbase text is what a pool chose to put in its own block, so
18
+ // it is shown verbatim. A prettier name appears only if a human put it in the alias file
19
+ // (`data/pool-aliases.json`, edited by hand). No tag -> name guessing, no "looks like
20
+ // Foundry" heuristics: a wrong miner attribution is worse than a raw tag, and it is the
21
+ // kind of wrong that gets repeated by everyone who reads the page.
22
+
23
+ const TEXT_OK = /^[\x20-\x7e\x80-\xff]+$/;
24
+
25
+ /**
26
+ * Decode the push frames of a scriptSig.
27
+ *
28
+ * Three things this has to survive, all of them seen in real blocks here on 2026-09-09:
29
+ * - direct pushes (length 1..75), the common case;
30
+ * - OP_PUSHDATA1/2/3 (0x4c/0x4d/0x4e), which tags longer than 75 bytes use -- stopping
31
+ * at them would lose the pool's own name and silently file the block as unknown;
32
+ * - a push that is text followed by binary in the SAME frame (AntPool's
33
+ * "Mined by AntPool971\x15\x00\"\x00\xe16{m"), which is why tag extraction takes the
34
+ * printable prefix of a push rather than demanding the whole frame be text.
35
+ * An unparseable frame stops the walk; a coinbase we cannot read is reported as
36
+ * unparseable, not decoded into something plausible.
37
+ */
38
+ export function parsePushes(hex) {
39
+ const bytes = Buffer.from(String(hex ?? ''), 'hex');
40
+ const out = [];
41
+ let i = 0;
42
+ while (i < bytes.length) {
43
+ const op = bytes[i];
44
+ let n = op;
45
+ let head = 1;
46
+ if (op === 0x4c) { n = bytes[i + 1]; head = 2; }
47
+ else if (op === 0x4d) { n = bytes.readUInt16LE(i + 1); head = 3; }
48
+ else if (op === 0x4e) { n = bytes.readUInt32LE(i + 1); head = 5; }
49
+ if (op === 0 || n < 1 || n > 4096 || i + head + n > bytes.length) break;
50
+ const payload = bytes.subarray(i + head, i + head + n);
51
+ out.push({ len: n, opPushData: op >= 0x4c && op <= 0x4e, hex: payload.toString('hex'), bytes: payload });
52
+ i += head + n;
53
+ }
54
+ return { consumed: i, total: bytes.length, pushes: out };
55
+ }
56
+
57
+ /**
58
+ * The longest printable prefix of a push. Pool tags and extra nonces share one frame
59
+ * more often than they get a frame each, so "is this frame text" is the wrong question;
60
+ * the right one is "what did it start with". Returns null for a frame with nothing
61
+ * readable at the front, so a binary extranonce is never mistaken for a name.
62
+ */
63
+ export function printablePrefix(buf) {
64
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf ?? ''));
65
+ let end = 0;
66
+ while (end < b.length && b[end] >= 0x20 && b[end] <= 0x7e) end++;
67
+ return end >= 2 ? b.subarray(0, end).toString('ascii') : null;
68
+ }
69
+
70
+ /**
71
+ * A coinbase scriptSig, decoded.
72
+ *
73
+ * Layout as the block-creating software writes it: a 3-5 byte little-endian BIP34 block
74
+ * height, then text the pool chose, then the witness-stripping commitment (0xaa21a9ed…)
75
+ * and/or an extra nonce. Everything after the tag is reported as hex, never as text:
76
+ * calling random bytes "text" is how a field starts lying.
77
+ *
78
+ * Verified against a real block: height 966257 decodes to
79
+ * push0 71be0e -> 0x0ebe71 little-endian = 966257, equal to getblock.height
80
+ * push1 "…/ViaBTC/Mined by ecgbtc/"
81
+ * push2 starts fabe6d6d (the BIP300-style commitment prefix)
82
+ */
83
+ export function decodeCoinbase(hex) {
84
+ const { consumed, total, pushes } = parsePushes(hex);
85
+ const out = {
86
+ parseable: pushes.length > 0,
87
+ truncatedAt: consumed < total ? total - consumed : 0,
88
+ height: null,
89
+ tagText: null,
90
+ tag: null,
91
+ commitment: null,
92
+ extraNonce: null,
93
+ raw: String(hex ?? ''),
94
+ };
95
+ if (!out.parseable) return out;
96
+
97
+ const first = pushes[0];
98
+ if (first.len >= 1 && first.len <= 5) out.height = first.bytes.readUIntLE(0, first.len);
99
+
100
+ // The first push that starts with readable text containing a letter is the tag the
101
+ // pool chose -- the printable *prefix*, because the extra nonce is routinely appended
102
+ // inside the same frame. Anything else in position 1 is reported where it belongs, not
103
+ // dressed up as a name.
104
+ for (let k = 1; k < pushes.length; k++) {
105
+ const s = printablePrefix(pushes[k].bytes);
106
+ if (pushes[k].len >= 2 && s && /[A-Za-z]/.test(s)) {
107
+ out.tagText = s.replace(/\0+$/, '').slice(0, 100);
108
+ out.tag = cleanTag(out.tagText);
109
+ break;
110
+ }
111
+ }
112
+
113
+ if (!out.tagText && pushes.length) {
114
+ const runs = printableRuns(out.raw, 4).filter((r) => r.offset >= (pushes[0]?.len ?? 0) + 1);
115
+ if (runs.length) {
116
+ out.tagText = runs[0].text.slice(0, 100);
117
+ out.tag = cleanTag(out.tagText);
118
+ out.tagSource = 'scan';
119
+ }
120
+ } else if (out.tagText) {
121
+ out.tagSource = 'push';
122
+ }
123
+
124
+ const rest = pushes.slice(out.tagText ? 2 : 1);
125
+ let joined = rest.map((p) => p.hex).join('');
126
+ // The walk stops at the first frame header it cannot read, and in a lot of real blocks
127
+ // that is exactly where the witness-stripping commitment lives (it begins with the
128
+ // 4-byte marker fabe6d6d, which is not a valid push length). Searching the remainder
129
+ // for the marker is a fact rather than a guess: either the bytes are there or they are
130
+ // not, and finding it recovers the commitment the pushes lost.
131
+ if (!joined.includes('fabe6d6d')) {
132
+ // `consumed` is where the push walk stopped, in bytes -- not the sum of payload
133
+ // lengths, which forgets the length bytes themselves and would search from the
134
+ // wrong place.
135
+ const tail = out.raw.slice(consumed * 2);
136
+ if (tail.includes('fabe6d6d')) joined = tail;
137
+ }
138
+ const at = joined.indexOf('fabe6d6d');
139
+ if (at >= 0) {
140
+ out.commitment = 'fabe6d6d';
141
+ out.commitmentData = joined.slice(at + 8, at + 8 + 64) || null;
142
+ out.extraNonce = joined.slice(0, at) || out.extraNonce || null;
143
+ } else if (joined) {
144
+ out.extraNonce = joined.slice(0, 32) || null;
145
+ }
146
+ return out;
147
+ }
148
+
149
+
150
+ /**
151
+ * Every readable run of at least `min` characters in the scriptSig, in order.
152
+ *
153
+ * This exists because some pools write a scriptSig whose push lengths do not describe
154
+ * the bytes that follow them -- the Foundry block at height 966258 declares a 47-byte
155
+ * push inside a 50-byte scriptSig, so the strict push walk stops with no tag even
156
+ * though "Foundry USA Pool #dropgold" is sitting there in ASCII, in order, in the block.
157
+ * Scanning for runs is a weaker claim than parsing, so it is used only as a fallback and
158
+ * it says which one produced the answer (tagSource: 'push' | 'scan'). Neither invents
159
+ * anything: both read bytes the miner put in the block.
160
+ */
161
+ export function printableRuns(buf, min = 4) {
162
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf ?? ''), 'hex');
163
+ const runs = [];
164
+ let start = -1;
165
+ for (let i = 0; i < b.length; i++) {
166
+ const ok = b[i] >= 0x20 && b[i] <= 0x7e;
167
+ if (ok && start < 0) start = i;
168
+ if ((!ok || i === b.length - 1) && start >= 0) {
169
+ const end = ok ? i + 1 : i;
170
+ if (end - start >= min) {
171
+ const text = b.subarray(start, end).toString('ascii');
172
+ if (/[A-Za-z]/.test(text)) runs.push({ offset: start, text });
173
+ }
174
+ start = -1;
175
+ }
176
+ }
177
+ return runs;
178
+ }
179
+
180
+ /** Lowercase, control bytes folded, whitespace collapsed -- the form a tag is matched in. */
181
+ export function normalizeTagText(s) {
182
+ return String(s ?? '')
183
+ .replace(/\0+/g, ' ')
184
+ .replace(/[\x01-\x1f\x7f]/g, ' ')
185
+ .replace(/\s+/g, ' ')
186
+ .trim()
187
+ .toLowerCase();
188
+ }
189
+
190
+ /**
191
+ * Look a coinbase up in a curated tag map (data/pool-map.json, written by
192
+ * scripts/pool-map.js from mempool.space's mining-pools data).
193
+ *
194
+ * The haystack is the WHOLE scriptSig as text, not only the extracted tag, because the
195
+ * pool's chosen fragment sometimes sits in a frame the tag walk never reached. Longest
196
+ * literal tag wins; tags under three normalised characters are dropped at build time, so
197
+ * a bare "pool" can never claim a block. Returns null when nothing matches -- the caller
198
+ * then keeps the raw tag and the unknown fingerprint, which is the point of the exercise.
199
+ */
200
+ export function matchPool(map, { tagText = '', rawHex = '' } = {}) {
201
+ if (!map?.matchers?.length) return null;
202
+ const hay = normalizeTagText(
203
+ (rawHex ? Buffer.from(String(rawHex), 'hex').toString('utf8') : '') + ' ' + String(tagText ?? ''));
204
+ if (!hay.trim()) return null;
205
+ for (const m of map.matchers) {
206
+ if (m.tagNorm && hay.includes(m.tagNorm)) return { key: m.key, name: m.name, matchedTag: m.tag };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * A comparable key for a pool, without pretending to know its brand.
213
+ * "/ViaBTC/Mined by ecgbtc/" -> "viabtc"; "nanopool" -> "nanopool"; a tag with no
214
+ * letters of its own stays "unknown:<hash>" so it is still countable and never silently
215
+ * merged with another pool.
216
+ */
217
+ export function cleanTag(tagText) {
218
+ const s = String(tagText ?? '');
219
+ const parts = s.split(/[\/|\s]+/).map((p) => p.trim()).filter((p) => p && p !== '-');
220
+ // Prefer the first token that is not a verb phrase ("Mined by", "Paid to").
221
+ const noisy = /^(mined|paid|pooled|by|via|from|mempool|space|bitdeer|www|http|https)$/i;
222
+ const picked = parts.find((p) => /^[A-Za-z][A-Za-z0-9 ._-]{1,24}$/.test(p) && !noisy.test(p))
223
+ ?? parts.find((p) => /^[A-Za-z][A-Za-z0-9 ._-]{1,24}$/.test(p));
224
+ if (!picked) return null;
225
+ return picked.toLowerCase().replace(/\s+/g, ' ');
226
+ }
227
+
228
+ /** Short, stable fingerprint so unattributed blocks are countable without a name. */
229
+ export function tagFingerprint(tagText) {
230
+ const s = String(tagText ?? '');
231
+ let h = 2166136261;
232
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
233
+ return `unknown:${(h >>> 0).toString(16).slice(0, 6)}`;
234
+ }
235
+
236
+ /** What a decoded coinbase plus a getblockstats row becomes in the ledger. */
237
+ export function minerRow({ height, hash, decoded, stats, at }) {
238
+ const key = decoded?.tag ? decoded.tag : tagFingerprint(decoded?.tagText ?? '');
239
+ return {
240
+ height,
241
+ hash: hash ?? null,
242
+ at: at ?? null,
243
+ poolKey: key,
244
+ tagText: decoded?.tagText ?? null,
245
+ tagSource: decoded?.tagSource ?? null,
246
+ tagParseable: !!decoded?.parseable,
247
+ tagHeightMatch: decoded?.height != null && stats?.height != null ? decoded.height === stats.height : null,
248
+ weight: stats?.weight ?? null,
249
+ size: stats?.size ?? null,
250
+ strippedSize: stats?.strippedSize ?? null,
251
+ txs: stats?.txs ?? null,
252
+ totalfee: stats?.totalfee ?? null,
253
+ avgFeerate: stats?.avgFeerate ?? null,
254
+ p50: stats?.p1 ?? null,
255
+ p75: stats?.p2 ?? null,
256
+ p99: stats?.p4 ?? null,
257
+ extraNonce: decoded?.extraNonce ?? null,
258
+ commitment: decoded?.commitment ?? null,
259
+ rawCoinbase: decoded?.raw ?? null,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Fold one block into the per-pool ledger. Counts are per pool over the rows the monitor
265
+ * has actually seen -- it is a window, not history, and callers must present it that way
266
+ * (share % of the observed window, with the window stated).
267
+ */
268
+ export function ledgerApply(ledger, row) {
269
+ if (!row?.poolKey) return ledger;
270
+ const p = ledger.get(row.poolKey) ?? {
271
+ poolKey: row.poolKey, tagText: row.tagText, tagTexts: new Set(), label: row.poolLabel ?? null,
272
+ blocks: 0, txs: 0,
273
+ weightSum: 0, feeSum: 0, feerateSum: 0, feerateN: 0,
274
+ firstHeight: row.height, lastHeight: row.height, lastAt: null, sizes: [], feerates: [],
275
+ };
276
+ p.blocks += 1;
277
+ // Keep the distinct coinbase texts that folded into this row: a group is only as
278
+ // trustworthy as its audit trail, and this is what makes 'AntPool971 grouped into
279
+ // AntPool by a curated label' checkable rather than invisible.
280
+ if (row.tagText) { p.tagTexts.add(String(row.tagText).slice(0, 60)); if (!p.tagText) p.tagText = row.tagText; }
281
+ if (row.poolLabel && !p.label) p.label = row.poolLabel;
282
+ p.txs += row.txs ?? 0;
283
+ if (row.weight != null) p.weightSum += row.weight;
284
+ if (row.totalfee != null) p.feeSum += row.totalfee;
285
+ if (row.avgFeerate != null) { p.feerateSum += row.avgFeerate; p.feerateN += 1; p.feerates.push(row.avgFeerate); }
286
+ if (row.size != null) p.sizes.push(row.size);
287
+ p.lastHeight = Math.max(p.lastHeight, row.height);
288
+ p.firstHeight = Math.min(p.firstHeight, row.height);
289
+ p.lastAt = row.at ?? p.lastAt;
290
+ ledger.set(row.poolKey, p);
291
+ return ledger;
292
+ }
293
+
294
+ const median = (a) => {
295
+ if (!a.length) return null;
296
+ const s = [...a].sort((x, y) => x - y);
297
+ return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
298
+ };
299
+
300
+ /** Ledger rows, ranked, with the window they describe so a share % is never bare. */
301
+ export function ledgerRows(ledger, { keep = 12 } = {}) {
302
+ const all = [...ledger.values()];
303
+ const totalBlocks = all.reduce((n, p) => n + p.blocks, 0);
304
+ return all
305
+ .map((p) => ({
306
+ poolKey: p.poolKey,
307
+ label: p.label ?? null,
308
+ tagText: p.tagText,
309
+ tags: [...(p.tagTexts ?? new Set())].slice(0, 4),
310
+ blocks: p.blocks,
311
+ sharePct: totalBlocks ? +(100 * p.blocks / totalBlocks).toFixed(1) : null,
312
+ txs: p.txs || null,
313
+ avgWeight: p.blocks ? Math.round(p.weightSum / p.blocks) : null,
314
+ medianSize: median(p.sizes),
315
+ avgFeeRate: p.feerateN ? +(p.feerateSum / p.feerateN).toFixed(2) : null,
316
+ medianFeeRate: median(p.feerates),
317
+ totalFeesSat: p.feeSum || null,
318
+ firstHeight: p.firstHeight,
319
+ lastHeight: p.lastHeight,
320
+ }))
321
+ .sort((a, b) => b.blocks - a.blocks || b.lastHeight - a.lastHeight)
322
+ .slice(0, keep);
323
+ }
324
+
325
+ /**
326
+ * Human labels, only from a file a person wrote. Returns null when there is no label --
327
+ * callers show the raw tag in that case, which is the point.
328
+ */
329
+ export function aliasFor(table, key) {
330
+ if (!table || !key) return null;
331
+ const hit = table[key] ?? table[String(key).replace(/\s+/g, ' ').trim()];
332
+ return typeof hit === 'string' && hit.trim() ? hit.trim() : null;
333
+ }