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,138 @@
1
+ // Measure the blockspace maps AS ANIMATION, not as a still.
2
+ //
3
+ // browser-check.mjs answers "did pixels get painted". It cannot answer the question
4
+ // actually on the table today: "does the picture MOVE, and can I see the mempool
5
+ // reshuffling?" For that you sample the same canvas twice and count pixels that changed.
6
+ // It also asserts the browser is running the bytes on disk (rule 25), and switches the
7
+ // SPA page through the nav button rather than by hash, because a hash-only navigation
8
+ // against a warm document is a no-op and every such reading this repo has had was the
9
+ // wrong page measured.
10
+ //
11
+ // BLOCKYARD_BASE=https://<lan>:8088 [CDP=http://127.0.0.1:9333] node scripts/motion-check.mjs
12
+ import { execFileSync } from 'node:child_process';
13
+
14
+ const BASE = process.env.BLOCKYARD_BASE;
15
+ const CDP = process.env.BROWSER_CDP ?? 'http://127.0.0.1:9333';
16
+ const CA = process.env.BLOCKYARD_CA ?? null; // a CA file for an https monitor, if one is needed
17
+ const OUT = process.env.MOTION_OUT ?? `/tmp/motion-check-${process.pid}.png`;
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ if (!BASE) { console.log('usage: BLOCKYARD_BASE=https://<address>:8088 node scripts/motion-check.mjs'); process.exit(2); }
20
+
21
+ const servedSha = (p) => execFileSync('curl', ['-sk', '--cacert', CA, `${BASE}${p}`], { encoding: 'utf8', maxBuffer: 1 << 24 })
22
+ .split('').reduce((h, c) => (h * 33 ^ c.charCodeAt(0)) >>> 0, 5381).toString(16);
23
+
24
+ const targets = JSON.parse(await (await fetch(`${CDP}/json/list`)).text());
25
+ const page = targets.find((t) => t.type === 'page');
26
+ if (!page) { console.log('no page target'); process.exit(1); }
27
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
28
+ await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
29
+ let nextId = 1;
30
+ const pending = new Map();
31
+ const exceptions = [];
32
+ ws.onmessage = (ev) => {
33
+ const msg = JSON.parse(ev.data);
34
+ if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
35
+ if (msg.method === 'Runtime.exceptionThrown') exceptions.push(msg.params.exceptionDetails?.exception?.description ?? msg.params.exceptionDetails?.text);
36
+ };
37
+ const send = (method, params = {}) => new Promise((res) => {
38
+ const id = nextId++;
39
+ pending.set(id, (m) => res(m.result ?? m.error ?? {}));
40
+ ws.send(JSON.stringify({ id, method, params }));
41
+ });
42
+ const evl = async (expression) => (await send('Runtime.evaluate', { expression, returnByValue: true }))?.result?.value;
43
+
44
+ await send('Page.enable');
45
+ await send('Runtime.enable');
46
+ await send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1900, deviceScaleFactor: 1, mobile: false });
47
+
48
+ // ---- load the app, then SWITCH pages the way a user does (rule 25) ----------------
49
+ const url = `${BASE}/?t=${Date.now()}#overview`;
50
+ const loaded = new Promise((res) => {
51
+ const orig = ws.onmessage;
52
+ ws.onmessage = (m) => { orig(m); if (JSON.parse(m.data).method === 'Page.loadEventFired') res(true); };
53
+ setTimeout(() => res(false), 25_000);
54
+ });
55
+ await send('Page.navigate', { url });
56
+ await loaded;
57
+ await sleep(4000);
58
+
59
+ const shell = JSON.parse(await evl(`JSON.stringify({nav: !!document.getElementById('nav'), path: location.pathname})`) ?? '{}');
60
+ if (!shell.nav || shell.path.startsWith('/login')) { console.log('ABORT: not the app', shell); process.exit(3); }
61
+
62
+ // Prove the document runs the bytes on disk, by reading back something only the new code
63
+ // has. Not the build id -- the served JS itself, hashed in the page against the served copy.
64
+ const ramp = await evl(`(async () => { const t = await (await fetch('/js/goggles.js')).text();
65
+ return JSON.stringify({ hasRamp: /RAMP/.test(t) && /255, 43, 111/.test(t), hasPulse: /PULSE_MS/.test(t), hasHatch: /hatchPattern/.test(t), len: t.length }); })()`);
66
+ console.log('served goggles.js:', ramp);
67
+
68
+ // Click through to Mining and wait for the template (the node spends ~1.3 s on it).
69
+ await evl(`(() => { const b = document.querySelector('#nav button[data-page="mining"]'); if (b) b.click(); return !!b; })()`);
70
+ await sleep(9000);
71
+
72
+ // ---- the measurement: does the canvas actually change between frames? -------------
73
+ const sampler = () => {
74
+ window.__snap = window.__snap ?? {};
75
+ window.__grab = (id) => {
76
+ const c = document.getElementById(id);
77
+ if (!c) return { missing: true };
78
+ const ctx = c.getContext('2d', { willReadFrequently: true });
79
+ const d = ctx.getImageData(0, 0, c.width, c.height).data;
80
+ const key = [];
81
+ let lit = 0, bright = 0;
82
+ const hist = {};
83
+ for (let i = 0; i < d.length; i += 4 * 7) {
84
+ if (d[i + 3] < 8) continue;
85
+ const r = d[i], g = d[i + 1], b = d[i + 2];
86
+ key.push(((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2));
87
+ const L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
88
+ if (L > 60) lit++;
89
+ if (L > 150) bright++;
90
+ const k = `${r >> 4},${g >> 4},${b >> 4}`;
91
+ hist[k] = (hist[k] ?? 0) + 1;
92
+ }
93
+ let hsh = 2166136261;
94
+ for (let i = 0; i < key.length; i++) { hsh ^= key[i]; hsh = Math.imul(hsh, 16777619) >>> 0; }
95
+ const top = Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, 8);
96
+ const tot = Object.values(hist).reduce((a, b) => a + b, 0) || 1;
97
+ return { hash: hsh, px: key.length, litPct: +(100 * lit / key.length).toFixed(1), brightPct: +(100 * bright / key.length).toFixed(1),
98
+ top: top.map(([k, v]) => `${k}:${(100 * v / tot).toFixed(0)}%`) };
99
+ };
100
+ return 'ready';
101
+ };
102
+ await send('Runtime.evaluate', { expression: `(${sampler.toString()})()` });
103
+
104
+ const read = (id) => evl(`window.__grab(${JSON.stringify(id)})`);
105
+ const IDS = ['gnTreemap', 'gnMempoolTreemap'];
106
+ const frames = { gnTreemap: [], gnMempoolTreemap: [] };
107
+ for (let f = 0; f < 24; f++) {
108
+ for (const id of IDS) frames[id].push(await read(id));
109
+ await sleep(200);
110
+ }
111
+ // A second wave later: does the map reshuffle when a new template lands?
112
+ await sleep(14000);
113
+ const after = {};
114
+ for (const id of IDS) after[id] = await read(id);
115
+
116
+ console.log('\n-- per-canvas, 24 samples at 200 ms --');
117
+ for (const id of IDS) {
118
+ const fs = frames[id];
119
+ if (fs[0]?.missing) { console.log(` ${id}: MISSING`); continue; }
120
+ const hashes = fs.map((f) => f.hash);
121
+ const distinct = new Set(hashes).size;
122
+ // Adjacent-frame change: how many samples differ from the one before.
123
+ let changed = 0;
124
+ for (let i = 1; i < hashes.length; i++) if (hashes[i] !== hashes[i - 1]) changed++;
125
+ console.log(` ${id.padEnd(18)} distinct=${distinct}/${hashes.length} adjacent-changes=${changed}/${hashes.length - 1}`);
126
+ console.log(` lit=${fs[0].litPct}% bright=${fs[0].brightPct}% colours=${JSON.stringify(fs[0].top)}`);
127
+ const a = after[id];
128
+ console.log(` 14s later: distinctAgainstFirst=${a.hash !== fs[fs.length - 1].hash} lit=${a.litPct}% bright=${a.brightPct}%`);
129
+ }
130
+ if (exceptions.length) { console.log(`uncaught exceptions: ${exceptions.length}`); exceptions.slice(0, 5).forEach((e) => console.log(` !! ${String(e).split('\n')[0]}`)); }
131
+
132
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
133
+ if (shot.data) {
134
+ const fs = await import('node:fs');
135
+ fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64'));
136
+ console.log(`\nscreenshot: ${OUT}`);
137
+ }
138
+ ws.close();
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // Build the pool label map: coinbase tag -> pool name, from mempool.space's curated
3
+ // mining-pools data (https://github.com/mempool/mining-pools, pools-v2.json).
4
+ //
5
+ // WHY THIS IS ALLOWED AND A GUESS IS NOT. The monitor never invents a miner name. It
6
+ // shows the bytes the pool put in its own coinbase, verbatim. A label from this file is
7
+ // not a guess by us -- it is a curated mapping by the people who maintain one, and it
8
+ // arrives with its provenance (source URL, content sha, fetchedAt) and travels with
9
+ // every row, so a reader can see which label came from where and check it. Blocks that
10
+ // match nothing stay 'unknown:<fingerprint>' with the raw tag on screen.
11
+ //
12
+ // HOW TO RUN. It is manual by default, on purpose: the app must not need the network at
13
+ // runtime, and a surprise fetch that changes which organisation the dashboard blames for
14
+ // a block is not a benign dependency. Refresh it when you want newer labels:
15
+ //
16
+ // node scripts/pool-map.js # fetch and write data/pool-map.json
17
+ // node scripts/pool-map.js --file pools-v2.json # offline input
18
+ // node scripts/pool-map.js --check # compare against what we are seeing now
19
+ //
20
+ // The input shape as fetched on 2026-09-09: 35,733 bytes, 171 pools, 201 tags, keys
21
+ // per pool: addresses, id, link, name, tags. Tags are literal fragments of the coinbase
22
+ // text (e.g. "/BlockfillsPool/"), which is exactly what we can test for -- no regex
23
+ // dialect, no wildcards, no heuristics.
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import crypto from 'node:crypto';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const OUT = path.join(ROOT, 'data', 'pool-map.json');
32
+ const URL_ = 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json';
33
+
34
+ const args = process.argv.slice(2);
35
+ const asFile = args.includes('--file') ? path.resolve(args[args.indexOf('--file') + 1]) : null;
36
+ const checkOnly = args.includes('--check');
37
+
38
+ /** The whole scriptSig rendered as text, so a tag with non-ASCII bytes can still match. */
39
+ function normalizeText(s) {
40
+ return String(s ?? '')
41
+ .replace(/\0+/g, ' ')
42
+ .replace(/[\x01-\x1f\x7f]/g, ' ')
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .toLowerCase();
46
+ }
47
+
48
+ function build(content, source) {
49
+ const list = JSON.parse(content);
50
+ if (!Array.isArray(list) || !list.length) throw new Error('pools-v2.json is not a non-empty array');
51
+ const pools = [];
52
+ for (const p of list) {
53
+ const name = (p.name ?? p.poolName ?? '').toString().trim();
54
+ if (!name) continue;
55
+ const tags = (p.tags ?? []).map((t) => String(t)).filter((t) => t.trim().length >= 2);
56
+ if (!tags.length) continue;
57
+ pools.push({
58
+ key: name.toLowerCase().replace(/\s+/g, ' '),
59
+ name,
60
+ slug: (p.slug ?? p.slugInfo?.slug ?? null) || null,
61
+ link: p.link ?? p.poolLinks?.[0]?.urls?.[0]?.url ?? null,
62
+ tags: tags.sort((a, b) => b.length - a.length),
63
+ });
64
+ }
65
+ // Longest tag wins, so "/Foundry USA Pool" is never stolen by a shorter overlapping tag.
66
+ const matchers = pools
67
+ .flatMap((p) => p.tags.map((tag) => ({ tag, tagNorm: normalizeText(tag), key: p.key, name: p.name })))
68
+ .filter((m) => m.tagNorm.length >= 3)
69
+ .sort((a, b) => b.tagNorm.length - a.tagNorm.length);
70
+ return {
71
+ source,
72
+ sourceSha256: crypto.createHash('sha256').update(content).digest('hex'),
73
+ fetchedAt: new Date().toISOString(),
74
+ attribution: 'mempool.space/mining-pools (MIT). Labels are their curated mapping, matched by literal coinbase text; unmatched blocks keep their raw tag and an unknown fingerprint.',
75
+ matchRule: 'longest literal tag substring wins, case-insensitive, on the whole coinbase scriptSig as text; tags shorter than 3 normalised characters are dropped',
76
+ pools,
77
+ matchers,
78
+ };
79
+ }
80
+
81
+ // node:https with family:4, not fetch(). Measured on this box: `fetch` handed the
82
+ // request to every A and AAAA address, every IPv6 attempt was ENETUNREACH (this host has
83
+ // no IPv6 route -- the node's own log says the same: "no global IPv6 route"), and the
84
+ // call died inside a 20 s budget while an IPv4 socket to the same host was connecting in
85
+ // 367 ms. Pinning the family is the fix; a curl fallback is kept because a tool that
86
+ // fetches pool labels has to work on a box whose stack is half broken.
87
+ import https from 'node:https';
88
+ import { execFileSync } from 'node:child_process';
89
+
90
+ function getOverHttps(url, redirectsLeft = 3, extra = {}) {
91
+ return new Promise((resolve, reject) => {
92
+ const req = https.get(url, { family: 4, ...extra, headers: { 'user-agent': 'BlockYard pool-map/1' }, timeout: 20_000 }, (res) => {
93
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirectsLeft > 0) {
94
+ res.resume();
95
+ return resolve(getOverHttps(new URL(res.headers.location, url).toString(), redirectsLeft - 1));
96
+ }
97
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`)); }
98
+ const chunks = [];
99
+ res.on('data', (c) => chunks.push(c));
100
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
101
+ res.on('error', reject);
102
+ });
103
+ req.on('timeout', () => req.destroy(new Error(`GET ${url} timed out`)));
104
+ req.on('error', reject);
105
+ });
106
+ }
107
+
108
+ async function fetchContent() {
109
+ try {
110
+ return await getOverHttps(URL_);
111
+ } catch (err) {
112
+ // curl follows its own resolver order and got 200 here while node's fetch failed,
113
+ // so a second, different path is worth one retry before declaring the fetch dead.
114
+ try {
115
+ return execFileSync('curl', ['-fsSL', '--max-time', '25', URL_], { encoding: 'utf8', maxBuffer: 8 << 20 });
116
+ } catch {
117
+ throw new Error(`could not fetch ${URL_}: ${err?.message ?? err}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ const content = asFile ? fs.readFileSync(asFile, 'utf8') : await fetchContent();
123
+ const map = build(content, asFile ? `file:${path.basename(asFile)}` : URL_);
124
+
125
+ // What we are actually seeing right now, so the coverage number is measured and not
126
+ // assumed. Read live from the running monitor if it answers; otherwise say it is unknown.
127
+ let observed = null;
128
+ let coverageError = null;
129
+ try {
130
+ const cfgPath = path.join(ROOT, 'config', 'local.json');
131
+ const host = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))?.server?.hosts?.[0] ?? '127.0.0.1';
132
+ // Same family:4 path as the fetch above, plus the box's own CA: the monitor serves TLS
133
+ // from a local CA that is not in the system trust store, and "could not verify" is a
134
+ // different fact from "could not reach".
135
+ const caFile = process.env.BLOCKYARD_CA_FILE ?? null; // a CA file, only where the fetch needs a private one
136
+ const body = await getOverHttps(`https://${host}:8088/api/mining?node=main`, 0, fs.existsSync(caFile) ? { ca: fs.readFileSync(caFile) } : {});
137
+ const d = JSON.parse(body);
138
+ const rows = d.recent ?? [];
139
+ const hit = rows.filter((r) => map.matchers.some((m) => normalizeText(`${r.rawCoinbase ? Buffer.from(r.rawCoinbase, 'hex').toString('utf8') : ''} ${r.tagText || ''}`).includes(m.tagNorm))).length;
140
+ observed = {
141
+ blocks: rows.length, labelled: hit, unknown: rows.filter((r) => !r.poolLabel).length,
142
+ labelSource: d.labelSource ? `labels from ${d.labelSource.source} @ ${(d.labelSource.sha256 || '').slice(0, 10)}` : 'no map loaded by the monitor yet',
143
+ };
144
+ } catch (err) {
145
+ coverageError = err?.message ?? String(err);
146
+ }
147
+
148
+ console.log(`pools: ${map.pools.length} matchers: ${map.matchers.length} sha256: ${map.sourceSha256.slice(0, 12)}`);
149
+ if (observed) console.log(`coverage of the ${observed.blocks} attributed blocks: ${observed.labelled} matched a curated tag, ${observed.unknown} keep their raw tag\n ${observed.labelSource ?? ''}`);
150
+ else console.log(`coverage: not measured (${coverageError}) -- reported as unknown rather than assumed`);
151
+ if (checkOnly) process.exit(0);
152
+
153
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
154
+ const tmp = `${OUT}.tmp`;
155
+ fs.writeFileSync(tmp, `${JSON.stringify(map, null, 1)}\n`);
156
+ fs.renameSync(tmp, OUT);
157
+ console.log(`wrote ${path.relative(ROOT, OUT)}`);