blockyard 0.0.1 → 0.1.0

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 (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,189 @@
1
+ // Log follower for the node's log.
2
+ //
3
+ // Correctness concerns, all of them real on this box (logrotate rotates
4
+ // bitcoin.main.log to .1 and gzips older ones):
5
+ // - rotation: the file we hold moves to .1 and a NEW file appears at the same
6
+ // name. Reading the new file from byte 0 while the old inode still has
7
+ // unread lines would lose them; following the old inode forever would stall
8
+ // at EOF. So: drain the old inode to EOF, then switch.
9
+ // - truncation: size < position means the file was cut in place. Restart at 0.
10
+ // - a partial last line must be carried to the next read, not parsed and lost.
11
+ // - polling is the primary mechanism (an append-heavy log makes fs.watch
12
+ // unreliable); the watcher is only a nudge to reduce latency.
13
+ import fs from 'node:fs';
14
+ import fsp from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { EventEmitter } from 'node:events';
17
+ import { splitLines } from './logparse.js';
18
+
19
+ export class LogTail extends EventEmitter {
20
+ constructor(file, { pollMs = 1000, tailBytes = 2 * 1024 * 1024, maxBytes = 256 * 1024, log = () => {} } = {}) {
21
+ super();
22
+ this.file = file;
23
+ this.pollMs = pollMs;
24
+ this.tailBytes = tailBytes;
25
+ this.maxBytes = maxBytes;
26
+ this.log = log;
27
+ this.fd = null;
28
+ this.ino = null;
29
+ this.pos = 0;
30
+ this.carry = { carry: '' };
31
+ this.timer = null;
32
+ this.watcher = null;
33
+ this.stopped = false;
34
+ this.busy = false;
35
+ this.state = {
36
+ file, exists: false, inode: null, size: 0, pos: 0, lagBytes: 0,
37
+ openedAt: null, lastReadAt: null, lines: 0, events: 0, rotations: 0, truncations: 0,
38
+ readErrors: 0, backfilled: false,
39
+ };
40
+ }
41
+
42
+ async start() {
43
+ await this._openInitial();
44
+ this._watch();
45
+ const tick = () => {
46
+ if (this.stopped) return;
47
+ this._read().catch((err) => {
48
+ this.state.readErrors += 1;
49
+ this.log({ level: 'warn', msg: `log tail read failed: ${err.message}` });
50
+ }).finally(() => { if (!this.stopped) this.timer = setTimeout(tick, this.pollMs); });
51
+ };
52
+ this.timer = setTimeout(tick, this.pollMs);
53
+ return this;
54
+ }
55
+
56
+ async _openInitial() {
57
+ let st;
58
+ try {
59
+ st = await fsp.stat(this.file);
60
+ } catch {
61
+ this.state.exists = false;
62
+ return; // may appear later; the poll loop retries
63
+ }
64
+ this.state.exists = true;
65
+ try {
66
+ this.fd = await fsp.open(this.file, 'r');
67
+ } catch (err) {
68
+ this.log({ level: 'warn', msg: `cannot open ${this.file}: ${err.message}` });
69
+ return;
70
+ }
71
+ const s = await this.fd.stat();
72
+ this.ino = s.ino;
73
+ this.state.inode = s.ino;
74
+ // Backfill the tail, starting on a line boundary so the first parsed line is
75
+ // not a fragment of a stack trace.
76
+ this.pos = s.size > this.tailBytes ? await this._firstLineOffset(s.size - this.tailBytes) : 0;
77
+ this.state.openedAt = Date.now();
78
+ this.state.backfilled = false;
79
+ }
80
+
81
+ async _firstLineOffset(from) {
82
+ if (from <= 0) return 0;
83
+ const buf = Buffer.alloc(8192);
84
+ try {
85
+ const { bytesRead } = await this.fd.read(buf, 0, buf.length, from);
86
+ const nl = buf.indexOf(0x0a, 0, bytesRead);
87
+ return nl >= 0 ? from + nl + 1 : from;
88
+ } catch {
89
+ return from;
90
+ }
91
+ }
92
+
93
+ _watch() {
94
+ try {
95
+ this.watcher = fs.watch(path.dirname(this.file), (evt, name) => {
96
+ if (!name || path.join(path.dirname(this.file), name) === this.file) this._read().catch(() => {});
97
+ });
98
+ this.watcher.on('error', () => { try { this.watcher.close(); } catch { /* already gone */ } });
99
+ } catch {
100
+ // Directory not watchable (network mount, permissions). Polling still works.
101
+ }
102
+ }
103
+
104
+ async _read() {
105
+ if (this.busy || this.stopped) return;
106
+ this.busy = true;
107
+ try {
108
+ if (this.fd == null) { await this._openInitial(); if (this.fd == null) return; }
109
+
110
+ let s = await this.fd.stat();
111
+
112
+ if (s.size < this.pos) { // truncated in place
113
+ this.state.truncations += 1;
114
+ this.pos = 0;
115
+ this.carry.carry = '';
116
+ }
117
+ if (s.size > this.pos) await this._drain();
118
+
119
+ // Rotation: has the name moved on to a different inode?
120
+ let nameStat = null;
121
+ try { nameStat = await fsp.stat(this.file); } catch { nameStat = null; }
122
+ if (nameStat && nameStat.ino !== this.ino) {
123
+ await this._drain(); // whatever is left of the old inode first
124
+ await this.fd.close().catch(() => {});
125
+ this.fd = null;
126
+ this.state.rotations += 1;
127
+ this.carry.carry = '';
128
+ try {
129
+ this.fd = await fsp.open(this.file, 'r');
130
+ const ns = await this.fd.stat();
131
+ this.ino = ns.ino;
132
+ this.pos = 0;
133
+ this.state.inode = ns.ino;
134
+ this.state.size = ns.size;
135
+ this.state.exists = true;
136
+ } catch {
137
+ this.ino = null;
138
+ this.state.exists = false;
139
+ }
140
+ } else if (!nameStat && !this.state.exists) {
141
+ this.state.exists = false;
142
+ } else if (nameStat) {
143
+ this.state.exists = true;
144
+ this.state.size = nameStat.size;
145
+ }
146
+
147
+ this.state.pos = this.pos;
148
+ this.state.size = s.size;
149
+ this.state.lagBytes = Math.max(0, s.size - this.pos);
150
+ this.state.lastReadAt = Date.now();
151
+ if (!this.state.backfilled) {
152
+ this.state.backfilled = true;
153
+ this.emit('backfilled', this.state);
154
+ }
155
+ } finally {
156
+ this.busy = false;
157
+ }
158
+ }
159
+
160
+ async _drain() {
161
+ for (;;) {
162
+ const s = await this.fd.stat();
163
+ if (s.size <= this.pos) return;
164
+ const len = Math.min(this.maxBytes, s.size - this.pos);
165
+ const buf = Buffer.alloc(len);
166
+ const { bytesRead } = await this.fd.read(buf, 0, len, this.pos);
167
+ if (bytesRead <= 0) return;
168
+ this.pos += bytesRead;
169
+ this.state.lines += bytesRead;
170
+ const events = splitLines(buf.toString('utf8', 0, bytesRead), this.carry);
171
+ if (events.length) {
172
+ this.state.events += events.length;
173
+ this.emit('events', events);
174
+ }
175
+ if (bytesRead < len) return;
176
+ }
177
+ }
178
+
179
+ status() {
180
+ return { ...this.state, lagBytes: this.state.lagBytes };
181
+ }
182
+
183
+ async stop() {
184
+ this.stopped = true;
185
+ if (this.timer) clearTimeout(this.timer);
186
+ if (this.watcher) { try { this.watcher.close(); } catch { /* ignore */ } }
187
+ if (this.fd) { await this.fd.close().catch(() => {}); this.fd = null; }
188
+ }
189
+ }
@@ -0,0 +1,333 @@
1
+ // MARKETS (operator, 2026-09-11: "I also want us to have another tab that basically rips off
2
+ // bitcoinity.org entirely"; asked where prices should come from: "Add exchange prices").
3
+ //
4
+ // THE ONE OUTBOUND CONNECTION THAT IS NOT THE NODE. Everything else in this monitor reads the
5
+ // node's RPC; this module fetches the public, unauthenticated REST endpoints of five exchanges
6
+ // over HTTPS. It runs here, server-side, because the page's CSP lets the browser talk to this
7
+ // origin only (connect-src 'self'). What leaves the machine: this host's address and a
8
+ // User-Agent, sent to the hosts named below -- nothing about the node.
9
+ //
10
+ // ON DEMAND: polling starts when someone opens the Markets tab (GET /api/markets calls touch())
11
+ // and stops ten minutes after the last such request, so a monitor nobody is looking at makes no
12
+ // exchange traffic at all. BLOCKYARD_MARKETS=0 (or markets.enabled=false) turns it off entirely.
13
+ // Binance was tried and answers HTTP 451 from here (geo-blocked, 2026-09-11), so it is not listed.
14
+ //
15
+ // Every figure keeps its age, and an exchange that fails is reported failing with its error --
16
+ // never dropped or zero-filled (rule 8: stale must look stale).
17
+
18
+ import net from 'node:net';
19
+
20
+ // COINBASE AND OKX READ "fetch failed" (2026-09-11) while curl reached both from the same host.
21
+ // Node's fetch races a host's addresses (happy eyeballs) and gives each connect attempt 250 ms by
22
+ // default; this host has no IPv6 route, and the Cloudflare-fronted exchanges sometimes take longer
23
+ // than that to accept over IPv4, so every attempt was abandoned and the request failed outright.
24
+ // Three seconds an attempt; the process's only other connections are to the node on loopback.
25
+ net.setDefaultAutoSelectFamilyAttemptTimeout?.(3000);
26
+
27
+ const num = (v) => { const n = Number(v); return v != null && v !== '' && Number.isFinite(n) ? n : null; };
28
+ const candle = (t, o, h, l, c, v) => ({ t: num(t), o: num(o), h: num(h), l: num(l), c: num(c), v: num(v) });
29
+ const tidy = (rows) => rows.filter((k) => k.t != null && k.c != null).sort((a, b) => a.t - b.t);
30
+
31
+ function krakenResult(j) {
32
+ if (Array.isArray(j?.error) && j.error.length) throw new Error(j.error.join('; '));
33
+ return Object.values(j?.result ?? {}).find((v) => v !== null && typeof v === 'object') ?? null;
34
+ }
35
+ function okxData(j) {
36
+ if (j?.code !== '0') throw new Error(j?.msg || `code ${j?.code ?? '?'}`);
37
+ return Array.isArray(j.data) ? j.data : [];
38
+ }
39
+ function bitfinexRows(j) {
40
+ if (Array.isArray(j) && j[0] === 'error') throw new Error(String(j[2] ?? j[1] ?? 'error'));
41
+ return Array.isArray(j) ? j : [];
42
+ }
43
+
44
+ // Hourly candles from each: { t (ms, the hour's start), o, h, l, c, v (BTC) }, oldest first.
45
+ export const EXCHANGES = [
46
+ {
47
+ id: 'coinbase', name: 'Coinbase', pair: 'BTC-USD', quote: 'USD',
48
+ tickerUrl: 'https://api.exchange.coinbase.com/products/BTC-USD/ticker',
49
+ parseTicker: (j) => ({ last: num(j?.price), bid: num(j?.bid), ask: num(j?.ask), vol24: num(j?.volume) }),
50
+ candleUrl: 'https://api.exchange.coinbase.com/products/BTC-USD/candles?granularity=3600',
51
+ // [time s, low, high, open, close, volume], newest first
52
+ parseCandles: (j) => tidy((Array.isArray(j) ? j : []).map((r) => candle(r[0] * 1000, r[3], r[2], r[1], r[4], r[5]))),
53
+ },
54
+ {
55
+ id: 'kraken', name: 'Kraken', pair: 'XBT/USD', quote: 'USD',
56
+ tickerUrl: 'https://api.kraken.com/0/public/Ticker?pair=XBTUSD',
57
+ parseTicker: (j) => { const r = krakenResult(j); return { last: num(r?.c?.[0]), bid: num(r?.b?.[0]), ask: num(r?.a?.[0]), vol24: num(r?.v?.[1]) }; },
58
+ candleUrl: 'https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=60',
59
+ // [time s, open, high, low, close, vwap, volume, count], oldest first
60
+ parseCandles: (j) => tidy((krakenResult(j) ?? []).map((r) => candle(r[0] * 1000, r[1], r[2], r[3], r[4], r[6]))),
61
+ },
62
+ {
63
+ id: 'bitstamp', name: 'Bitstamp', pair: 'BTC/USD', quote: 'USD',
64
+ tickerUrl: 'https://www.bitstamp.net/api/v2/ticker/btcusd/',
65
+ parseTicker: (j) => ({ last: num(j?.last), bid: num(j?.bid), ask: num(j?.ask), vol24: num(j?.volume) }),
66
+ candleUrl: 'https://www.bitstamp.net/api/v2/ohlc/btcusd/?step=3600&limit=168',
67
+ parseCandles: (j) => tidy((j?.data?.ohlc ?? []).map((r) => candle(Number(r.timestamp) * 1000, r.open, r.high, r.low, r.close, r.volume))),
68
+ },
69
+ {
70
+ id: 'bitfinex', name: 'Bitfinex', pair: 'BTC/USD', quote: 'USD',
71
+ tickerUrl: 'https://api-pub.bitfinex.com/v2/ticker/tBTCUSD',
72
+ // [BID, BID_SIZE, ASK, ASK_SIZE, DAILY_CHANGE, DAILY_CHANGE_RELATIVE, LAST_PRICE, VOLUME, HIGH, LOW]
73
+ parseTicker: (j) => { const a = bitfinexRows(j); return { last: num(a[6]), bid: num(a[0]), ask: num(a[2]), vol24: num(a[7]) }; },
74
+ candleUrl: 'https://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist?limit=168',
75
+ // [MTS, OPEN, CLOSE, HIGH, LOW, VOLUME], newest first
76
+ parseCandles: (j) => tidy(bitfinexRows(j).map((r) => candle(r[0], r[1], r[3], r[4], r[2], r[5]))),
77
+ },
78
+ {
79
+ id: 'okx', name: 'OKX', pair: 'BTC-USDT', quote: 'USDT',
80
+ tickerUrl: 'https://www.okx.com/api/v5/market/ticker?instId=BTC-USDT',
81
+ parseTicker: (j) => { const d = okxData(j)[0]; return { last: num(d?.last), bid: num(d?.bidPx), ask: num(d?.askPx), vol24: num(d?.vol24h) }; },
82
+ candleUrl: 'https://www.okx.com/api/v5/market/candles?instId=BTC-USDT&bar=1H&limit=168',
83
+ // [ts ms, o, h, l, c, vol, volCcy, volCcyQuote, confirm], newest first
84
+ parseCandles: (j) => tidy(okxData(j).map((r) => candle(r[0], r[1], r[2], r[3], r[4], r[5]))),
85
+ },
86
+ ];
87
+
88
+ // ORDER BOOKS (operator, 2026-09-11, with a picture of bitcoinity's depth chart: "we are totally
89
+ // missing this view in the market info"). Each exchange's public book, as deep as it gives it
90
+ // free (measured 2026-09-11): Coinbase and Bitstamp the whole book (1.1 MB, 4 s; 160 KB);
91
+ // Bitfinex grouped to $100 levels, +-13%; Kraken 500 levels, about -2% / +3%; OKX 5000 levels
92
+ // (books-full), about +-1.2%. A book is never extended past where it ends.
93
+ const pairs = (rows) => (rows ?? []).map((r) => [num(r[0]), num(r[1])]).filter(([p, q]) => p > 0 && q > 0);
94
+ export const BOOKS = {
95
+ coinbase: { url: 'https://api.exchange.coinbase.com/products/BTC-USD/book?level=2', parse: (j) => ({ bids: pairs(j?.bids), asks: pairs(j?.asks) }) },
96
+ kraken: { url: 'https://api.kraken.com/0/public/Depth?pair=XBTUSD&count=500', parse: (j) => { const r = krakenResult(j); return { bids: pairs(r?.bids), asks: pairs(r?.asks) }; } },
97
+ bitstamp: { url: 'https://www.bitstamp.net/api/v2/order_book/btcusd/', parse: (j) => ({ bids: pairs(j?.bids), asks: pairs(j?.asks) }) },
98
+ // [PRICE, COUNT, AMOUNT]: a positive amount is a bid, a negative one an ask
99
+ bitfinex: {
100
+ url: 'https://api-pub.bitfinex.com/v2/book/tBTCUSD/P2?len=100',
101
+ parse: (j) => { const r = bitfinexRows(j); return { bids: pairs(r.filter((x) => x[2] > 0).map((x) => [x[0], x[2]])), asks: pairs(r.filter((x) => x[2] < 0).map((x) => [x[0], -x[2]])) }; },
102
+ },
103
+ okx: { url: 'https://www.okx.com/api/v5/market/books-full?instId=BTC-USDT&sz=5000', parse: (j) => { const d = okxData(j)[0]; return { bids: pairs(d?.bids), asks: pairs(d?.asks) }; } },
104
+ };
105
+
106
+ export const DEPTH_STEP = 50; // dollars a level on the depth grid: fixed, so snapshots compare as the price moves
107
+ export const DEPTH_SPAN = 0.12; // how far either side of the mid the grid reaches
108
+ export const DEPTH_AGOS = [60, 300, 600, 1800, 3600];
109
+ const r3 = (v) => Math.round(v * 1000) / 1000;
110
+
111
+ // A book as cumulative depth on the grid p0 + i*step: bids[i] is the BTC bid at or above that
112
+ // price, asks[i] the BTC asked at or below it. null where that side has nothing to say -- above
113
+ // the best bid, below the best ask -- and past the end of a book the exchange cut short.
114
+ export function depthOf(book, p0, n, step = DEPTH_STEP) {
115
+ let bestBid = -Infinity, lowBid = Infinity, bestAsk = Infinity, highAsk = -Infinity;
116
+ for (const [p] of book.bids) { if (p > bestBid) bestBid = p; if (p < lowBid) lowBid = p; }
117
+ for (const [p] of book.asks) { if (p < bestAsk) bestAsk = p; if (p > highAsk) highAsk = p; }
118
+ const bids = book.bids.filter(([p]) => p >= p0 - step).sort((a, b) => b[0] - a[0]);
119
+ const asks = book.asks.filter(([p]) => p <= p0 + n * step).sort((a, b) => a[0] - b[0]);
120
+ const B = new Array(n).fill(null), A = new Array(n).fill(null);
121
+ let j = 0, cum = 0;
122
+ for (let i = n - 1; i >= 0; i--) {
123
+ const p = p0 + i * step;
124
+ if (p > bestBid || p < lowBid) continue;
125
+ while (j < bids.length && bids[j][0] >= p) cum += bids[j++][1];
126
+ B[i] = r3(cum);
127
+ }
128
+ j = 0; cum = 0;
129
+ for (let i = 0; i < n; i++) {
130
+ const p = p0 + i * step;
131
+ if (p < bestAsk || p > highAsk) continue;
132
+ while (j < asks.length && asks[j][0] <= p) cum += asks[j++][1];
133
+ A[i] = r3(cum);
134
+ }
135
+ return { bids: B, asks: A };
136
+ }
137
+
138
+ const UA = 'BlockYard (self-hosted Bitcoin node monitor)';
139
+ const median = (xs) => (xs.length ? (xs.length % 2 ? xs[(xs.length - 1) / 2] : (xs[xs.length / 2 - 1] + xs[xs.length / 2]) / 2) : null);
140
+
141
+ export class MarketFeed {
142
+ constructor(cfg = {}, { log = () => {}, fetchImpl = globalThis.fetch, now = Date.now, exchanges = EXCHANGES } = {}) {
143
+ this.cfg = { tickerMs: 15_000, candleMs: 300_000, bookMs: 30_000, idleAfterMs: 600_000, timeoutMs: 8_000, candles: 168, ...cfg };
144
+ this.log = log;
145
+ this.fetch = fetchImpl;
146
+ this.now = now;
147
+ this.exchanges = exchanges;
148
+ this.rows = new Map(exchanges.map((ex) => [ex.id, { ticker: null, candles: [], candlesAt: null, tickerError: null, candleError: null }]));
149
+ this.timers = null;
150
+ this.lastTouch = 0;
151
+ this.depthHist = []; // an hour of depth snapshots, for the change bars
152
+ }
153
+
154
+ get running() { return this.timers != null; }
155
+ idle() { return this.now() - this.lastTouch > this.cfg.idleAfterMs; }
156
+
157
+ // Someone is looking: poll now if we were parked, and keep polling until nobody has asked
158
+ // for idleAfterMs.
159
+ touch() {
160
+ this.lastTouch = this.now();
161
+ if (this.timers) return;
162
+ this.log({ level: 'info', msg: `markets: polling ${this.exchanges.length} exchanges while the Markets tab is open` });
163
+ const run = (fn) => { fn().catch(() => {}); };
164
+ run(() => this.pollTickers());
165
+ run(() => this.pollCandles());
166
+ run(() => this.pollBooks());
167
+ const t1 = setInterval(() => {
168
+ if (this.idle()) { this.stop(); this.log({ level: 'info', msg: 'markets: nobody watching; exchange polling parked' }); return; }
169
+ run(() => this.pollTickers());
170
+ }, this.cfg.tickerMs);
171
+ const t2 = setInterval(() => run(() => this.pollCandles()), this.cfg.candleMs);
172
+ const t3 = setInterval(() => run(() => this.pollBooks()), this.cfg.bookMs);
173
+ t1.unref?.(); t2.unref?.(); t3.unref?.();
174
+ this.timers = [t1, t2, t3];
175
+ }
176
+
177
+ stop() {
178
+ for (const t of this.timers ?? []) clearInterval(t);
179
+ this.timers = null;
180
+ }
181
+
182
+ async get(url) {
183
+ const r = await this.fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, signal: AbortSignal.timeout(this.cfg.timeoutMs) });
184
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
185
+ return r.json();
186
+ }
187
+
188
+ async pollTickers() {
189
+ await Promise.all(this.exchanges.map(async (ex) => {
190
+ const row = this.rows.get(ex.id);
191
+ try {
192
+ const t = ex.parseTicker(await this.get(ex.tickerUrl));
193
+ if (!(t.last > 0)) throw new Error('no price in the reply');
194
+ row.ticker = { ...t, at: this.now() };
195
+ row.tickerError = null;
196
+ } catch (err) {
197
+ row.tickerError = { message: String(err?.message ?? err), at: this.now() };
198
+ }
199
+ }));
200
+ }
201
+
202
+ async pollCandles() {
203
+ await Promise.all(this.exchanges.map(async (ex) => {
204
+ const row = this.rows.get(ex.id);
205
+ try {
206
+ const c = ex.parseCandles(await this.get(ex.candleUrl)).slice(-this.cfg.candles);
207
+ if (!c.length) throw new Error('no candles in the reply');
208
+ row.candles = c;
209
+ row.candlesAt = this.now();
210
+ row.candleError = null;
211
+ } catch (err) {
212
+ row.candleError = { message: String(err?.message ?? err), at: this.now() };
213
+ }
214
+ }));
215
+ }
216
+
217
+ async pollBooks() {
218
+ const books = new Map();
219
+ await Promise.all(this.exchanges.map(async (ex) => {
220
+ const B = BOOKS[ex.id];
221
+ if (!B) return;
222
+ const row = this.rows.get(ex.id);
223
+ try {
224
+ const bk = B.parse(await this.get(B.url));
225
+ if (!bk.bids.length || !bk.asks.length) throw new Error('an empty book');
226
+ books.set(ex.id, bk);
227
+ row.bookAt = this.now();
228
+ row.bookError = null;
229
+ } catch (err) {
230
+ row.bookError = { message: String(err?.message ?? err), at: this.now() };
231
+ }
232
+ }));
233
+ if (!books.size) return;
234
+ const mids = [...books.values()].map((b) => {
235
+ let bb = -Infinity, ba = Infinity;
236
+ for (const [p] of b.bids) if (p > bb) bb = p;
237
+ for (const [p] of b.asks) if (p < ba) ba = p;
238
+ return (bb + ba) / 2;
239
+ }).sort((a, b) => a - b);
240
+ const mid = median(mids);
241
+ const p0 = Math.floor((mid * (1 - DEPTH_SPAN)) / DEPTH_STEP) * DEPTH_STEP;
242
+ const n = Math.ceil((mid * 2 * DEPTH_SPAN) / DEPTH_STEP) + 1;
243
+ const snap = { at: this.now(), mid, p0, n, ex: {} };
244
+ for (const [id, bk] of books) snap.ex[id] = depthOf(bk, p0, n);
245
+ this.depthHist.push(snap);
246
+ const keep = snap.at - 3700e3;
247
+ while (this.depthHist.length && this.depthHist[0].at < keep) this.depthHist.shift();
248
+ }
249
+
250
+ // The latest depth, and the snapshot `ago` seconds before it (realigned onto the latest
251
+ // grid, which follows the price) for the dashed line and the change bars.
252
+ depthView(agoSec = 600) {
253
+ const base = { ok: true, enabled: true, running: this.running, bookMs: this.cfg.bookMs };
254
+ const cur = this.depthHist.at(-1);
255
+ if (!cur) return { ...base, warming: true, exchanges: this.exchanges.map((ex) => ({ id: ex.id, name: ex.name, error: this.rows.get(ex.id).bookError?.message ?? null })) };
256
+ const ago = DEPTH_AGOS.includes(agoSec) ? agoSec : 600;
257
+ const want = cur.at - ago * 1000 + 5000; // a poll's jitter either way
258
+ let then = null;
259
+ for (let i = this.depthHist.length - 2; i >= 0; i--) if (this.depthHist[i].at <= want) { then = this.depthHist[i]; break; }
260
+ const shift = (arr) => { const k = Math.round((then.p0 - cur.p0) / DEPTH_STEP); return Array.from({ length: cur.n }, (_, i) => arr[i - k] ?? null); };
261
+ return {
262
+ ...base, at: cur.at, thenAt: then?.at ?? null, firstAt: this.depthHist[0].at, ago,
263
+ mid: cur.mid, p0: cur.p0, n: cur.n, step: DEPTH_STEP,
264
+ exchanges: this.exchanges.map((ex) => {
265
+ const c = cur.ex[ex.id], t = then?.ex[ex.id], r = this.rows.get(ex.id);
266
+ return {
267
+ id: ex.id, name: ex.name, quote: ex.quote,
268
+ bids: c?.bids ?? null, asks: c?.asks ?? null,
269
+ thenBids: t ? shift(t.bids) : null, thenAsks: t ? shift(t.asks) : null,
270
+ error: r.bookError?.message ?? null, bookAt: r.bookAt ?? null,
271
+ };
272
+ }),
273
+ };
274
+ }
275
+
276
+ // A PRICE FOR THE EXPLORER'S DOLLAR FIGURES without starting the feed: the median of fresh
277
+ // tickers when the Markets tab has them; otherwise one Coinbase and one Kraken ticker read,
278
+ // cached a minute (two requests a minute at most while someone browses the explorer).
279
+ async spot() {
280
+ const v = this.view();
281
+ if (v.summary.median != null) return { usd: v.summary.median, at: this.now(), source: 'markets' };
282
+ if (this.spotCache && this.now() - this.spotCache.at < 60_000) return this.spotCache;
283
+ if (this.spotBusy) return this.spotBusy;
284
+ this.spotBusy = (async () => {
285
+ const picks = this.exchanges.filter((e) => e.quote === 'USD').slice(0, 2);
286
+ const got = await Promise.all(picks.map(async (ex) => { try { return ex.parseTicker(await this.get(ex.tickerUrl)).last; } catch { return null; } }));
287
+ const xs = got.filter((x) => x > 0).sort((a, b) => a - b);
288
+ this.spotCache = { usd: xs.length ? median(xs) : null, at: this.now(), source: 'spot' };
289
+ return this.spotCache;
290
+ })().finally(() => { this.spotBusy = null; });
291
+ return this.spotBusy;
292
+ }
293
+
294
+ view() {
295
+ const now = this.now();
296
+ const day = now - 24 * 3600e3;
297
+ const exchanges = this.exchanges.map((ex) => {
298
+ const r = this.rows.get(ex.id);
299
+ const t = r.ticker;
300
+ const lastCandle = r.candles.at(-1) ?? null;
301
+ const last = t?.last ?? lastCandle?.c ?? null;
302
+ const inDay = r.candles.filter((k) => k.t >= day);
303
+ const ref = inDay[0]?.o ?? null; // the price as the first hour inside the last 24 h opened
304
+ const highs = inDay.map((k) => k.h).filter((v) => v != null);
305
+ const lows = inDay.map((k) => k.l).filter((v) => v != null);
306
+ return {
307
+ id: ex.id, name: ex.name, pair: ex.pair, quote: ex.quote,
308
+ last, bid: t?.bid ?? null, ask: t?.ask ?? null,
309
+ spread: t?.bid != null && t?.ask != null ? t.ask - t.bid : null,
310
+ vol24: t?.vol24 ?? null,
311
+ change24: last != null && ref ? (last - ref) / ref : null,
312
+ high24: highs.length ? Math.max(...highs, last ?? -Infinity) : null,
313
+ low24: lows.length ? Math.min(...lows, last ?? Infinity) : null,
314
+ at: t?.at ?? null,
315
+ stale: !t || now - t.at > 3 * this.cfg.tickerMs,
316
+ error: r.tickerError?.message ?? r.candleError?.message ?? null,
317
+ candles: r.candles, candlesAt: r.candlesAt,
318
+ };
319
+ });
320
+ const usd = exchanges.filter((e) => e.quote === 'USD' && e.last != null && !e.stale).map((e) => e.last).sort((a, b) => a - b);
321
+ return {
322
+ ok: true, enabled: true, at: now, running: this.running, interval: '1h', tickerMs: this.cfg.tickerMs,
323
+ warming: exchanges.every((e) => e.last == null),
324
+ summary: {
325
+ median: median(usd),
326
+ spread: usd.length > 1 ? usd.at(-1) - usd[0] : null,
327
+ vol24: exchanges.reduce((a, e) => a + (e.vol24 ?? 0), 0) || null,
328
+ reporting: usd.length,
329
+ },
330
+ exchanges,
331
+ };
332
+ }
333
+ }