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,220 @@
1
+ // Named time series + crash-safe persistence.
2
+ //
3
+ // Snapshots are written tmp+fsync+rename, i.e. a reader sees the old file or the
4
+ // new one and never a half-written one. That is the same discipline the node's
5
+ // own writers use (its docs repeat the rule: "header written last, so a crash
6
+ // leaves a file that reads as absent rather than a partial one that looks
7
+ // whole"), and it is the only sane answer when the process can be SIGKILLed
8
+ // mid-flush by a system OOM killer this box has actually triggered before.
9
+ import fsp from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { Ring } from './ring.js';
12
+
13
+ // The single schema both the collectors and the HTTP layer use. Field names here
14
+ // are what the browser receives; adding a field means adding it here first.
15
+ export const SERIES = {
16
+ node: ['t', 'blocks', 'headers', 'progress', 'difficulty', 'sizeOnDisk', 'ibd', 'connections',
17
+ 'peersIn', 'peersOut', 'uptimeMs', 'txRate', 'chainTxCount', 'txouts', 'totalAmount', 'muhash'],
18
+ mempool: ['t', 'count', 'bytes', 'usage', 'maxUsage', 'totalFee', 'minFee', 'minRelayFee', 'unbroadcast',
19
+ 'ingestRate', 'acceptedDelta', 'rejectMissing', 'rejectPolicy', 'rejectInvalid', 'confirmedDrain',
20
+ 'pendingAncestors', 'replaceable', 'avgFee', 'avgVsize'],
21
+ net: ['t', 'inBps', 'outBps', 'diskWriteBps', 'inTotal', 'outTotal', 'diskTotal', 'avgRecvBps', 'avgWriteBps',
22
+ 'floorBps', 'poolMedianBps'],
23
+ fees: ['t', 'f1', 'f2', 'f6', 'f24', 'f144', 'mempoolmin', 'priority', 'estimatorOk'],
24
+ peers: ['t', 'connections', 'in', 'out', 'relayPeers', 'servedBlocks', 'txRelayPeers', 'wanted', 'banned',
25
+ 'rankingLive', 'rankingAnswered', 'rankingMedianKbps'],
26
+ blocks: ['t', 'height', 'time', 'mediantime', 'totalfee', 'txs', 'size', 'weight', 'medianTxSize', 'avgTxSize', 'swtotalSize', 'swtxs', 'avgFeerate', 'subsidy',
27
+ 'utxoIncrease', 'ins', 'outs', 'avgfee', 'medianfee', 'maxfee', 'p0', 'p1', 'p2', 'p3', 'p4', 'gapSec', 'viaPeer', 'source'],
28
+ txflow: ['t', 'accepted', 'relayAccepted', 'rejectMissing', 'rejectPolicy', 'rejectInvalid', 'alreadyConfirmed',
29
+ 'orphansHeld', 'orphansParked', 'orphansResolved', 'orphansDropped', 'inFlight', 'oneP1C', 'oneP1CFailed', 'windowSec'],
30
+ rpc: ['t', 'latencyMs', 'avgLatencyMs', 'ratePerSec', 'queued', 'errors', 'breakerTrips', 'busyMsPerSec'],
31
+ self: ['t', 'rssMb', 'heapMb', 'sseClients', 'usersActive', 'cpuPct', 'eventRate'],
32
+ };
33
+
34
+ export class History {
35
+ constructor(dir, cfg, { log = () => {} } = {}) {
36
+ this.dir = dir;
37
+ this.cfg = cfg;
38
+ this.log = log;
39
+ this.rings = new Map();
40
+ for (const [name] of Object.entries(SERIES)) this.rings.set(name, new Ring(cfg.ringCapacity));
41
+ this.events = []; // newest first, capped
42
+ this.eventsSeq = 0;
43
+ this.file = path.join(dir, 'history.json');
44
+ this.eventsFile = path.join(dir, 'events.jsonl');
45
+ this.saving = false;
46
+ this.lastSavedAt = null;
47
+ this.lastSaveError = null;
48
+ this.dirtySince = 0;
49
+ }
50
+
51
+ ring(name) {
52
+ if (!this.rings.has(name)) throw new Error(`unknown series ${name}`);
53
+ return this.rings.get(name);
54
+ }
55
+
56
+ // A node-scoped view of the same store. Series rings are shared by every
57
+ // configured node, so writes are stamped and reads are filtered; without this,
58
+ // a two-node deployment draws one line that averages two daemons (measured:
59
+ // 2,308 production rows against 1,816 bench rows in the `peers` ring).
60
+ //
61
+ // Deliberately still ONE ring per series rather than one per node: capacity and
62
+ // retention are configured per series and shared, and per-node rings would double
63
+ // the memory of every added node while making a removed node's history unreachable.
64
+ // If capacity ever becomes the constraint, say so here rather than discovering it
65
+ // in a chart that quietly stopped reaching back 24 h.
66
+ forNode(nodeId) {
67
+ if (!nodeId) throw new Error('forNode needs a node id');
68
+ const history = this;
69
+ return {
70
+ __perNode: true,
71
+ node: nodeId,
72
+ ring(name) {
73
+ const ring = history.ring(name);
74
+ return {
75
+ node: nodeId,
76
+ get length() { return ring.length; },
77
+ first: () => ring.first(),
78
+ last: (opts = {}) => ring.tail(50).filter((r) => r.node === nodeId).pop() ?? null,
79
+ since: (t, o = {}) => ring.since(t).filter((r) => (o.node === undefined ? r.node === nodeId : o.node === r.node)),
80
+ tail: (n, o = {}) => ring.tail(n * 4).filter((r) => (o.node === undefined ? r.node === nodeId : o.node === r.node)).slice(-n),
81
+ series: (field, opts = {}) => ring.series(field, { ...opts, node: opts.node ?? nodeId }),
82
+ stats: (field, opts = {}) => ring.stats(field, { ...opts, node: opts.node ?? nodeId }),
83
+ raw: ring,
84
+ };
85
+ },
86
+ record(name, row) { return history.record(name, { ...row, node: nodeId }); },
87
+ addEvent(ev) { return history.addEvent({ ...ev, node: nodeId }); },
88
+ addEvents(evs) { return evs.map((e) => this.addEvent(e)); },
89
+ eventsSinceSeq: (seq, limit) => history.eventsSinceSeq(seq, limit),
90
+ forNode: () => history.forNode(nodeId),
91
+ };
92
+ }
93
+
94
+ record(name, row) {
95
+ const ring = this.ring(name);
96
+ ring.push({ t: row.t ?? Date.now(), ...row });
97
+ this.dirtySince ||= Date.now();
98
+ return ring.last();
99
+ }
100
+
101
+ addEvent(ev) {
102
+ this.eventsSeq += 1;
103
+ const row = { seq: this.eventsSeq, ...ev };
104
+ this.events.unshift(row);
105
+ if (this.events.length > this.cfg.maxEventLog) this.events.length = this.cfg.maxEventLog;
106
+ this.dirtySince ||= Date.now();
107
+ return row;
108
+ }
109
+
110
+ addEvents(evs) { return evs.map((e) => this.addEvent(e)); }
111
+
112
+ // Newest-first events with a sequence above `seq` (this.events is maintained
113
+ // newest-first, so a plain filter already yields the right order).
114
+ eventsSinceSeq(seq, limit = 500) {
115
+ return this.events.filter((e) => e.seq > seq).slice(0, limit);
116
+ }
117
+
118
+ prune() {
119
+ const cutoff = Date.now() - this.cfg.retentionHours * 3600 * 1000;
120
+ for (const ring of this.rings.values()) ring.pruneBefore(cutoff);
121
+ }
122
+
123
+ summary() {
124
+ const out = {};
125
+ for (const [name, ring] of this.rings) {
126
+ out[name] = {
127
+ points: ring.length,
128
+ firstAt: ring.first()?.t ?? null,
129
+ lastAt: ring.last()?.t ?? null,
130
+ // Rows written before per-node tagging, and which nodes are represented.
131
+ // Stated because a node-filtered chart legitimately cannot draw them, and
132
+ // silence about that would look like missing history.
133
+ unattributed: ring.unattributed(),
134
+ nodes: ring.nodes(),
135
+ };
136
+ }
137
+ return out;
138
+ }
139
+
140
+ async save() {
141
+ if (this.saving) return { skipped: true };
142
+ this.saving = true;
143
+ try {
144
+ await fsp.mkdir(this.dir, { recursive: true });
145
+ const payload = {
146
+ version: 1,
147
+ savedAt: Date.now(),
148
+ retentionHours: this.cfg.retentionHours,
149
+ rings: Object.fromEntries([...this.rings].map(([k, r]) => [k, r.toJSON()])),
150
+ events: this.events.slice(0, this.cfg.maxEventLog),
151
+ eventsSeq: this.eventsSeq,
152
+ };
153
+ const tmp = `${this.file}.tmp`;
154
+ const fh = await fsp.open(tmp, 'w', 0o600); // node-derived detail: owner-only, like users and sessions
155
+ await fh.writeFile(JSON.stringify(payload));
156
+ await fh.sync();
157
+ await fh.close();
158
+ await fsp.rename(tmp, this.file);
159
+ this.lastSavedAt = Date.now();
160
+ this.lastSaveError = null;
161
+ this.dirtySince = 0;
162
+ return { saved: true, bytes: JSON.stringify(payload).length };
163
+ } catch (err) {
164
+ this.lastSaveError = { at: Date.now(), message: err.message };
165
+ this.log({ level: 'error', msg: `history snapshot failed: ${err.message}` });
166
+ return { saved: false, error: err.message };
167
+ } finally {
168
+ this.saving = false;
169
+ }
170
+ }
171
+
172
+ async load() {
173
+ let raw;
174
+ try {
175
+ raw = await fsp.readFile(this.file, 'utf8');
176
+ } catch {
177
+ return { loaded: false, reason: 'no snapshot yet' };
178
+ }
179
+ let data;
180
+ try {
181
+ data = JSON.parse(raw);
182
+ } catch (err) {
183
+ // A corrupt snapshot is not a reason to refuse to start; it is a reason to
184
+ // say so and fall back to an empty history.
185
+ this.log({ level: 'warn', msg: `history snapshot unparseable (${err.message}); starting empty` });
186
+ return { loaded: false, reason: `unparseable: ${err.message}` };
187
+ }
188
+ const cutoff = Date.now() - this.cfg.retentionHours * 3600 * 1000;
189
+ for (const name of Object.keys(SERIES)) {
190
+ const r = Ring.fromJSON(data.rings?.[name], this.cfg.ringCapacity);
191
+ r.pruneBefore(cutoff);
192
+ this.rings.set(name, r);
193
+ }
194
+ this.events = (data.events ?? []).filter((e) => e.ts >= cutoff).slice(0, this.cfg.maxEventLog);
195
+ this.eventsSeq = Math.max(data.eventsSeq ?? 0, this.events[0]?.seq ?? 0);
196
+ return { loaded: true, points: [...this.rings.values()].reduce((a, r) => a + r.length, 0), savedAt: data.savedAt ?? null };
197
+ }
198
+
199
+ startAutosave() {
200
+ this.timer = setInterval(() => {
201
+ if (!this.dirtySince) return;
202
+ if (Date.now() - this.dirtySince < this.cfg.snapshotEveryMs) return;
203
+ this.prune();
204
+ this.save().catch(() => {});
205
+ }, Math.min(60_000, this.cfg.snapshotEveryMs));
206
+ this.timer.unref?.();
207
+ return this;
208
+ }
209
+
210
+ async stop() {
211
+ if (this.timer) clearInterval(this.timer);
212
+ if (this.dirtySince) await this.save();
213
+ }
214
+ }
215
+
216
+ // Atomic append-only text sink, used by the audit log.
217
+ export async function appendJsonl(file, row) {
218
+ await fsp.mkdir(path.dirname(file), { recursive: true });
219
+ await fsp.appendFile(file, JSON.stringify(row) + '\n', { encoding: 'utf8', mode: 0o600 }); // who did what: owner-only (audit 2026-09-14, L4)
220
+ }
@@ -0,0 +1,290 @@
1
+ // The durable store for facts the monitor derives and must not re-derive.
2
+ //
3
+ // Why this exists: coinbase attribution is two RPC reads per block, and until now the
4
+ // result lived in a 400-row in-memory map that died on every restart -- so a monitor that
5
+ // had been running for a month could still tell you about exactly the last four minutes of
6
+ // mining, and a restart threw away everything it had learned about who mines this chain.
7
+ // "Durable, and the next run starts from what this one saw" is the requirement; this is
8
+ // the smallest thing that satisfies it.
9
+ //
10
+ // Two engines behind one interface, chosen at boot, no dependency either way:
11
+ //
12
+ // sqlite -- node:sqlite, in the runtime we already run. Measured on this box
13
+ // (2026-09-09): 52,578 rows (a year of blocks) written in 62 ms, aggregate
14
+ // over the lot in 6 ms, 7.93 MB on disk. Crash safety was tested, not
15
+ // assumed: 1,000 committed rows, a child process inserting 1,000 more inside
16
+ // an open transaction, SIGKILL mid-transaction -- reopen gave rows=1000,
17
+ // uncommitted kept=0, PRAGMA integrity_check = ok.
18
+ // Caveat carried honestly: node:sqlite prints
19
+ // "ExperimentalWarning: SQLite is an experimental feature and might change",
20
+ // so it is feature-detected and never assumed.
21
+ //
22
+ // jsonl -- append-only newline-delimited JSON, fsync'd. Measured: the same year of
23
+ // rows written + fsync'd in 13 ms, 14.84 MB, replayed in 5 ms. No queries,
24
+ // no transactions across rows, but it has no API risk at all, is readable by
25
+ // grep while the service runs, and doubles as the export of the sqlite file.
26
+ //
27
+ // The fallback is not a downgrade path that changes behaviour: aggregation lives here, in
28
+ // JavaScript, over the same row shape, so both engines answer identically. That is a
29
+ // deliberate trade -- sqlite could answer the aggregate in SQL in 6 ms -- in exchange for
30
+ // one code path, one set of tests, and a fallback that is provably the same product.
31
+
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+
35
+ const ROW_COLUMNS = ['height', 'hash', 'poolKey', 'poolLabelKey', 'poolLabel', 'matchedTag',
36
+ 'tagText', 'tagSource', 'weight', 'size', 'strippedSize', 'txs', 'totalfee', 'avgFeerate',
37
+ 'p50', 'p75', 'p99', 'extraNonce', 'commitment', 'rawCoinbase', 'mapSha', 'seenAt'];
38
+
39
+ /** Does this runtime give us a working node:sqlite? Tested by using it, not by version. */
40
+ async function sqliteEngine() {
41
+ try {
42
+ const mod = await import('node:sqlite');
43
+ if (typeof mod.DatabaseSync !== 'function') return null;
44
+ // Open an in-memory database and write to it. If that works, the engine works.
45
+ const probe = new mod.DatabaseSync(':memory:');
46
+ probe.exec('CREATE TABLE probe(k INTEGER PRIMARY KEY, v TEXT)');
47
+ probe.prepare('INSERT INTO probe VALUES (1,?)').run('ok');
48
+ probe.close();
49
+ return mod.DatabaseSync;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function openLedger({ file, engine = 'auto', keepHeights = 52_594, log = () => {} } = {}) {
56
+ if (!file) throw new Error('openLedger needs a file path');
57
+ fs.mkdirSync(path.dirname(file), { recursive: true });
58
+ let chosen = engine;
59
+ if (engine === 'auto') {
60
+ chosen = (process.env.BLOCKYARD_LEDGER_ENGINE ?? 'sqlite').trim().toLowerCase();
61
+ if (chosen === 'sqlite') {
62
+ const DatabaseSync = await sqliteEngine();
63
+ if (!DatabaseSync) {
64
+ chosen = 'jsonl';
65
+ log({ level: 'warn', msg: `ledger: node:sqlite unavailable, using the append-only file (same rows, same answers, no queries)` });
66
+ }
67
+ }
68
+ }
69
+ if (chosen === 'sqlite') {
70
+ const DatabaseSync = await sqliteEngine();
71
+ if (DatabaseSync) return new SqliteLedger({ file, DatabaseSync, keepHeights, log });
72
+ chosen = 'jsonl';
73
+ }
74
+ return new JsonlLedger({ file, keepHeights, log });
75
+ }
76
+
77
+ // ------------------------------------------------------------------ sqlite
78
+
79
+ class SqliteLedger {
80
+ constructor({ file, DatabaseSync, keepHeights, log }) {
81
+ this.kind = 'sqlite';
82
+ this.file = file;
83
+ this.keepHeights = keepHeights;
84
+ this.log = log;
85
+ this.db = new DatabaseSync(file);
86
+ // WAL + synchronous=FULL: measured to roll back an open transaction on SIGKILL.
87
+ // WAL leaves a -wal beside the file; that is expected and is part of the store.
88
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
89
+ this.db.exec(`CREATE TABLE IF NOT EXISTS attribution (
90
+ height INTEGER PRIMARY KEY, pool_key TEXT, pool_label TEXT, seen_at INTEGER, row TEXT
91
+ )`);
92
+ this.db.exec('CREATE INDEX IF NOT EXISTS attribution_seen ON attribution(seen_at DESC)');
93
+ this.insert = this.db.prepare('INSERT OR REPLACE INTO attribution VALUES (?,?,?,?,?)');
94
+ }
95
+
96
+ put(row) { return this.putMany([row]); }
97
+
98
+ putMany(rows) {
99
+ const good = (rows ?? []).filter((r) => Number.isInteger(r?.height));
100
+ if (!good.length) return 0;
101
+ this.db.exec('BEGIN');
102
+ try {
103
+ for (const r of good) {
104
+ this.insert.run(r.height, r.poolKey ?? null, r.poolLabel ?? null, r.seenAt ?? Date.now(), JSON.stringify(pick(r)));
105
+ }
106
+ this.db.exec('COMMIT');
107
+ } catch (err) {
108
+ this.db.exec('ROLLBACK');
109
+ throw err;
110
+ }
111
+ return good.length;
112
+ }
113
+
114
+ /** A reorg happened: nothing above the new fork height may survive. */
115
+ dropAbove(height) {
116
+ const gone = this.db.prepare('SELECT COUNT(*) c FROM attribution WHERE height > ?').get(height).c;
117
+ this.db.prepare('DELETE FROM attribution WHERE height > ?').run(height);
118
+ return gone;
119
+ }
120
+
121
+ latest(n = 50) {
122
+ return this.db.prepare('SELECT row FROM attribution ORDER BY height DESC LIMIT ?').all(n)
123
+ .map((r) => JSON.parse(r.row));
124
+ }
125
+
126
+ since(height) {
127
+ return this.db.prepare('SELECT row FROM attribution WHERE height >= ? ORDER BY height DESC').all(height)
128
+ .map((r) => JSON.parse(r.row));
129
+ }
130
+
131
+ deleteOlderThan(height) {
132
+ return this.db.prepare('DELETE FROM attribution WHERE height < ?').run(height).changes ?? 0;
133
+ }
134
+
135
+ depth() {
136
+ const row = this.db.prepare('SELECT COUNT(*) n, MIN(height) lo, MAX(height) hi FROM attribution').get();
137
+ return {
138
+ engine: this.kind, file: path.basename(this.file), rows: row.n ?? 0,
139
+ from: row.lo ?? null, to: row.hi ?? null, bytes: fileSize(this.file),
140
+ };
141
+ }
142
+
143
+ close() { try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ } this.db.close(); }
144
+ }
145
+
146
+ // ------------------------------------------------------------------- jsonl
147
+
148
+ class JsonlLedger {
149
+ constructor({ file, keepHeights, log }) {
150
+ this.kind = 'jsonl';
151
+ this.file = file;
152
+ this.keepHeights = keepHeights;
153
+ this.log = log;
154
+ this.byHeight = new Map();
155
+ this.dirty = 0;
156
+ // A torn last line is normal after a crash, not corruption: read what parses and
157
+ // keep appending. Same discipline as the log follower uses on the node's log.
158
+ let tail = '';
159
+ try { tail = fs.readFileSync(file, 'utf8'); } catch { tail = ''; }
160
+ for (const line of tail.split('\n')) {
161
+ if (!line.trim()) continue;
162
+ try {
163
+ const row = JSON.parse(line);
164
+ if (Number.isInteger(row?.height)) this.byHeight.set(row.height, row);
165
+ } catch { /* the incomplete final write; ignored, then overwritten */ }
166
+ }
167
+ this.fd = fs.openSync(file, 'a');
168
+ }
169
+
170
+ put(row) { return this.putMany([row]); }
171
+
172
+ putMany(rows) {
173
+ const good = (rows ?? []).filter((r) => Number.isInteger(r?.height));
174
+ let wrote = 0;
175
+ for (const r of good) {
176
+ this.byHeight.set(r.height, pick(r));
177
+ fs.writeSync(this.fd, JSON.stringify(pick(r)) + '\n');
178
+ wrote++;
179
+ }
180
+ if (wrote) {
181
+ fs.fsyncSync(this.fd); // durability is the whole point of this file
182
+ this.dirty += wrote;
183
+ if (this.dirty > 20_000) this.#compact(); // keep the file from growing without bound
184
+ }
185
+ return wrote;
186
+ }
187
+
188
+ #compact() {
189
+ const rows = [...this.byHeight.values()].sort((a, b) => a.height - b.height);
190
+ const tmp = `${this.file}.tmp`;
191
+ const fd = fs.openSync(tmp, 'w');
192
+ for (const r of rows) fs.writeSync(fd, JSON.stringify(r) + '\n');
193
+ fs.fsyncSync(fd);
194
+ fs.closeSync(fd);
195
+ fs.closeSync(this.fd);
196
+ fs.renameSync(tmp, this.file);
197
+ this.fd = fs.openSync(this.file, 'a');
198
+ this.dirty = 0;
199
+ }
200
+
201
+ dropAbove(height) {
202
+ const rows = [...this.byHeight.keys()].filter((h) => h > height);
203
+ for (const h of rows) this.byHeight.delete(h);
204
+ if (rows.length) this.#compact();
205
+ return rows.length;
206
+ }
207
+
208
+ latest(n = 50) {
209
+ return [...this.byHeight.keys()].sort((a, b) => b - a).slice(0, n).map((h) => this.byHeight.get(h));
210
+ }
211
+
212
+ since(height) {
213
+ return [...this.byHeight.entries()].filter(([h]) => h >= height)
214
+ .sort((a, b) => b[0] - a[0]).map(([, r]) => r);
215
+ }
216
+
217
+ deleteOlderThan(height) {
218
+ let n = 0;
219
+ for (const h of [...this.byHeight.keys()]) if (h < height) { this.byHeight.delete(h); n++; }
220
+ if (n) this.#compact();
221
+ return n;
222
+ }
223
+
224
+ depth() {
225
+ const ks = [...this.byHeight.keys()];
226
+ return {
227
+ engine: this.kind, file: path.basename(this.file), rows: ks.length,
228
+ from: ks.length ? Math.min(...ks) : null, to: ks.length ? Math.max(...ks) : null,
229
+ bytes: fileSize(this.file),
230
+ };
231
+ }
232
+
233
+ close() { try { this.#compact(); } catch { /* closing must not throw */ } try { fs.closeSync(this.fd); } catch { /* already closed */ } }
234
+ }
235
+
236
+ // ----------------------------------------------------------------- shared
237
+
238
+ function pick(r) {
239
+ const out = {};
240
+ for (const k of ROW_COLUMNS) if (r[k] !== undefined) out[k] = r[k];
241
+ return out;
242
+ }
243
+
244
+ const fileSize = (f) => { try { return fs.statSync(f).size; } catch { return 0; } };
245
+
246
+ /**
247
+ * Fold rows into the pool view. One implementation for both engines, so an engine
248
+ * switch cannot change what the page says about who mines this chain.
249
+ */
250
+ export function aggregate(rows = []) {
251
+ const pools = new Map();
252
+ for (const r of rows) {
253
+ const key = r.poolLabelKey ?? r.poolKey ?? 'unknown';
254
+ const p = pools.get(key) ?? {
255
+ poolKey: r.poolKey ?? key, label: r.poolLabel ?? null, labelled: !!r.poolLabel,
256
+ blocks: 0, txs: 0, weightSum: 0, feeSum: 0, feerates: [], sizes: [], tags: new Set(),
257
+ firstHeight: r.height, lastHeight: r.height,
258
+ };
259
+ p.blocks++;
260
+ p.txs += r.txs ?? 0;
261
+ if (r.weight != null) p.weightSum += r.weight;
262
+ if (r.totalfee != null) p.feeSum += r.totalfee;
263
+ if (r.avgFeerate != null) p.feerates.push(r.avgFeerate);
264
+ if (r.size != null) p.sizes.push(r.size);
265
+ if (r.tagText) p.tags.add(String(r.tagText).slice(0, 60));
266
+ p.firstHeight = Math.min(p.firstHeight, r.height);
267
+ p.lastHeight = Math.max(p.lastHeight, r.height);
268
+ if (r.poolLabel && !p.label) { p.label = r.poolLabel; p.labelled = true; }
269
+ pools.set(key, p);
270
+ }
271
+ const total = rows.length;
272
+ const median = (a) => {
273
+ if (!a.length) return null;
274
+ const s = [...a].sort((x, y) => x - y);
275
+ return s.length % 2 ? s[(s.length - 1) / 2] : +((s[s.length / 2 - 1] + s[s.length / 2]) / 2).toFixed(2);
276
+ };
277
+ return [...pools.values()]
278
+ .map((p) => ({
279
+ poolKey: p.poolKey, label: p.label, labelled: p.labelled, blocks: p.blocks,
280
+ sharePct: total ? +(100 * p.blocks / total).toFixed(1) : null,
281
+ txs: p.txs || null,
282
+ avgWeight: p.blocks && p.weightSum ? Math.round(p.weightSum / p.blocks) : null,
283
+ medianSize: median(p.sizes),
284
+ medianFeeRate: median(p.feerates),
285
+ totalFeesSat: p.feeSum || null,
286
+ tags: [...p.tags].slice(0, 4),
287
+ firstHeight: p.firstHeight, lastHeight: p.lastHeight,
288
+ }))
289
+ .sort((a, b) => b.blocks - a.blocks || b.lastHeight - a.lastHeight);
290
+ }
@@ -0,0 +1,173 @@
1
+ // Fixed-capacity time-ordered store with on-read downsampling.
2
+ //
3
+ // Charts ask "give me 6 hours at ~200 pixels", which is a bucketed aggregate,
4
+ // not a raw walk. Doing that here means the browser receives a few hundred
5
+ // numbers per series instead of 20k, on every SSE refresh.
6
+ export class Ring {
7
+ constructor(capacity = 20000) {
8
+ this.capacity = capacity;
9
+ this.rows = [];
10
+ }
11
+
12
+ push(row) {
13
+ if (row == null || !Number.isFinite(row.t)) return;
14
+ // Append-only assumption: collectors are the only writers and always move
15
+ // forward in time, so a binary search is safe and keeps `since` cheap.
16
+ this.rows.push(row);
17
+ if (this.rows.length > this.capacity) this.rows.splice(0, this.rows.length - this.capacity);
18
+ }
19
+
20
+ get length() { return this.rows.length; }
21
+ first() { return this.rows[0] ?? null; }
22
+ last() { return this.rows[this.rows.length - 1] ?? null; }
23
+
24
+ pruneBefore(cutoffMs) {
25
+ const i = lowerBound(this.rows, cutoffMs);
26
+ if (i > 0) this.rows.splice(0, i);
27
+ }
28
+
29
+ since(t) {
30
+ const i = lowerBound(this.rows, t);
31
+ return this.rows.slice(i);
32
+ }
33
+
34
+ tail(n) { return this.rows.slice(Math.max(0, this.rows.length - n)); }
35
+
36
+ // Rows written before per-node tagging existed. Reported rather than silently
37
+ // dropped or silently attributed.
38
+ unattributed() { return this.rows.reduce((n, r) => n + (r.node == null ? 1 : 0), 0); }
39
+
40
+ nodes() {
41
+ const seen = [];
42
+ for (const r of this.rows) if (r.node != null && !seen.includes(r.node)) seen.push(r.node);
43
+ return seen;
44
+ }
45
+
46
+ // Bucketed single-field aggregate for a chart series.
47
+ //
48
+ // `node` matters because one ring per series is shared by every configured node.
49
+ // Measured with two live nodes on 2026-09-08: the `peers` ring held 2,308 rows
50
+ // from production interleaved with 1,816 from the bench node, and no row said
51
+ // which. Drawn as one line, that is a chart of an average of two daemons, which
52
+ // is not a fact about either. Rows written before the tagging exist and have no
53
+ // node: they are excluded from a node-filtered read, because guessing their owner
54
+ // would put bench traffic on a production chart. They still count in `stats()`
55
+ // with no filter, and in `unattributed()` so the number is visible.
56
+ series(field, { since: from, bucketMs = 0, agg = 'last', node = null } = {}) {
57
+ const rows = pick(this.rows, from, node);
58
+ if (!bucketMs) return rows.map((r) => ({ t: r.t, v: r[field] })).filter((p) => p.v != null);
59
+ const out = [];
60
+ let bucket = null;
61
+ const acc = { n: 0, sum: 0, min: Infinity, max: -Infinity, last: null, first: null, prev: null };
62
+ const flush = () => {
63
+ if (bucket == null) return;
64
+ out.push({ t: bucket + bucketMs, v: aggregate(agg, acc) });
65
+ acc.n = 0; acc.sum = 0; acc.min = Infinity; acc.max = -Infinity; acc.last = null; acc.first = null; acc.prev = null;
66
+ };
67
+ for (const r of rows) {
68
+ const v = r[field];
69
+ const b = Math.floor(r.t / bucketMs) * bucketMs;
70
+ if (bucket === null) bucket = b;
71
+ if (b !== bucket) { flush(); bucket = b; }
72
+ if (v == null || Number.isNaN(v)) continue;
73
+ if (acc.n === 0) acc.first = v;
74
+ if (agg === 'delta') {
75
+ if (acc.n > 0) { acc.sum += Math.max(0, v - acc.last); }
76
+ acc.prev = acc.last;
77
+ }
78
+ acc.n += 1; acc.sum += v; acc.min = Math.min(acc.min, v); acc.max = Math.max(acc.max, v); acc.last = v;
79
+ }
80
+ flush();
81
+ return out.filter((p) => p.v != null);
82
+ }
83
+
84
+ stats(field, { since: from, node = null } = {}) {
85
+ const rows = pick(this.rows, from, node);
86
+ const acc = { n: 0, sum: 0, min: Infinity, max: -Infinity, last: null, first: null, prev: null };
87
+ for (const r of rows) {
88
+ const v = r[field];
89
+ if (v == null || Number.isNaN(v)) continue;
90
+ if (acc.n === 0) acc.first = v;
91
+ if (acc.n > 0) acc.sum += Math.max(0, v - acc.last);
92
+ acc.n += 1;
93
+ acc.min = Math.min(acc.min, v); acc.max = Math.max(acc.max, v); acc.last = v;
94
+ }
95
+ if (!acc.n) return null;
96
+ return {
97
+ n: acc.n, first: acc.first, last: acc.last, min: acc.min, max: acc.max,
98
+ avg: acc.sum / acc.n, spread: acc.max - acc.min,
99
+ // A counter's growth per bucket, not its level.
100
+ growth: acc.n > 1 ? acc.sum : 0,
101
+ };
102
+ }
103
+
104
+ toJSON() { return { capacity: this.capacity, rows: this.rows }; }
105
+ static fromJSON(o, capacity) { const r = new Ring(capacity ?? o?.capacity ?? 20000); if (o?.rows) r.rows = o.rows.slice(-1 * (capacity ?? o.capacity ?? 20000)); return r; }
106
+ }
107
+
108
+ function aggregate(kind, acc) {
109
+ if (!acc.n) return null;
110
+ switch (kind) {
111
+ case 'avg': return acc.sum / acc.n;
112
+ case 'min': return acc.min;
113
+ case 'max': return acc.max;
114
+ case 'sum': return acc.sum;
115
+ case 'first': return acc.first;
116
+ case 'delta': return acc.sum; // sum of positive inter-sample steps in the bucket
117
+ case 'last':
118
+ default: return acc.last;
119
+ }
120
+ }
121
+
122
+
123
+ export function lowerBound(rows, t) {
124
+ let lo = 0;
125
+ let hi = rows.length;
126
+ while (lo < hi) {
127
+ const mid = (lo + hi) >> 1;
128
+ if (rows[mid].t < t) lo = mid + 1; else hi = mid;
129
+ }
130
+ return lo;
131
+ }
132
+
133
+ // Time slice, then optional owner filter. The filter runs after the binary search
134
+ // so the cheap part stays cheap; a node-filtered walk is O(rows-since) which is
135
+ // bounded by the ring capacity either way.
136
+ function pick(rows, from, node) {
137
+ let out = from ? rows.slice(lowerBound(rows, from)) : rows;
138
+ if (node != null) out = out.filter((r) => r.node === node);
139
+ return out;
140
+ }
141
+
142
+ // A sliding window that also answers "per second over the window", which is how
143
+ // every counter in this system (bytes, tx counts, txouts) becomes a rate.
144
+ export class CounterRate {
145
+ constructor(windowMs = 120000) {
146
+ this.windowMs = windowMs;
147
+ this.samples = [];
148
+ }
149
+ add(value, t = Date.now()) {
150
+ if (value == null || !Number.isFinite(value)) return null;
151
+ this.samples.push({ t, value });
152
+ const cutoff = t - this.windowMs;
153
+ while (this.samples.length > 1 && this.samples[0].t < cutoff) this.samples.shift();
154
+ if (this.samples.length > 2000) this.samples.splice(0, this.samples.length - 2000);
155
+ // A counter reset (node restart zeroes it) must not read as a negative rate
156
+ // nor as a colossal one; treat a decrease as a fresh baseline.
157
+ if (value < this.samples[0].value) { this.samples = [{ t, value }]; return 0; }
158
+ return this.rate();
159
+ }
160
+
161
+ // Current rate without pushing a sample, so a read model can ask for the rate
162
+ // at snapshot time.
163
+ rate() {
164
+ if (this.samples.length < 2) return null;
165
+ const a = this.samples[0];
166
+ const b = this.samples[this.samples.length - 1];
167
+ const dt = (b.t - a.t) / 1000;
168
+ if (dt <= 0) return null;
169
+ return (b.value - a.value) / dt;
170
+ }
171
+
172
+ get span() { return this.samples.length >= 2 ? this.samples[this.samples.length - 1].t - this.samples[0].t : 0; }
173
+ }