blockyard 0.0.9 → 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.
- package/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/scripts/donate-qr.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# Regenerate the donation QR
|
|
3
|
-
# with OpenCV's encoder, and check the modules written decode back to the address.
|
|
4
|
-
# the address changes; the
|
|
2
|
+
# Regenerate the donation QR (public/donate-qr.png, 4 px a module with the 4-module quiet zone) from
|
|
3
|
+
# the address below, with OpenCV's encoder, and check the modules written decode back to the address.
|
|
4
|
+
# Run by hand when the address changes; the PNG is committed, so nothing is generated at run time or
|
|
5
|
+
# in the browser. It was an inline SVG until 2026-09-15, when Safari painted its ground and none of
|
|
6
|
+
# its module rects; a bitmap is painted the same everywhere.
|
|
5
7
|
import cv2, numpy as np, re, sys
|
|
6
8
|
ADDR = 'bc1q249cv27lc2q7y0x53vkczgfvvgsjzhwxwv42gc'
|
|
7
9
|
img = cv2.QRCodeEncoder.create().encode(ADDR)
|
|
@@ -12,9 +14,10 @@ for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
|
|
|
12
14
|
x, y = int(mm.group(1)), int(mm.group(2)); m[y*8:(y+1)*8, x*8:(x+1)*8] = 0
|
|
13
15
|
data, _, _ = cv2.QRCodeDetector().detectAndDecode(m)
|
|
14
16
|
if data != ADDR: sys.exit(f'the QR does not decode to the address: {data!r}')
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
png = np.full((size * 4, size * 4), 255, np.uint8)
|
|
18
|
+
for mm in re.finditer(r'M(\d+) (\d+)h1v1h-1z', d):
|
|
19
|
+
x, y = int(mm.group(1)), int(mm.group(2)); png[y*4:(y+1)*4, x*4:(x+1)*4] = 0
|
|
20
|
+
cv2.imwrite('public/donate-qr.png', png)
|
|
21
|
+
check, _, _ = cv2.QRCodeDetector().detectAndDecode(cv2.imread('public/donate-qr.png'))
|
|
22
|
+
if check != ADDR: sys.exit(f'the PNG does not decode to the address: {check!r}')
|
|
23
|
+
print(f'{n}x{n} modules, public/donate-qr.png decodes to {check}')
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// How fast the DOS Diversions' emulated PC runs on this machine, headless (docs/MEASUREMENTS.md §32).
|
|
2
|
+
//
|
|
3
|
+
// Boots a game from games/ on a clock that counts instructions, lets its title and demos run, and
|
|
4
|
+
// reports instructions a second of wall time and the screens it drew. No browser: the same x86.js
|
|
5
|
+
// and dospc.js the worker runs, on Node's V8.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/dos-bench.js [doom|quake] [millions of instructions]
|
|
8
|
+
//
|
|
9
|
+
// DOOM runs at 30 million instructions to the virtual second (a fast 486); Quake at 70 million and
|
|
10
|
+
// with `+timedemo demo1`, so its screens drawn over the virtual time are the frame rate its own
|
|
11
|
+
// benchmark reports on a machine exactly as fast as this emulator.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createPC } from '../public/js/dospc.js';
|
|
16
|
+
import { createSoundCard } from '../public/js/soundcard.js';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const game = process.argv[2] === 'quake' ? 'quake' : 'doom';
|
|
20
|
+
const SPEC = {
|
|
21
|
+
doom: { dir: 'doom_dos', exe: 'DOOM.EXE', args: '', ips: 30e6, total: 400 },
|
|
22
|
+
quake: { dir: 'quake_dos', exe: 'QUAKE.EXE', args: '-nocdaudio +timedemo demo1', ips: 70e6, total: 3000 },
|
|
23
|
+
}[game];
|
|
24
|
+
const DIR = path.join(ROOT, 'games', SPEC.dir);
|
|
25
|
+
const total = Number(process.argv[3] ?? SPEC.total) * 1e6;
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(path.join(DIR, SPEC.exe))) {
|
|
28
|
+
console.error(`no ${path.join(DIR, SPEC.exe)}: this measures the shareware ${game}, which is not here`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const files = {};
|
|
32
|
+
const walk = (dir, pre) => {
|
|
33
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
if (e.name.startsWith('.')) continue;
|
|
35
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), `${pre}${e.name}/`);
|
|
36
|
+
else files[`${pre}${e.name}`] = new Uint8Array(fs.readFileSync(path.join(dir, e.name)));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(DIR, '');
|
|
40
|
+
|
|
41
|
+
let pc;
|
|
42
|
+
const now = () => (pc ? pc.cpu.cycles : 0) / SPEC.ips * 1000;
|
|
43
|
+
pc = createPC({ files, now, args: SPEC.args, sound: (mem) => createSoundCard({ mem, rate: 44100 }) });
|
|
44
|
+
pc.boot(files[SPEC.exe]);
|
|
45
|
+
|
|
46
|
+
const t0 = process.hrtime.bigint();
|
|
47
|
+
let done = 0, graphicsAt = null;
|
|
48
|
+
while (done < total && !pc.exited) {
|
|
49
|
+
done += pc.run(1e6);
|
|
50
|
+
if (graphicsAt === null && pc.vga.mode === 0x13) graphicsAt = done;
|
|
51
|
+
}
|
|
52
|
+
const secs = Number(process.hrtime.bigint() - t0) / 1e9;
|
|
53
|
+
console.log(`${game} on node ${process.version}`);
|
|
54
|
+
console.log(`${(done / 1e6).toFixed(0)} M instructions in ${secs.toFixed(2)} s: ${(done / secs / 1e6).toFixed(1)} M a second`);
|
|
55
|
+
console.log(`graphics mode after ${(graphicsAt / 1e6).toFixed(1)} M instructions`);
|
|
56
|
+
console.log(`${(pc.vga.frames / 2).toFixed(0)} pages flipped, ${(pc.vga.writes / 64000).toFixed(0)} screens' worth written, in ${(done / SPEC.ips).toFixed(1)} virtual seconds`);
|
package/scripts/setup.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import { existsSync, statSync, writeFileSync, copyFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import net from 'node:net';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import https from 'node:https';
|
|
22
24
|
import path from 'node:path';
|
|
23
25
|
import readline from 'node:readline/promises';
|
|
24
26
|
import { stdin, stdout } from 'node:process';
|
|
@@ -177,15 +179,24 @@ export const RPC_PORT = { main: 8332, test: 18332, testnet4: 48332, signet: 3833
|
|
|
177
179
|
|
|
178
180
|
/** Is a BlockYard (or anything) already answering on this port? */
|
|
179
181
|
export async function portInUse(host, port) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
// HTTPS is the default (2026-09-15) and its certificate is self-signed, so the probe tries
|
|
183
|
+
// https first without checking the chain, then plain http
|
|
184
|
+
const h = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
185
|
+
const probe = (mod, scheme) => new Promise((resolve) => {
|
|
186
|
+
const req = mod.get({ host: h, port, path: '/api/health', timeout: 1500, rejectUnauthorized: false }, (res) => {
|
|
187
|
+
let text = '';
|
|
188
|
+
res.on('data', (d) => { text += d; });
|
|
189
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(text); } catch { /* not ours */ } resolve({ ok: true, scheme, blockyard: j?.version ?? null }); });
|
|
190
|
+
});
|
|
191
|
+
req.on('timeout', () => { req.destroy(new Error('timeout')); });
|
|
192
|
+
req.on('error', (err) => resolve({ ok: false, err }));
|
|
193
|
+
});
|
|
194
|
+
const s = await probe(https, 'https');
|
|
195
|
+
if (s.ok) return { busy: true, blockyard: s.blockyard };
|
|
196
|
+
const p = await probe(http, 'http');
|
|
197
|
+
if (p.ok) return { busy: true, blockyard: p.blockyard };
|
|
198
|
+
const refused = (e) => /ECONNREFUSED/.test(e?.code ?? '') || /ECONNREFUSED/.test(e?.message ?? '') || /timeout/.test(e?.message ?? '');
|
|
199
|
+
return { busy: !(refused(s.err) && refused(p.err)), blockyard: null };
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
// ------------------------------------------------------------------------------------ the flow
|
|
@@ -298,9 +309,20 @@ async function main() {
|
|
|
298
309
|
|
|
299
310
|
// ------------------------------------------------------------------------ 3. the web interface
|
|
300
311
|
out(step(3, STEPS, 'The web interface'));
|
|
301
|
-
say(c.dim('127.0.0.1 keeps it to this machine
|
|
302
|
-
say(c.dim('reach the port
|
|
312
|
+
say(c.dim('127.0.0.1 (the default) keeps it to this machine -- reach it from elsewhere over an SSH'));
|
|
313
|
+
say(c.dim('tunnel; a LAN address, or 0.0.0.0, opens it to everyone who can reach the port. Sign-in'));
|
|
314
|
+
say(c.dim('is on either way: the first start prints the admin password once, and it serves HTTPS'));
|
|
315
|
+
say(c.dim('with a certificate it makes for itself -- expect one browser warning per address (docs/SECURITY.md).'));
|
|
303
316
|
a.host = await ask('bind address', arg('host', '127.0.0.1'), validate.host);
|
|
317
|
+
// ANYTHING BUT LOOPBACK IS SAID TWICE (operator, 2026-09-15: "hardened and on 127.0.0.1,
|
|
318
|
+
// unless the user explicitly types 0.0.0.0 in the installer"): typing it is the first time,
|
|
319
|
+
// this question the second -- except when --host named it on the command line, which is as
|
|
320
|
+
// explicit as it gets
|
|
321
|
+
if (!['127.0.0.1', '::1'].includes(a.host) && arg('host') !== a.host) {
|
|
322
|
+
const who = a.host === '0.0.0.0' ? 'everyone who can reach this machine on any interface' : `everyone who can reach ${a.host}`;
|
|
323
|
+
say(`${c.warn('!')} ${who} will get the sign-in page on port ${arg('port', '21000')}; docs/SECURITY.md before opening it further`);
|
|
324
|
+
if (!(await yes(`bind ${a.host} rather than 127.0.0.1?`, false))) { a.host = '127.0.0.1'; say(c.dim('127.0.0.1 then -- an SSH tunnel reaches it from elsewhere')); }
|
|
325
|
+
}
|
|
304
326
|
for (;;) {
|
|
305
327
|
a.port = await ask('port', arg('port', '21000'), validate.port);
|
|
306
328
|
const inUse = await portInUse(a.host, a.port);
|
|
@@ -386,7 +408,7 @@ async function main() {
|
|
|
386
408
|
else say(c.dim('nothing to build'));
|
|
387
409
|
|
|
388
410
|
// --------------------------------------------------------------------------------- done
|
|
389
|
-
const url = `
|
|
411
|
+
const url = `https://${a.host === '0.0.0.0' ? '127.0.0.1' : a.host}:${a.port}`;
|
|
390
412
|
out();
|
|
391
413
|
out(box([
|
|
392
414
|
`${c.bold('start it')} ${c.accent('npm start')}${!YES ? c.dim(' (or answer yes below)') : ''}`,
|
package/scripts/shots.mjs
CHANGED
|
@@ -129,6 +129,8 @@ for (const [name, p, settle, height = null] of [
|
|
|
129
129
|
// measures fine; this one is told what it is.
|
|
130
130
|
['kiosk', 'kiosk', 12000, 1000],
|
|
131
131
|
['explorer-home', 'explorer', 6000],
|
|
132
|
+
// the network row, the labelled pie and the year chart (2026-09-16); content-sized like the rest
|
|
133
|
+
['mining', 'mining', 12000],
|
|
132
134
|
]) {
|
|
133
135
|
if (!doing(name)) continue;
|
|
134
136
|
try { await nav(p); done.push(await shoot(name, { settle, height })); } catch (e) { fail(name, e); }
|
|
@@ -240,6 +242,10 @@ if (doing('block-space-neon')) {
|
|
|
240
242
|
await sleep(3000);
|
|
241
243
|
await evl(`document.getElementById('btnSettings')?.click()`);
|
|
242
244
|
await sleep(1200);
|
|
245
|
+
// THE BLOCK SPACE TAB FIRST (2026-09-16): the sheet opens on its first tab, Appearance since
|
|
246
|
+
// the themes arrived, and a tab's controls exist only while it is the open one.
|
|
247
|
+
await evl(`document.querySelector('[data-cfgtab="space"]')?.click()`);
|
|
248
|
+
await sleep(500);
|
|
243
249
|
// space.neon lives in the Block space group; find its control by data-cfg.
|
|
244
250
|
const on = await evl(`(() => {
|
|
245
251
|
const el=document.querySelector('[data-cfg="space.neon"]');
|
package/scripts/smoke.sh
CHANGED
|
@@ -63,7 +63,7 @@ echo "== booting server on :${PORT} (fake node :${FAKE_PORT}) =="
|
|
|
63
63
|
# signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
|
|
64
64
|
# own instance further down, on its own port, so both are asserted rather than one
|
|
65
65
|
# replacing the other.
|
|
66
|
-
BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 \
|
|
66
|
+
BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 BLOCKYARD_TLS=0 \
|
|
67
67
|
BLOCKYARD_DATA="$DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$PORT" BLOCKYARD_ADMIN_PASSWORD="$PW" \
|
|
68
68
|
BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$FAKE_PORT" node server/main.js >"$DIR/server.log" 2>&1 &
|
|
69
69
|
SRV=$!
|
package/scripts/tls.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// blockyard tls -- make (or remake) this monitor's own self-signed certificate.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/tls.js [--out DIR] [--san a,b,c] [--days N] [--force] [--print]
|
|
5
|
+
//
|
|
6
|
+
// The server does this by itself on first start (HTTPS is the default), under <data>/tls,
|
|
7
|
+
// naming the addresses it is reached on. This command is for doing it by hand: after the
|
|
8
|
+
// machine's address changed, to add a name (--san), or to start over (--force). --print writes
|
|
9
|
+
// the certificate to stdout, for pasting into a browser's or another machine's trust store.
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { ROOT } from '../server/config.js';
|
|
14
|
+
import { localAddresses } from '../server/netinfo.js';
|
|
15
|
+
import { ensureSelfSigned } from '../server/tls/selfsigned.js';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const flag = (n) => argv.includes(`--${n}`);
|
|
20
|
+
const arg = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] != null && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
|
|
21
|
+
if (flag('help') || flag('h')) {
|
|
22
|
+
process.stdout.write('usage: blockyard tls [--out DIR] [--san a,b,c] [--days N] [--force] [--print]\n');
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
const dir = arg('out', path.join(process.env.BLOCKYARD_DATA || path.join(ROOT, 'data'), 'tls'));
|
|
26
|
+
const extra = (arg('san', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
27
|
+
const sans = ['localhost', os.hostname(), '127.0.0.1', '::1', ...localAddresses().map((a) => a.address), ...extra];
|
|
28
|
+
const r = ensureSelfSigned(dir, { sans, mustName: extra, days: Number(arg('days', 825)) || 825, force: flag('force') });
|
|
29
|
+
if (flag('print')) { process.stdout.write(fs.readFileSync(r.certFile, 'utf8')); process.exit(0); }
|
|
30
|
+
const names = (new crypto.X509Certificate(fs.readFileSync(r.certFile, 'utf8')).subjectAltName ?? '').split(',').map((s) => s.trim().replace(/^(DNS|IP Address):/, ''));
|
|
31
|
+
process.stdout.write(`${r.made ? `made a new certificate (${r.why})` : 'the certificate is current; nothing written (--force to remake it)'}\n cert ${r.certFile}\n key ${r.keyFile}\n names ${names.join(', ')}\n${r.made ? ' restart BlockYard to serve it; browsers warn once per address\n' : ''}`);
|
|
@@ -42,21 +42,38 @@ async function chainHashes(rpc, tip, onProgress, pace = null) {
|
|
|
42
42
|
return { table, hashes };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
class Pool {
|
|
46
|
-
constructor(size, workerData) {
|
|
47
|
-
this.workers = Array.from({ length: size }, () => new Worker(
|
|
45
|
+
export class Pool {
|
|
46
|
+
constructor(size, workerData, script = new URL('./worker.js', import.meta.url)) {
|
|
47
|
+
this.workers = Array.from({ length: size }, () => new Worker(script, { workerData }));
|
|
48
48
|
}
|
|
49
49
|
// run jobs, at most one per worker; onResult may be async (it is awaited before that worker's next
|
|
50
50
|
// job); `pace`, if given, is awaited before each job is handed out -- the server's background build
|
|
51
51
|
// uses it to hold the workers while the node's RPC is slow, since they share its disk
|
|
52
|
+
//
|
|
53
|
+
// A WORKER THAT DIES FAILS THE RUN (2026-09-15, the first Mac install: the build sat at "scan
|
|
54
|
+
// 5,720 of 5,721, about 1 s left" for an hour and a half). Only `message` was listened for, so a
|
|
55
|
+
// worker killed outright -- out of memory is the way on a machine with four of them beside the
|
|
56
|
+
// node -- answered nothing, its job was never finished and never reported, and the other workers
|
|
57
|
+
// drained the list and left the run waiting for a reply that could not come. `exit` and `error`
|
|
58
|
+
// are the reply now: the run rejects, naming the job and the way to run again.
|
|
52
59
|
async run(jobs, onResult, pace = null) {
|
|
53
60
|
let next = 0, failed = null;
|
|
54
61
|
await Promise.all(this.workers.map((w) => new Promise((resolve) => {
|
|
62
|
+
let current = null;
|
|
63
|
+
const die = (why) => {
|
|
64
|
+
if (failed) { resolve(); return; }
|
|
65
|
+
failed = new Error(`an index worker ${why} while on ${current ? JSON.stringify(current) : 'no job'} -- if the machine ran out of memory, run again with fewer workers (addressIndexWorkers in config/local.json; each needs about 2.5 GB)`);
|
|
66
|
+
resolve();
|
|
67
|
+
};
|
|
68
|
+
w.on('exit', (code) => { if (current) die(`exited with code ${code}`); });
|
|
69
|
+
w.on('error', (err) => die(`threw: ${err?.message ?? err}`));
|
|
55
70
|
const go = async () => {
|
|
56
|
-
if (failed || next >= jobs.length) { resolve(); return; }
|
|
71
|
+
if (failed || next >= jobs.length) { current = null; resolve(); return; }
|
|
57
72
|
const job = jobs[next++];
|
|
58
73
|
if (pace) { try { await pace(); } catch (err) { failed = err; resolve(); return; } }
|
|
74
|
+
current = job;
|
|
59
75
|
w.once('message', async (msg) => {
|
|
76
|
+
current = null;
|
|
60
77
|
if (msg.type === 'error') { failed = new Error(`${JSON.stringify(msg.job)}: ${msg.message}`); resolve(); return; }
|
|
61
78
|
try { await onResult(msg); } catch (err) { failed = err; resolve(); return; }
|
|
62
79
|
go();
|
|
@@ -21,6 +21,7 @@ import { CounterRate } from '../store/ring.js';
|
|
|
21
21
|
import { computeSync, stripFacts } from './sync.js';
|
|
22
22
|
import { SHAPES, RULE_TO_SHAPE } from './logparse.js';
|
|
23
23
|
import { decodeCoinbase, minerRow, ledgerApply, ledgerRows, aliasFor, matchPool } from './mining.js';
|
|
24
|
+
import { NetworkStats } from './network.js';
|
|
24
25
|
import { summarizeTemplate, packagesFromTemplate, blockEconomy, templateCells } from './nextblock.js';
|
|
25
26
|
import { templateFromMempool, LOCAL_TEMPLATE_NOTE } from './gbt.js';
|
|
26
27
|
import fs from 'node:fs';
|
|
@@ -93,6 +94,10 @@ export class NodeMonitor extends EventEmitter {
|
|
|
93
94
|
};
|
|
94
95
|
this.miningQueue = [];
|
|
95
96
|
this.miningBusy = false;
|
|
97
|
+
// THE NETWORK OVER A WEEK AND A YEAR (2026-09-15; network.js): rewards, the difficulty
|
|
98
|
+
// period, hashrate samples, a week of pool shares -- refreshed from the mid tier
|
|
99
|
+
// (a thin handle on this.rpc -- the constructor's `rpc` argument is the lane TIMING block, not the client)
|
|
100
|
+
this.network = new NetworkStats({ rpc: { batch: (calls, opts) => this.rpc.batch(calls, opts) }, log: this.log, poolMap: () => this.mining.poolMap, aliases: () => this.mining.aliases });
|
|
96
101
|
// THE BLOCK BEING BUILT, assembled here from the mempool (2026-09-13; operator, on how
|
|
97
102
|
// mempool.space manages this against a base Core install: "do it"). It used to be one
|
|
98
103
|
// getblocktemplate call costing this node 1.3-1.5 s of its single RPC thread and 1.79 MB,
|
|
@@ -515,6 +520,9 @@ export class NodeMonitor extends EventEmitter {
|
|
|
515
520
|
this.state.peers.connections = ni.connections;
|
|
516
521
|
}
|
|
517
522
|
this.state.mining = unwrap(res[1]);
|
|
523
|
+
// ...not in the first half minute: the boot's own backfills have the lane, and the network
|
|
524
|
+
// row's first gathering (144 block stats, 400 headers) can wait for the live polls to settle
|
|
525
|
+
if (this.miningCfg.enabled && this.state.chainInfo && Date.now() - this.state.startedAt > 30_000) this.network.refresh(this.state.chainInfo).catch(() => {});
|
|
518
526
|
const tips = unwrap(res[2]);
|
|
519
527
|
if (Array.isArray(tips)) {
|
|
520
528
|
this.state.tips = tips;
|
|
@@ -561,8 +569,27 @@ export class NodeMonitor extends EventEmitter {
|
|
|
561
569
|
let raw = null;
|
|
562
570
|
try {
|
|
563
571
|
raw = await this.rpc.call('getrawmempool', [true], { heavy: true, key: `${this.id}:pool-verbose`, priority: 6 });
|
|
572
|
+
// A STREAK OF STALE DROPS IS ONE STORY, NOT A HUNDRED (2026-09-15: on a day the node answered
|
|
573
|
+
// slowly for thirteen hours, this poll -- lowest priority, so last to the lane -- was dropped
|
|
574
|
+
// as stale every four minutes and each drop was its own warn event: 188 of the feed's 200
|
|
575
|
+
// rows, everything else pushed out). A drop now opens a quality flag that counts, one event
|
|
576
|
+
// marks the streak's start, and one marks its end with the count and the span.
|
|
577
|
+
if (this.poolDrops?.n) {
|
|
578
|
+
const d = this.poolDrops;
|
|
579
|
+
this.addEvent({ kind: 'collector_recovered', severity: 'info', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose answers again: dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} over ${Math.round((Date.now() - d.since) / 60000)} min (longest wait ${Math.round(d.maxWait / 1000)}s) -- the node's RPC was too slow for the lowest-priority poll to get a turn` });
|
|
580
|
+
this.clearQuality('pool-poll-dropped');
|
|
581
|
+
this.poolDrops = null;
|
|
582
|
+
}
|
|
564
583
|
} catch (err) {
|
|
565
|
-
|
|
584
|
+
if (err?.kind === 'stale' && /dropped: waited/.test(err.message)) {
|
|
585
|
+
const waited = Number((/waited (\d+)ms/.exec(err.message) ?? [])[1] ?? 0);
|
|
586
|
+
const d = (this.poolDrops ??= { n: 0, since: Date.now(), maxWait: 0 });
|
|
587
|
+
d.n += 1; d.maxWait = Math.max(d.maxWait, waited);
|
|
588
|
+
if (d.n === 1) this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose dropped as stale (waited ${Math.round(waited / 1000)}s for the lane): the node's RPC is slow and this poll is the last in line; further drops are counted on the Node & RPC page until it answers again` });
|
|
589
|
+
this.flagQuality('pool-poll-dropped', `the full-pool poll (getrawmempool verbose) has been dropped as stale ${d.n} time${d.n === 1 ? '' : 's'} since ${new Date(d.since).toISOString().slice(11, 16)} UTC (longest wait ${Math.round(d.maxWait / 1000)}s): the node's RPC is answering slowly and this lowest-priority poll waits behind the live ones; the mempool panels show their last reading meanwhile`, 'warn');
|
|
590
|
+
} else {
|
|
591
|
+
this.addEvent({ kind: 'collector_error', severity: 'warn', tag: 'collector', ts: Date.now(), text: `getrawmempool verbose failed: ${err.message}` });
|
|
592
|
+
}
|
|
566
593
|
}
|
|
567
594
|
if (raw && typeof raw === 'object') {
|
|
568
595
|
this.state.mempoolDist = summarizeMempool(raw);
|
|
@@ -1996,6 +2023,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
1996
2023
|
uploadMeasured: s.net.totalSent != null && s.net.totalSent > 0 && s.net.outBps != null,
|
|
1997
2024
|
},
|
|
1998
2025
|
attribution: this.miningView(),
|
|
2026
|
+
network: this.network.view(),
|
|
1999
2027
|
blocks: {
|
|
2000
2028
|
count: blocks.length,
|
|
2001
2029
|
// 40 in the live frame; /api/blocks?limit= serves up to 400 for the chart.
|
|
@@ -2185,6 +2213,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
2185
2213
|
|
|
2186
2214
|
async stop() {
|
|
2187
2215
|
this.stopped = true;
|
|
2216
|
+
this.network?.stop();
|
|
2188
2217
|
for (const t of this.tierTimers.values()) clearTimeout(t);
|
|
2189
2218
|
if (this.logHealthTimer) clearInterval(this.logHealthTimer);
|
|
2190
2219
|
if (this.tail) await this.tail.stop();
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// THE NETWORK OVER A WEEK AND A YEAR, from this node alone (operator, 2026-09-15, with a
|
|
2
|
+
// mempool.space screenshot of its mining dashboard: "Why don't we have this view in our mining
|
|
3
|
+
// tab?" -- "Do it", then the adjustments table: "add this too"). Nothing here is fetched from
|
|
4
|
+
// anyone but the node:
|
|
5
|
+
//
|
|
6
|
+
// rewards the last 144 blocks' subsidy, fees and transaction counts -- getblockstats, one
|
|
7
|
+
// call per block, batched, rolled forward a block at a time
|
|
8
|
+
// epochs the start of each difficulty period back a year: its height, time and difficulty
|
|
9
|
+
// (getblockhash + getblockheader, two in-memory lookups per epoch) -- the
|
|
10
|
+
// adjustment table, the estimate for the current period, the chart's steps
|
|
11
|
+
// samples a block every 144 back a year, with its time and difficulty: the daily hashrate
|
|
12
|
+
// estimate is difficulty * 2^32 / the mean interval across the day
|
|
13
|
+
// pools the coinbase of every block of the last week, attributed the way the ledger
|
|
14
|
+
// does it (decodeCoinbase + the curated pool map) -- a week is ~1000 blocks at
|
|
15
|
+
// two calls each, so it fills in the background at eight blocks every few seconds
|
|
16
|
+
// and the view says how far it has got
|
|
17
|
+
//
|
|
18
|
+
// The arithmetic is in pure functions above the class, which is what the tests hold.
|
|
19
|
+
import { decodeCoinbase, matchPool, aliasFor, tagFingerprint } from './mining.js';
|
|
20
|
+
|
|
21
|
+
export const EPOCH = 2016;
|
|
22
|
+
export const TARGET_SPACING = 600;
|
|
23
|
+
export const HALVING_INTERVAL = 210_000;
|
|
24
|
+
const DAY_BLOCKS = 144;
|
|
25
|
+
|
|
26
|
+
/** Sum a window of getblockstats rows into the reward figures. Satoshis in, satoshis out. */
|
|
27
|
+
export function rewardStats(rows) {
|
|
28
|
+
const r = rows.filter((x) => x && Number.isFinite(x.totalfee) && Number.isFinite(x.subsidy));
|
|
29
|
+
if (!r.length) return { blocks: 0, minersRewardSat: 0, avgBlockFeeSat: null, avgTxFeeSat: null, txs: 0, from: null, to: null };
|
|
30
|
+
const fees = r.reduce((a, x) => a + x.totalfee, 0);
|
|
31
|
+
const subsidy = r.reduce((a, x) => a + x.subsidy, 0);
|
|
32
|
+
const txs = r.reduce((a, x) => a + Math.max(0, (x.txs ?? 1) - 1), 0); // the coinbase pays no fee
|
|
33
|
+
const hs = r.map((x) => x.height);
|
|
34
|
+
return {
|
|
35
|
+
blocks: r.length,
|
|
36
|
+
minersRewardSat: fees + subsidy,
|
|
37
|
+
avgBlockFeeSat: Math.round(fees / r.length),
|
|
38
|
+
avgTxFeeSat: txs ? Math.round(fees / txs) : null,
|
|
39
|
+
txs,
|
|
40
|
+
from: Math.min(...hs), to: Math.max(...hs),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where the current difficulty period stands and what the next adjustment looks like, from the
|
|
46
|
+
* tip's height and time, the period's first block time, and the two difficulties.
|
|
47
|
+
* The estimate is what Core would compute if the rest of the period kept the pace so far,
|
|
48
|
+
* clamped to the protocol's factor-of-four bounds. Before ten blocks there is no pace to speak
|
|
49
|
+
* of, so the estimate is null.
|
|
50
|
+
*/
|
|
51
|
+
export function difficultyEstimate({ height, tipTime, epochStartTime, difficulty, prevDifficulty = null, now = Date.now() }) {
|
|
52
|
+
if (!Number.isFinite(height) || !Number.isFinite(tipTime) || !Number.isFinite(epochStartTime)) return null;
|
|
53
|
+
const epochStart = height - (height % EPOCH);
|
|
54
|
+
const into = height - epochStart; // blocks mined since the period's first
|
|
55
|
+
const remaining = EPOCH - into;
|
|
56
|
+
const elapsed = Math.max(1, tipTime - epochStartTime); // seconds over `into` intervals
|
|
57
|
+
const pace = into >= 10 ? elapsed / into : null;
|
|
58
|
+
let estimatePct = null;
|
|
59
|
+
if (pace != null) {
|
|
60
|
+
const projected = pace * EPOCH;
|
|
61
|
+
const factor = Math.max(0.25, Math.min(4, (EPOCH * TARGET_SPACING) / projected));
|
|
62
|
+
estimatePct = (factor - 1) * 100;
|
|
63
|
+
}
|
|
64
|
+
const previousPct = Number.isFinite(prevDifficulty) && prevDifficulty > 0 && Number.isFinite(difficulty) ? (difficulty / prevDifficulty - 1) * 100 : null;
|
|
65
|
+
const etaSec = remaining * (pace ?? TARGET_SPACING);
|
|
66
|
+
return { epochStart, into, remaining, elapsedSec: elapsed, paceSec: pace, estimatePct, previousPct, etaSec, at: now + etaSec * 1000 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The next halving: its height, the blocks to go, and when at the target spacing. */
|
|
70
|
+
export function halvingInfo(height, { now = Date.now() } = {}) {
|
|
71
|
+
if (!Number.isFinite(height)) return null;
|
|
72
|
+
const nextHeight = (Math.floor(height / HALVING_INTERVAL) + 1) * HALVING_INTERVAL;
|
|
73
|
+
const blocksLeft = nextHeight - height;
|
|
74
|
+
const etaSec = blocksLeft * TARGET_SPACING;
|
|
75
|
+
return { nextHeight, blocksLeft, etaSec, at: now + etaSec * 1000, era: Math.floor(height / HALVING_INTERVAL) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hashrate estimates from block samples ({ height, time, difficulty }), any spacing: between two
|
|
80
|
+
* consecutive samples the mean interval is (t1 - t0) / (h1 - h0), and the hashrate that produces
|
|
81
|
+
* one block per that interval at the later sample's difficulty is difficulty * 2^32 / interval.
|
|
82
|
+
* Ascending by height; a sample with a non-positive interval (clock skew) is skipped.
|
|
83
|
+
*/
|
|
84
|
+
export function hashrateSeries(samples) {
|
|
85
|
+
const s = [...samples].filter((x) => x && Number.isFinite(x.height) && Number.isFinite(x.time) && Number.isFinite(x.difficulty)).sort((a, b) => a.height - b.height);
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let i = 1; i < s.length; i++) {
|
|
88
|
+
const dh = s[i].height - s[i - 1].height, dt = s[i].time - s[i - 1].time;
|
|
89
|
+
if (dh <= 0 || dt <= 0) continue;
|
|
90
|
+
const interval = dt / dh;
|
|
91
|
+
out.push({ t: s[i].time * 1000, height: s[i].height, hashrate: s[i].difficulty * 4294967296 / interval, difficulty: s[i].difficulty });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The adjustment table: each epoch start against the one before it. Newest first. */
|
|
97
|
+
export function adjustments(epochs) {
|
|
98
|
+
const e = [...epochs].filter((x) => x && Number.isFinite(x.height) && Number.isFinite(x.difficulty)).sort((a, b) => a.height - b.height);
|
|
99
|
+
const out = [];
|
|
100
|
+
for (let i = 1; i < e.length; i++) {
|
|
101
|
+
out.push({ height: e[i].height, time: e[i].time, difficulty: e[i].difficulty, changePct: e[i - 1].difficulty > 0 ? (e[i].difficulty / e[i - 1].difficulty - 1) * 100 : null });
|
|
102
|
+
}
|
|
103
|
+
return out.reverse();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pool shares over the window: blocks inside it grouped by pool, share of the window, and the
|
|
108
|
+
* window's luck -- the blocks found against the number the target spacing would give.
|
|
109
|
+
* rows: { height, time, poolKey, name, labelled }.
|
|
110
|
+
*/
|
|
111
|
+
export function poolShares(rows, { now = Date.now(), windowSec = 7 * 86_400 } = {}) {
|
|
112
|
+
const since = now / 1000 - windowSec;
|
|
113
|
+
const inWin = rows.filter((r) => r && Number.isFinite(r.time) && r.time >= since);
|
|
114
|
+
const by = new Map();
|
|
115
|
+
for (const r of inWin) {
|
|
116
|
+
const k = r.poolKey ?? 'unknown';
|
|
117
|
+
const e = by.get(k) ?? { key: k, name: r.name ?? k, labelled: !!r.labelled, blocks: 0 };
|
|
118
|
+
e.blocks += 1;
|
|
119
|
+
by.set(k, e);
|
|
120
|
+
}
|
|
121
|
+
const pools = [...by.values()].sort((a, b) => b.blocks - a.blocks || a.name.localeCompare(b.name));
|
|
122
|
+
for (const p of pools) p.sharePct = inWin.length ? (p.blocks / inWin.length) * 100 : 0;
|
|
123
|
+
// luck against the span actually covered: while the week is still being read, the oldest
|
|
124
|
+
// block read bounds it, so a half-read week is not reported as half the luck
|
|
125
|
+
const oldest = inWin.length ? Math.min(...inWin.map((r) => r.time)) : null;
|
|
126
|
+
const spanSec = oldest == null ? windowSec : Math.min(windowSec, Math.max(TARGET_SPACING, now / 1000 - oldest));
|
|
127
|
+
const expected = spanSec / TARGET_SPACING;
|
|
128
|
+
return { blocks: inWin.length, expected, spanSec, luckPct: inWin.length ? (inWin.length / expected) * 100 : null, pools, count: pools.length, windowSec };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------- the collector
|
|
132
|
+
export class NetworkStats {
|
|
133
|
+
/**
|
|
134
|
+
* @param {object} o
|
|
135
|
+
* @param {{batch: Function}} o.rpc the node's RPC client (batch(calls, opts))
|
|
136
|
+
* @param {Function} [o.log]
|
|
137
|
+
* @param {Function} [o.poolMap] () => the curated coinbase map, or null
|
|
138
|
+
* @param {Function} [o.aliases] () => the operator's alias table, or null
|
|
139
|
+
* @param {Function} [o.now]
|
|
140
|
+
*/
|
|
141
|
+
constructor({ rpc, log = () => {}, poolMap = () => null, aliases = () => null, now = Date.now, windowDays = 7, sampleDays = 366, rewardBlocks = 144 } = {}) {
|
|
142
|
+
this.rpc = rpc; this.log = log; this.poolMapOf = poolMap; this.aliasesOf = aliases; this.now = now;
|
|
143
|
+
this.windowDays = windowDays; this.sampleDays = sampleDays; this.rewardBlocks = rewardBlocks;
|
|
144
|
+
this.rewards = new Map(); // height -> { height, time, totalfee, subsidy, txs }
|
|
145
|
+
this.epochs = new Map(); // epoch start height -> { height, time, difficulty }
|
|
146
|
+
this.samples = new Map(); // sample height -> { height, time, difficulty }
|
|
147
|
+
this.pools = new Map(); // height -> { height, time, poolKey, name, labelled, tagText }
|
|
148
|
+
this.tip = null; this.tipTime = null; this.difficulty = null; this.networkHashPs = null;
|
|
149
|
+
this.busy = false; this.stopped = false; this.lastError = null; this.at = null;
|
|
150
|
+
this.poolTimer = null; this.poolFilling = false; this.poolTodo = [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stop() { this.stopped = true; if (this.poolTimer) clearTimeout(this.poolTimer); }
|
|
154
|
+
|
|
155
|
+
/** Called by the monitor whenever fresh chain info lands; cheap when nothing changed. */
|
|
156
|
+
async refresh(chainInfo) {
|
|
157
|
+
if (this.stopped || this.busy || !chainInfo || !Number.isFinite(chainInfo.blocks)) return;
|
|
158
|
+
if (chainInfo.initialblockdownload === true) return;
|
|
159
|
+
const tip = chainInfo.blocks;
|
|
160
|
+
this.busy = true;
|
|
161
|
+
try {
|
|
162
|
+
const tipChanged = tip !== this.tip;
|
|
163
|
+
this.tip = tip; this.difficulty = chainInfo.difficulty ?? this.difficulty;
|
|
164
|
+
// getblockchaininfo carries the tip's time on Core 24+; older builds get one header read
|
|
165
|
+
if (Number.isFinite(chainInfo.time)) this.tipTime = chainInfo.time;
|
|
166
|
+
else if (tipChanged || this.tipTime == null) { const h = await this.headers([tip]); this.tipTime = h.get(tip)?.time ?? this.tipTime; }
|
|
167
|
+
if (tipChanged || this.networkHashPs == null) {
|
|
168
|
+
const [hr] = await this.rpc.batch([{ method: 'getnetworkhashps', params: [DAY_BLOCKS * this.windowDays] }], { priority: 6 });
|
|
169
|
+
if (hr?.ok) this.networkHashPs = hr.result;
|
|
170
|
+
}
|
|
171
|
+
await this.ensureRewards(tip);
|
|
172
|
+
await this.ensureEpochs(tip);
|
|
173
|
+
await this.ensureSamples(tip);
|
|
174
|
+
this.queuePools(tip);
|
|
175
|
+
this.at = this.now();
|
|
176
|
+
this.lastError = null;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.lastError = err?.message ?? String(err);
|
|
179
|
+
} finally {
|
|
180
|
+
this.busy = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async ensureRewards(tip) {
|
|
185
|
+
const want = [];
|
|
186
|
+
for (let h = tip; h > tip - this.rewardBlocks && h > 0; h--) if (!this.rewards.has(h)) want.push(h);
|
|
187
|
+
for (let i = 0; i < want.length; i += 12) { // twelve block reads a batch: a step the live polls can slip between
|
|
188
|
+
const chunk = want.slice(i, i + 12);
|
|
189
|
+
const res = await this.rpc.batch(chunk.map((h) => ({ method: 'getblockstats', params: [h, ['height', 'time', 'totalfee', 'subsidy', 'txs']] })), { priority: 6, heavy: true });
|
|
190
|
+
res.forEach((r, k) => { if (r?.ok && r.result) this.rewards.set(chunk[k], r.result); });
|
|
191
|
+
}
|
|
192
|
+
for (const h of [...this.rewards.keys()]) if (h <= tip - this.rewardBlocks - 10) this.rewards.delete(h);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** height -> { height, time, difficulty } for a list of heights, two batched in-memory lookups each. */
|
|
196
|
+
async headers(heights) {
|
|
197
|
+
const out = new Map();
|
|
198
|
+
for (let i = 0; i < heights.length; i += 50) {
|
|
199
|
+
const chunk = heights.slice(i, i + 50);
|
|
200
|
+
const hashes = await this.rpc.batch(chunk.map((h) => ({ method: 'getblockhash', params: [h] })), { priority: 6 });
|
|
201
|
+
const withHash = chunk.map((h, k) => ({ h, hash: hashes[k]?.ok ? hashes[k].result : null })).filter((x) => x.hash);
|
|
202
|
+
if (!withHash.length) continue;
|
|
203
|
+
const hdrs = await this.rpc.batch(withHash.map((x) => ({ method: 'getblockheader', params: [x.hash] })), { priority: 6 });
|
|
204
|
+
hdrs.forEach((r, k) => { if (r?.ok && r.result) out.set(withHash[k].h, { height: withHash[k].h, time: r.result.time, difficulty: r.result.difficulty }); });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async ensureEpochs(tip) {
|
|
210
|
+
const epochStart = tip - (tip % EPOCH);
|
|
211
|
+
const perYear = Math.ceil((this.sampleDays * DAY_BLOCKS) / EPOCH) + 1;
|
|
212
|
+
const want = [];
|
|
213
|
+
for (let i = 0; i <= perYear; i++) { const h = epochStart - i * EPOCH; if (h >= 0 && !this.epochs.has(h)) want.push(h); }
|
|
214
|
+
// the block before the current period too, for the previous adjustment's base difficulty
|
|
215
|
+
if (epochStart > 0 && !this.epochs.has(epochStart - 1)) want.push(epochStart - 1);
|
|
216
|
+
if (!want.length) return;
|
|
217
|
+
for (const [h, v] of await this.headers(want)) this.epochs.set(h, v);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async ensureSamples(tip) {
|
|
221
|
+
const anchor = tip - (tip % DAY_BLOCKS);
|
|
222
|
+
const want = [];
|
|
223
|
+
for (let i = 0; i <= this.sampleDays; i++) { const h = anchor - i * DAY_BLOCKS; if (h >= 0 && !this.samples.has(h)) want.push(h); }
|
|
224
|
+
if (want.length) for (const [h, v] of await this.headers(want)) this.samples.set(h, v);
|
|
225
|
+
for (const h of [...this.samples.keys()]) if (h < anchor - (this.sampleDays + 2) * DAY_BLOCKS) this.samples.delete(h);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- the week of coinbases, in the background
|
|
229
|
+
queuePools(tip) {
|
|
230
|
+
const floor = Math.max(1, tip - Math.ceil(this.windowDays * DAY_BLOCKS * 1.15)); // a little past a week, then the view trims by time
|
|
231
|
+
const todo = [];
|
|
232
|
+
for (let h = tip; h >= floor; h--) if (!this.pools.has(h)) todo.push(h);
|
|
233
|
+
this.poolTodo = todo;
|
|
234
|
+
for (const h of [...this.pools.keys()]) if (h < floor - 50) this.pools.delete(h);
|
|
235
|
+
if (todo.length && !this.poolFilling) this.pumpPools();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
pumpPools() {
|
|
239
|
+
if (this.stopped) return;
|
|
240
|
+
this.poolFilling = true;
|
|
241
|
+
const chunk = this.poolTodo.splice(0, 8);
|
|
242
|
+
if (!chunk.length) { this.poolFilling = false; return; }
|
|
243
|
+
this.fetchPools(chunk)
|
|
244
|
+
.catch((err) => { this.lastError = `pools: ${err?.message ?? err}`; this.poolTodo.unshift(...chunk); })
|
|
245
|
+
.finally(() => {
|
|
246
|
+
if (this.stopped) return;
|
|
247
|
+
this.poolTimer = setTimeout(() => this.pumpPools(), this.lastError ? 15_000 : 3_000);
|
|
248
|
+
this.poolTimer.unref?.();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async fetchPools(heights) {
|
|
253
|
+
const hashes = await this.rpc.batch(heights.map((h) => ({ method: 'getblockhash', params: [h] })), { priority: 7 });
|
|
254
|
+
const withHash = heights.map((h, k) => ({ h, hash: hashes[k]?.ok ? hashes[k].result : null })).filter((x) => x.hash);
|
|
255
|
+
const blocks = await this.rpc.batch(withHash.map((x) => ({ method: 'getblock', params: [x.hash, 1] })), { priority: 7, heavy: true });
|
|
256
|
+
const withCb = withHash.map((x, k) => ({ ...x, block: blocks[k]?.ok ? blocks[k].result : null })).filter((x) => x.block?.tx?.length);
|
|
257
|
+
const cbs = await this.rpc.batch(withCb.map((x) => ({ method: 'getrawtransaction', params: [x.block.tx[0], 2, x.hash] })), { priority: 7, heavy: true });
|
|
258
|
+
const map = this.poolMapOf(), aliases = this.aliasesOf();
|
|
259
|
+
withCb.forEach((x, k) => {
|
|
260
|
+
const tx = cbs[k]?.ok ? cbs[k].result : null;
|
|
261
|
+
const hex = tx?.vin?.[0]?.coinbase ?? '';
|
|
262
|
+
const decoded = decodeCoinbase(hex);
|
|
263
|
+
const tagText = decoded?.tagText ?? '';
|
|
264
|
+
const matched = matchPool(map, { tagText, rawHex: hex });
|
|
265
|
+
const rawKey = decoded?.tag ? decoded.tag : tagFingerprint(tagText); // the ledger's own key rule (minerRow)
|
|
266
|
+
const key = matched ? matched.key : rawKey;
|
|
267
|
+
const name = matched ? matched.name : (aliasFor(aliases, rawKey) ?? rawKey); // the ledger shows an unlabelled pool by its key
|
|
268
|
+
this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name, labelled: !!matched, tagText });
|
|
269
|
+
});
|
|
270
|
+
this.at = this.now();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
view() {
|
|
274
|
+
const now = this.now();
|
|
275
|
+
const tip = this.tip;
|
|
276
|
+
const epochStart = tip != null ? tip - (tip % EPOCH) : null;
|
|
277
|
+
const cur = epochStart != null ? this.epochs.get(epochStart) : null;
|
|
278
|
+
const prev = epochStart != null ? this.epochs.get(epochStart - 1) : null;
|
|
279
|
+
const est = tip != null && cur ? difficultyEstimate({ height: tip, tipTime: this.tipTime, epochStartTime: cur.time, difficulty: this.difficulty, prevDifficulty: prev?.difficulty ?? null, now }) : null;
|
|
280
|
+
const series = hashrateSeries([...this.samples.values(), ...(tip != null && this.tipTime != null && this.difficulty != null ? [{ height: tip, time: this.tipTime, difficulty: this.difficulty }] : [])]);
|
|
281
|
+
const shares = poolShares([...this.pools.values()], { now, windowSec: this.windowDays * 86_400 });
|
|
282
|
+
const windowFloor = tip != null ? tip - Math.ceil(this.windowDays * DAY_BLOCKS * 1.15) : null;
|
|
283
|
+
return {
|
|
284
|
+
at: this.at, lastError: this.lastError, tip,
|
|
285
|
+
// exactly the newest 144: the map keeps a few extra rows below the window between prunes
|
|
286
|
+
rewards: rewardStats([...this.rewards.values()].sort((a, b) => b.height - a.height).slice(0, this.rewardBlocks)),
|
|
287
|
+
difficulty: this.difficulty,
|
|
288
|
+
adjustment: est,
|
|
289
|
+
halving: tip != null ? halvingInfo(tip, { now }) : null,
|
|
290
|
+
adjustments: adjustments([...this.epochs.values()].filter((e) => e.height % EPOCH === 0)).slice(0, 27), // a year and a period; the card shows six, View more all
|
|
291
|
+
hashrate: { networkHashPs: this.networkHashPs, windowBlocks: DAY_BLOCKS * this.windowDays, series },
|
|
292
|
+
pools: { ...shares, filled: this.pools.size, todo: this.poolTodo.length, filling: this.poolFilling, floor: windowFloor },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|