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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- 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/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// THE DOS WORKER: the PC runs here, off the page's thread, for the DOOM and Quake Diversions
|
|
2
|
+
// (operator, 2026-09-15: "Get DOOM working as a diversion inside blockyard with zero dependancies",
|
|
3
|
+
// then "get Quake working as a diversion").
|
|
4
|
+
//
|
|
5
|
+
// A worker because the emulated 486 wants every millisecond it can get: on the page's own thread it
|
|
6
|
+
// would share a 16 ms frame with the layout, the SSE feed and every other tab of the app, and the
|
|
7
|
+
// game would stutter whenever the monitor redrew a chart. Here it runs in ~10 ms slices and yields
|
|
8
|
+
// between them so key presses arrive; the page draws what it is sent.
|
|
9
|
+
//
|
|
10
|
+
// Messages in: boot {game, rate, controls}, run {on}, key {codes}, mouse {dx, dy, buttons}, audio {port}
|
|
11
|
+
// Messages out: status {text}, frame {pixels, palette?}, text {cells, cursor}, stats {mips},
|
|
12
|
+
// saved {name}, exit {code, cells}, error {message}
|
|
13
|
+
// (and controls {scheme}: rebind the running game's keys)
|
|
14
|
+
import { createPC } from './dospc.js';
|
|
15
|
+
import { createSoundCard } from './soundcard.js';
|
|
16
|
+
import { withControls, rebindKeys, quakeAutoexec, GAMES } from './dosio.js';
|
|
17
|
+
|
|
18
|
+
const SLICE_MS = 10;
|
|
19
|
+
const CHUNK = 50000; // instructions between looks for a finished picture
|
|
20
|
+
|
|
21
|
+
let pc = null, card = null, audioPort = null;
|
|
22
|
+
let running = false, scheduled = false;
|
|
23
|
+
let clock = 0, lastWall = 0; // machine time: wall time while running, frozen while not
|
|
24
|
+
let lastFrameSeq = -1, lastPalSeq = -1, lastText = null;
|
|
25
|
+
let pixels = new Uint8Array(64000); // bounced back by the page after each frame, to reuse
|
|
26
|
+
let lastWrites = -1, seenWrites = -1;
|
|
27
|
+
const keyEntries = {}; // where DOOM's key settings live, once found
|
|
28
|
+
const yieldChannel = new MessageChannel();
|
|
29
|
+
yieldChannel.port1.onmessage = () => { scheduled = false; loop(); };
|
|
30
|
+
|
|
31
|
+
const post = (m, transfer) => self.postMessage(m, transfer ?? []);
|
|
32
|
+
const now = () => (running ? clock + (performance.now() - lastWall) : clock);
|
|
33
|
+
|
|
34
|
+
// ------------------------------------------------------------------ saved files (IndexedDB)
|
|
35
|
+
// Savegames and the config the game writes on quit, kept in this browser. A browser that refuses
|
|
36
|
+
// IndexedDB still plays; its saves last as long as the tab.
|
|
37
|
+
function db() {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
try {
|
|
40
|
+
const req = indexedDB.open(game.db, 1);
|
|
41
|
+
req.onupgradeneeded = () => req.result.createObjectStore('files');
|
|
42
|
+
req.onsuccess = () => resolve(req.result);
|
|
43
|
+
req.onerror = () => resolve(null);
|
|
44
|
+
} catch { resolve(null); }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function loadSaved() {
|
|
48
|
+
const d = await db();
|
|
49
|
+
if (!d) return {};
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const out = {};
|
|
52
|
+
try {
|
|
53
|
+
const tx = d.transaction('files', 'readonly');
|
|
54
|
+
const cur = tx.objectStore('files').openCursor();
|
|
55
|
+
cur.onsuccess = () => {
|
|
56
|
+
const c = cur.result;
|
|
57
|
+
if (!c) { resolve(out); return; }
|
|
58
|
+
if (c.value instanceof Uint8Array) out[c.key] = c.value;
|
|
59
|
+
c.continue();
|
|
60
|
+
};
|
|
61
|
+
cur.onerror = () => resolve(out);
|
|
62
|
+
} catch { resolve(out); }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function store(name, bytes) {
|
|
66
|
+
const d = await db();
|
|
67
|
+
if (!d) return;
|
|
68
|
+
try {
|
|
69
|
+
const tx = d.transaction('files', 'readwrite');
|
|
70
|
+
if (bytes) tx.objectStore('files').put(bytes, name); else tx.objectStore('files').delete(name);
|
|
71
|
+
} catch { /* quota, private mode: the save lives for this session */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ------------------------------------------------------------------ boot
|
|
75
|
+
let game = null;
|
|
76
|
+
async function fetchFile(name, required) {
|
|
77
|
+
const r = await fetch(`/games/${game.key}/${name}`, { credentials: 'same-origin' });
|
|
78
|
+
if (!r.ok) {
|
|
79
|
+
if (required) throw new Error(`${name} is not installed: put the shareware files in ${game.dir}/ on the server (HTTP ${r.status})`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function boot({ game: key = 'doom', rate, controls }) {
|
|
86
|
+
game = { key, ...GAMES[key] };
|
|
87
|
+
if (!GAMES[key]) throw new Error(`no game called ${key}`);
|
|
88
|
+
post({ type: 'status', text: `loading ${game.label}…` });
|
|
89
|
+
const names = [game.exe, ...game.required, ...game.optional];
|
|
90
|
+
const [saved, ...got] = await Promise.all([loadSaved(), ...names.map((n, i) => fetchFile(n, i <= game.required.length))]);
|
|
91
|
+
const fetched = Object.fromEntries(names.map((n, i) => [n, got[i]]));
|
|
92
|
+
const files = {};
|
|
93
|
+
for (const n of [...game.required, ...game.optional]) if (fetched[n]) files[n] = fetched[n];
|
|
94
|
+
// the game's saves and the config it wrote on its last quit, over the shipped ones
|
|
95
|
+
const firstRun = !saved[game.config];
|
|
96
|
+
for (const [name, bytes] of Object.entries(saved)) files[name] = bytes;
|
|
97
|
+
if (key === 'doom' && files[game.config]) files[game.config] = withControls(files[game.config], controls);
|
|
98
|
+
if (key === 'quake') files['ID1/AUTOEXEC.CFG'] = quakeAutoexec({ firstRun });
|
|
99
|
+
pc = createPC({
|
|
100
|
+
files,
|
|
101
|
+
args: game.args,
|
|
102
|
+
programName: game.exe,
|
|
103
|
+
now,
|
|
104
|
+
sound: rate ? (mem) => (card = createSoundCard({ mem, rate })) : null,
|
|
105
|
+
onWrite: (name, bytes) => { store(name, bytes); post({ type: 'saved', name, deleted: !bytes }); },
|
|
106
|
+
onExit: (code) => { running = false; post({ type: 'exit', code, cells: pc.mem.slice(0xb8000, 0xb8000 + 4000) }); },
|
|
107
|
+
log: (m) => post({ type: 'log', text: m }),
|
|
108
|
+
});
|
|
109
|
+
pc.boot(fetched[game.exe]);
|
|
110
|
+
post({ type: 'status', text: 'booted' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------ the loop
|
|
114
|
+
function loop() {
|
|
115
|
+
if (!running || !pc || pc.exited) return;
|
|
116
|
+
const start = performance.now();
|
|
117
|
+
let n = 0;
|
|
118
|
+
try {
|
|
119
|
+
// in chunks of about half a millisecond, looking for a finished picture after each: at 50 frames
|
|
120
|
+
// a second two of Quake's frame copies could otherwise land in one 10 ms slice and one never be shown
|
|
121
|
+
do { n += pc.run(CHUNK); present(); } while (performance.now() - start < SLICE_MS && !pc.exited);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
running = false;
|
|
124
|
+
post({ type: 'error', message: e.message });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const spent = performance.now() - start;
|
|
128
|
+
mips = mips * 0.95 + (n / Math.max(0.001, spent)) * 0.05 * 1000 / 1e6;
|
|
129
|
+
if (card && audioPort && card.buffered > 0) {
|
|
130
|
+
const chunk = card.drain();
|
|
131
|
+
audioPort.postMessage(chunk, [chunk.buffer]);
|
|
132
|
+
}
|
|
133
|
+
if (performance.now() - lastStats > 1000) { lastStats = performance.now(); post({ type: 'stats', mips }); }
|
|
134
|
+
if (!scheduled) { scheduled = true; yieldChannel.port2.postMessage(0); }
|
|
135
|
+
}
|
|
136
|
+
let mips = 0, lastStats = 0, textTick = 0;
|
|
137
|
+
|
|
138
|
+
function present() {
|
|
139
|
+
if (pc.vga.mode === 0x13) {
|
|
140
|
+
lastText = null;
|
|
141
|
+
// a new picture: a page flipped (DOOM), the palette changed, or the linear window was written
|
|
142
|
+
// (Quake copies each finished frame into A0000h and never flips). A copy can straddle two chunks,
|
|
143
|
+
// so writes are only shown once a chunk has passed without any: never half a frame
|
|
144
|
+
const writing = pc.vga.writes !== seenWrites;
|
|
145
|
+
seenWrites = pc.vga.writes;
|
|
146
|
+
if (writing && pc.vga.frames === lastFrameSeq) return;
|
|
147
|
+
if (pc.vga.frames === lastFrameSeq && pc.vga.palSeq === lastPalSeq && pc.vga.writes === lastWrites) return;
|
|
148
|
+
if (!pixels) return; // the page still has the last frame
|
|
149
|
+
lastFrameSeq = pc.vga.frames; lastWrites = pc.vga.writes;
|
|
150
|
+
const m = { type: 'frame', pixels: pc.renderIndexed(pixels) };
|
|
151
|
+
if (pc.vga.palSeq !== lastPalSeq) { lastPalSeq = pc.vga.palSeq; m.palette = pc.vga.pal.slice(); }
|
|
152
|
+
const buf = pixels.buffer;
|
|
153
|
+
pixels = null;
|
|
154
|
+
post(m, [buf]);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
lastFrameSeq = -1; lastPalSeq = -1;
|
|
158
|
+
if (++textTick % 20 !== 0) return; // text mode changes slowly: look every ten milliseconds
|
|
159
|
+
const cells = pc.mem.subarray(0xb8000, 0xb8000 + 4000);
|
|
160
|
+
if (lastText && lastText.every((v, i) => v === cells[i])) return;
|
|
161
|
+
lastText = cells.slice();
|
|
162
|
+
post({ type: 'text', cells: lastText.slice(), cursor: [pc.text.x, pc.text.y] });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function setRunning(on) {
|
|
166
|
+
if (on === running) return;
|
|
167
|
+
if (on) { lastWall = performance.now(); running = true; loop(); }
|
|
168
|
+
else { clock = now(); running = false; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
self.onmessage = async (e) => {
|
|
172
|
+
const m = e.data;
|
|
173
|
+
try {
|
|
174
|
+
switch (m.type) {
|
|
175
|
+
case 'boot': await boot(m); setRunning(true); break;
|
|
176
|
+
case 'run': setRunning(m.on); break;
|
|
177
|
+
case 'key': if (pc) for (const c of m.codes) pc.key(c); break;
|
|
178
|
+
case 'mouse':
|
|
179
|
+
if (pc) { pc.mouse.dx += m.dx; pc.mouse.dy += m.dy; pc.mouse.buttons = m.buttons; }
|
|
180
|
+
break;
|
|
181
|
+
case 'controls':
|
|
182
|
+
if (pc) {
|
|
183
|
+
// the running game's keys now, and the config it would read if it started again
|
|
184
|
+
rebindKeys(pc.mem, m.scheme, keyEntries);
|
|
185
|
+
const cfg = pc.dir.get('DEFAULT.CFG');
|
|
186
|
+
if (cfg) pc.dir.set('DEFAULT.CFG', withControls(cfg, m.scheme));
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
case 'audio': audioPort = m.port; break;
|
|
190
|
+
case 'pixels': pixels = new Uint8Array(m.buffer); break;
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
running = false;
|
|
194
|
+
post({ type: 'error', message: err.message });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// THE EXPLORER (operator, 2026-09-11: "we need to completely rip off the mempool space block and
|
|
2
|
+
// transaction explorers. I want us to be a complete superior replacement for mempool space"; then,
|
|
3
|
+
// with our page beside theirs: "Surely we can make our display look at least as good as mempool.
|
|
4
|
+
// Do better. Make it beautiful").
|
|
5
|
+
//
|
|
6
|
+
// Our own pages over this node's RPC (server/http/explorer.js); no mempool.space code (AGPL --
|
|
7
|
+
// decided 2026-09-11), their layout ideas in our own light: a big title with the id beside it and
|
|
8
|
+
// a status pill; two panels of roomy rows; solid feature badges; dollar figures in green (a spot
|
|
9
|
+
// price the server attaches, when it has one within 1.5 s); a flow whose bands curve into one
|
|
10
|
+
// narrow glowing trunk; inputs and outputs as clean columns with arrow buttons to where the coins
|
|
11
|
+
// came from and went. Every page is a link, because the route lives in the URL:
|
|
12
|
+
// #explorer search + the latest blocks
|
|
13
|
+
// #explorer/block/<height|hash>[/<page>] header, statistics, the transactions a page at a time
|
|
14
|
+
// #explorer/tx/<txid> fee, rate, features, flow, inputs, outputs, spent-by
|
|
15
|
+
// #explorer/address/<address>[/<page>] balance, received, the transactions newest first
|
|
16
|
+
// The page renderers are pure (data in, HTML out) so the tests can hold them; renderExplorer
|
|
17
|
+
// fetches once per route and repaints only when what it would draw changed, so the once-a-second
|
|
18
|
+
// frame never resets a half-typed search. No inline styles anywhere (the CSP forbids them).
|
|
19
|
+
|
|
20
|
+
const X = { key: null, data: null, error: null, loading: false, shown: null, bound: false };
|
|
21
|
+
|
|
22
|
+
export function parseRoute(r) {
|
|
23
|
+
const [kind, id, page] = String(r ?? '').split('/').filter((x) => x !== '').map((x) => decodeURIComponent(x));
|
|
24
|
+
if ((kind === 'block' || kind === 'address') && id) return { kind, id, page: Math.max(0, Math.floor(Number(page) || 0)) };
|
|
25
|
+
if (kind === 'tx' && id) return { kind, id };
|
|
26
|
+
return { kind: 'home' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const enc = encodeURIComponent;
|
|
30
|
+
const mid = (s, a = 14, b = 8) => (String(s).length > a + b + 1 ? `${String(s).slice(0, a)}…${String(s).slice(-b)}` : String(s));
|
|
31
|
+
const blockLink = (id, label) => `<a href="#explorer/block/${enc(id)}">${label}</a>`;
|
|
32
|
+
const txLink = (fmt, txid, label = null) => `<a class="xmono" href="#explorer/tx/${enc(txid)}">${label ?? fmt.esc(fmt.hash(txid, 8))}</a>`;
|
|
33
|
+
const addrLink = (fmt, a, short = false) => `<a class="xmono xaddr" href="#explorer/address/${enc(a)}" title="${fmt.esc(a)}">${fmt.esc(short ? mid(a) : a)}</a>`;
|
|
34
|
+
const sats = (fmt, s) => (s == null ? '–' : `${fmt.num(s)} <small>sats</small>`);
|
|
35
|
+
const btcv = (fmt, s) => (s == null ? '–' : `${fmt.btc(s)} <small>BTC</small>`);
|
|
36
|
+
const usdOf = (s, usd) => (s == null || !usd ? null : (s / 1e8) * usd);
|
|
37
|
+
const dollars = (v) => (v >= 100 ? Math.round(v).toLocaleString('en-US') : v.toFixed(2));
|
|
38
|
+
const usdFmt = (v) => (v == null ? '' : `<b class="xusd">$${dollars(v)}</b>`);
|
|
39
|
+
const rate = (v) => (v == null ? '–' : `${v >= 10 ? Math.round(v) : v.toFixed(2)} <small>sat/vB</small>`);
|
|
40
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
41
|
+
const FEAT = { segwit: 'SegWit', taproot: 'Taproot', rbf: 'RBF', consolidation: 'Consolidation', op_return: 'OP_RETURN' };
|
|
42
|
+
const feats = (list) => (list ?? []).map((f) => `<span class="xf xf-${f}">${FEAT[f] ?? f}</span>`).join('');
|
|
43
|
+
const when = (fmt, t) => (t ? `${new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 16)} <small>UTC</small><span class="xago">${fmt.ago(t * 1000)}</span>` : '–');
|
|
44
|
+
const panel = (rows) => `<div class="xpanel">${rows.filter(Boolean).map(([k, v]) => `<div class="xrow"><span class="xk">${k}</span><span class="xv">${v}</span></div>`).join('')}</div>`;
|
|
45
|
+
const cards = (left, right) => `<div class="xcards">${panel(left)}${panel(right)}</div>`;
|
|
46
|
+
const ICON_COPY = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.6"/><path d="M11 3.5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h.5"/></svg>';
|
|
47
|
+
const ARROW = '<svg class="xico" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="9.5"/><path d="M5.5 10h8M10 6l4 4-4 4"/></svg>';
|
|
48
|
+
const copyBtn = (fmt, v) => `<button type="button" class="xcopy" data-copy="${fmt.esc(v)}" title="copy" aria-label="copy">${ICON_COPY}</button>`;
|
|
49
|
+
const hero = (title, sub, pill = '') => `<div class="xhero"><h1>${title}</h1>${sub}<span class="xgrow"></span>${pill}</div>`;
|
|
50
|
+
const section = (title, extra = '') => `<div class="xsect"><h2>${title}</h2>${extra}</div>`;
|
|
51
|
+
const confPill = (fmt, n) => (n > 0 ? `<span class="xpill ok">${plural(fmt.num(n), 'confirmation').replace(/^(\S+) confirmations?$/, (m) => m)}</span>` : n < 0 ? '<span class="xpill warn">Stale — off the best chain</span>' : '<span class="xpill warn">Unconfirmed</span>');
|
|
52
|
+
const pager = (base, page, pages) => (pages > 1 ? `<div class="xpager">
|
|
53
|
+
${page > 0 ? `<a href="#explorer/${base}/${page - 1}">‹ newer</a>` : '<span class="xdim">‹ newer</span>'}
|
|
54
|
+
<span class="xdim">page ${page + 1} of ${pages}</span>
|
|
55
|
+
${page + 1 < pages ? `<a href="#explorer/${base}/${page + 1}">older ›</a>` : '<span class="xdim">older ›</span>'}</div>` : '');
|
|
56
|
+
|
|
57
|
+
export function blockHtml(d, fmt) {
|
|
58
|
+
const b = d.block, st = d.stats ?? {}, usd = d.usd ?? null;
|
|
59
|
+
const pct = Array.isArray(st.feerate_percentiles) ? st.feerate_percentiles : null;
|
|
60
|
+
const prev = b.height > 0 ? `<a class="xnav" href="#explorer/block/${b.height - 1}" title="previous block">‹</a>` : '';
|
|
61
|
+
const next = d.tip != null && b.height < d.tip ? `<a class="xnav" href="#explorer/block/${b.height + 1}" title="next block">›</a>` : '';
|
|
62
|
+
const reward = st.subsidy != null && st.totalfee != null ? st.subsidy + st.totalfee : null;
|
|
63
|
+
const left = [
|
|
64
|
+
['Hash', `<span class="xid sm">${fmt.esc(mid(b.hash, 18, 18))}</span>${copyBtn(fmt, b.hash)}`],
|
|
65
|
+
['Timestamp', when(fmt, b.time)],
|
|
66
|
+
['Size', `${fmt.bytes(b.size)} <span class="xdim">· ${b.weight != null ? `${(b.weight / 1e6).toFixed(2)} MWU` : '–'}</span>`],
|
|
67
|
+
['Transactions', fmt.num(b.nTx)],
|
|
68
|
+
['Miner', fmt.esc(d.pool?.label ?? d.pool?.tag ?? '–')],
|
|
69
|
+
['Merkle root', `<span class="xmono xdim">${fmt.esc(mid(b.merkleroot ?? '–', 12, 12))}</span>`],
|
|
70
|
+
];
|
|
71
|
+
const right = [
|
|
72
|
+
['Median fee rate', pct ? rate(pct[2]) : '–'],
|
|
73
|
+
['Fee span', st.minfeerate != null ? `${st.minfeerate} – ${st.maxfeerate} <small>sat/vB</small>` : '–'],
|
|
74
|
+
['Total fees', `${btcv(fmt, st.totalfee)}${usdFmt(usdOf(st.totalfee, usd))}`],
|
|
75
|
+
['Subsidy + fees', `${btcv(fmt, reward)}${usdFmt(usdOf(reward, usd))}`],
|
|
76
|
+
['Difficulty', `${b.difficulty != null ? fmt.short(b.difficulty) : '–'} <span class="xdim">· nonce ${b.nonce ?? '–'}</span>`],
|
|
77
|
+
['Version · bits', `<span class="xmono">${fmt.esc(b.versionHex ?? String(b.version ?? '–'))}</span> <span class="xdim">· ${fmt.esc(b.bits ?? '–')}</span>`],
|
|
78
|
+
];
|
|
79
|
+
const txRows = d.txs.map((t) => (t.missing
|
|
80
|
+
? `<tr><td>${txLink(fmt, t.txid)}</td><td colspan="6" class="xdim">not decoded</td></tr>`
|
|
81
|
+
: `<tr><td>${txLink(fmt, t.txid)}</td>
|
|
82
|
+
<td class="r">${t.coinbase ? '<span class="xf xf-cb">coinbase</span>' : rate(t.feerate)}</td>
|
|
83
|
+
<td class="r">${t.fee == null ? '–' : sats(fmt, t.fee)}</td>
|
|
84
|
+
<td class="r">${fmt.num(t.vsize)} <small>vB</small></td>
|
|
85
|
+
<td class="r">${btcv(fmt, t.outSat)}</td>
|
|
86
|
+
<td class="r">${t.inCount} → ${t.outCount}</td>
|
|
87
|
+
<td>${feats(t.features)}</td></tr>`)).join('');
|
|
88
|
+
return `${hero(`Block ${prev}<span class="xh">${fmt.num(b.height)}</span>${next}`, '', confPill(fmt, b.confirmations))}
|
|
89
|
+
${cards(left, right)}
|
|
90
|
+
${section('Transactions', `<span class="xdim">${fmt.num(b.nTx)}</span>`)}
|
|
91
|
+
<div class="xtablecard"><table class="xtable"><thead><tr><th>transaction</th><th class="r">fee rate</th><th class="r">fee</th><th class="r">size</th><th class="r">value out</th><th class="r">in → out</th><th>features</th></tr></thead><tbody>${txRows}</tbody></table></div>
|
|
92
|
+
${pager(`block/${b.height}`, d.page, d.pages)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function txHtml(d, fmt) {
|
|
96
|
+
const t = d.tx, usd = d.usd ?? null;
|
|
97
|
+
const inBlock = t.confirmations > 0
|
|
98
|
+
? (t.height != null ? blockLink(t.height, fmt.num(t.height)) : blockLink(t.blockhash, fmt.esc(fmt.hash(t.blockhash, 8))))
|
|
99
|
+
: null;
|
|
100
|
+
const left = [
|
|
101
|
+
['Status', inBlock ? `Confirmed in block ${inBlock}` : 'In the mempool'],
|
|
102
|
+
['Timestamp', when(fmt, t.time)],
|
|
103
|
+
['Features', feats(t.features) || '<span class="xdim">none</span>'],
|
|
104
|
+
['Size', `${fmt.num(t.vsize)} <small>vB</small> <span class="xdim">· ${fmt.num(t.size)} B · ${fmt.num(t.weight)} WU</span>`],
|
|
105
|
+
];
|
|
106
|
+
const right = t.coinbase ? [
|
|
107
|
+
['Block reward', `${btcv(fmt, t.outSat)}${usdFmt(usdOf(t.outSat, usd))}`],
|
|
108
|
+
['Fee', '<span class="xdim">none — a coinbase pays the miner</span>'],
|
|
109
|
+
['Inputs → outputs', `${fmt.num(t.vin.length)} → ${fmt.num(t.vout.length)}`],
|
|
110
|
+
['Version · locktime', `${t.version ?? '–'} · ${t.locktime ?? 0}`],
|
|
111
|
+
] : [
|
|
112
|
+
['Fee', `${sats(fmt, t.fee)}${usdFmt(usdOf(t.fee, usd))}`],
|
|
113
|
+
['Fee rate', rate(t.feerate)],
|
|
114
|
+
['Total in → out', `${btcv(fmt, t.inSat)} <span class="xdim">→</span> ${btcv(fmt, t.outSat)}`],
|
|
115
|
+
['Version · locktime', `${t.version ?? '–'} · ${t.locktime ?? 0}`],
|
|
116
|
+
];
|
|
117
|
+
const ins = t.vin.map((v) => (v.coinbase
|
|
118
|
+
? `<div class="xitem"><span class="xico-wrap cb" title="new coins">${ARROW}</span><span class="xlabel"><span class="xf xf-cb">coinbase</span> new coins</span><span class="xamt">${btcv(fmt, t.outSat)}</span></div>`
|
|
119
|
+
: `<div class="xitem"><a class="xico-wrap in" href="#explorer/tx/${enc(v.txid)}" title="spends ${fmt.esc(v.txid)}:${v.vout}">${ARROW}</a>`
|
|
120
|
+
+ `<span class="xlabel">${v.address ? addrLink(fmt, v.address, true) : `<span class="xdim">${fmt.esc(v.type ?? 'unknown script')}</span>`}</span>`
|
|
121
|
+
+ `<span class="xamt">${btcv(fmt, v.value)}</span></div>`)).join('');
|
|
122
|
+
const shown = Math.min(t.vout.length, d.outputsShown ?? t.vout.length);
|
|
123
|
+
const outs = t.vout.slice(0, shown).map((o) => '<div class="xitem">'
|
|
124
|
+
+ `<span class="xlabel">${o.address ? addrLink(fmt, o.address, true) : `<span class="xdim">${o.type === 'nulldata' ? 'OP_RETURN · unspendable' : fmt.esc(o.type ?? '–')}</span>`}</span>`
|
|
125
|
+
+ `<span class="xamt">${btcv(fmt, o.value)}</span>`
|
|
126
|
+
+ (o.spentBy ? `<a class="xico-wrap out xspent" href="#explorer/tx/${enc(o.spentBy.txid)}" title="spent by ${fmt.esc(o.spentBy.txid)}">${ARROW}</a>`
|
|
127
|
+
: o.type === 'nulldata' ? '<span class="xico-wrap none" title="unspendable"></span>'
|
|
128
|
+
: '<span class="xico-wrap unspent" title="unspent"><i></i></span>')
|
|
129
|
+
+ '</div>').join('');
|
|
130
|
+
const totUsd = usdOf(t.outSat, usd);
|
|
131
|
+
return `${hero('Transaction', `<span class="xid">${fmt.esc(t.txid)}</span>${copyBtn(fmt, t.txid)}`, confPill(fmt, t.confirmations))}
|
|
132
|
+
${cards(left, right)}
|
|
133
|
+
${section('Flow', `<span class="xdim">${plural(t.vin.length, 'input')} → ${plural(t.vout.length, 'output')}</span>`)}
|
|
134
|
+
<div class="xflowcard">${flowSvg(t, fmt)}</div>
|
|
135
|
+
${section('Inputs & Outputs')}
|
|
136
|
+
<div class="xiocard">
|
|
137
|
+
<div class="xio2">
|
|
138
|
+
<div class="xiocol"><div class="xiohead">Inputs <span>${fmt.num(t.vin.length)}</span></div>${ins}</div>
|
|
139
|
+
<div class="xiocol"><div class="xiohead">Outputs <span>${fmt.num(t.vout.length)}</span></div>${outs}${shown < t.vout.length ? `<div class="xdim xmore">the first ${fmt.num(shown)} of ${fmt.num(t.vout.length)}</div>` : ''}</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="xtotals"><span class="xtot">${t.coinbase ? 'a coinbase: new coins' : `fee ${sats(fmt, t.fee)}`}</span><span class="xtotpill">${btcv(fmt, t.outSat)}${totUsd != null ? `<span class="xtotusd">$${dollars(totUsd)}</span>` : ''}</span></div>
|
|
142
|
+
</div>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function addressHtml(d, fmt) {
|
|
146
|
+
const bal = d.balance ?? {}, usd = d.usd ?? null;
|
|
147
|
+
const sent = bal.received != null && bal.balance != null ? bal.received - bal.balance : null;
|
|
148
|
+
// WHAT THIS NODE CANNOT ANSWER, SAID ONCE AND PLAINLY. Core has no address index -- measured on
|
|
149
|
+
// both configured nodes, getaddressbalance and getaddresstxids return "Method not found" -- so
|
|
150
|
+
// balance, history and totals have no source here. Printing the raw refusal where a balance
|
|
151
|
+
// belongs, or a count of 0 where nothing was counted, both read as "this address is unused".
|
|
152
|
+
const noIndex = d.indexed === false;
|
|
153
|
+
const unknown = '<span class="xdim">not indexed</span>';
|
|
154
|
+
// THE LOCAL INDEX (server/chain/index/) answers history and balance but keeps no UTXO list, and it
|
|
155
|
+
// covers the chain up to the block it was built at -- both said on the page, never implied away
|
|
156
|
+
const local = d.source === 'local-index';
|
|
157
|
+
const left = [
|
|
158
|
+
['Type', fmt.esc(d.type ?? '–')],
|
|
159
|
+
['Transactions', noIndex ? unknown : fmt.num(d.txCount)],
|
|
160
|
+
['Unspent outputs', bal.utxos != null ? fmt.num(bal.utxos) : local ? `<span class="xdim">${fmt.esc(d.utxoNote ?? 'not tracked')}</span>` : unknown],
|
|
161
|
+
];
|
|
162
|
+
const right = [
|
|
163
|
+
['Balance', bal.balance != null ? `${btcv(fmt, bal.balance)}${usdFmt(usdOf(bal.balance, usd))}` : unknown],
|
|
164
|
+
['Total received', bal.received != null ? btcv(fmt, bal.received) : unknown],
|
|
165
|
+
['Total sent', sent != null ? btcv(fmt, sent) : unknown],
|
|
166
|
+
];
|
|
167
|
+
const txRows = d.txs.map((t) => (t.missing
|
|
168
|
+
? `<tr><td>${txLink(fmt, t.txid)}</td><td colspan="3" class="xdim">not decoded</td></tr>`
|
|
169
|
+
: `<tr><td>${txLink(fmt, t.txid)}</td>
|
|
170
|
+
<td>${t.height != null ? blockLink(t.height, fmt.num(t.height)) : '<span class="xdim">mempool</span>'}</td>
|
|
171
|
+
<td class="r ${t.delta > 0 ? 'xpos' : t.delta < 0 ? 'xneg' : ''}">${t.delta > 0 ? '+' : ''}${fmt.btc(t.delta)} <small>BTC</small></td>
|
|
172
|
+
<td class="r">${t.coinbase ? '<span class="xf xf-cb">coinbase</span>' : rate(t.feerate)}</td></tr>`)).join('');
|
|
173
|
+
return `${hero('Address', `<span class="xid">${fmt.esc(d.address)}</span>${copyBtn(fmt, d.address)}`)}
|
|
174
|
+
${cards(left, right)}
|
|
175
|
+
${section('Transactions', '<span class="xdim">newest first</span>')}
|
|
176
|
+
${local && d.index.stale ? `<div class="caveat bad tiny"><b>The address index has stopped following the chain.</b> ${fmt.esc(d.index.stale)}</div>` : ''}
|
|
177
|
+
${local && d.index.postTip ? `<div class="caveat tiny"><b>${fmt.num(d.index.postTip)} transaction${d.index.postTip === 1 ? '' : 's'} in blocks the node no longer has</b> are left out of the figures: the chain reorganised and the index is catching up.</div>` : ''}
|
|
178
|
+
${local ? `<div class="note tiny">From the local address index, complete through block ${blockLink(d.index.tip, fmt.num(d.index.tip))}${d.index.behind ? ` — <b>${fmt.num(d.index.behind)} newer block${d.index.behind === 1 ? '' : 's'} not yet included</b>${d.index.following ? ', catching up' : ''}` : ''}. Unconfirmed transactions are not included. Received and sent add up each transaction's net effect on this address.</div>` : ''}
|
|
179
|
+
${noIndex && d.indexBuilding ? `<div class="note tiny"><b>The address index is being built</b>: ${fmt.esc(d.indexBuilding.phase ?? 'starting')}${d.indexBuilding.total ? ` ${fmt.num(d.indexBuilding.done)} of ${fmt.num(d.indexBuilding.total)} (${Math.round((100 * d.indexBuilding.done) / d.indexBuilding.total)}%)` : ''}${d.indexBuilding.rows ? `, ${fmt.num(d.indexBuilding.rows)} rows so far` : ''}${d.indexBuilding.eta ? `, about ${fmt.esc(d.indexBuilding.eta)} left` : ''}. The Overview shows its progress, and a notification says when it is done; this page fills in then.</div>` : ''}
|
|
180
|
+
${noIndex && !d.indexBuilding ? `<div class="note tiny">This node keeps <b>no address index</b>, so an address's balance and history have no source here — the address above is confirmed valid and nothing more is claimed. Bitcoin Core has never had <span class="mono">getaddressbalance</span> or <span class="mono">getaddresstxids</span>; they belong to insight-style forks. Transaction and block lookups are unaffected: those use the node's <span class="mono">txindex</span>, which is synced.</div>` : ''}
|
|
181
|
+
<div class="xtablecard"><table class="xtable"><thead><tr><th>transaction</th><th>block</th><th class="r">change</th><th class="r">fee rate</th></tr></thead><tbody>${txRows || `<tr><td colspan="4" class="xdim">${noIndex ? 'no address index on this node — history cannot be listed' : 'no transactions for this address'}</td></tr>`}</tbody></table></div>
|
|
182
|
+
${noIndex ? '' : pager(`address/${enc(d.address)}`, d.page, d.pages)}
|
|
183
|
+
${Array.isArray(d.utxos) ? `${section('Unspent outputs', `<span class="xdim">${fmt.num(d.utxos.length)} · as the node's UTXO set has them, less what the mempool already spends</span>`)}
|
|
184
|
+
<div class="xtablecard"><table class="xtable"><thead><tr><th>output</th><th>block</th><th class="r">value</th></tr></thead><tbody>${d.utxos.length
|
|
185
|
+
? d.utxos.map((u) => `<tr><td>${txLink(fmt, u.txid)}<span class="xdim">:${u.n}</span></td><td>${u.height != null ? blockLink(u.height, fmt.num(u.height)) : '<span class="xdim">–</span>'}</td><td class="r">${fmt.btc(u.value)} <small>BTC</small></td></tr>`).join('')
|
|
186
|
+
: '<tr><td colspan="3" class="xdim">nothing unspent</td></tr>'}</tbody></table></div>` : ''}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// the fee band a block's median rate falls in, for its cube's colour (no inline styles: classes)
|
|
190
|
+
export const feeBand = (r) => (r == null ? 0 : r < 2 ? 1 : r < 4 ? 2 : r < 8 ? 3 : r < 16 ? 4 : r < 32 ? 5 : r < 64 ? 6 : 7);
|
|
191
|
+
|
|
192
|
+
export function homeHtml(s, fmt) {
|
|
193
|
+
const recent = s?.blocks?.recent ?? [];
|
|
194
|
+
const pools = new Map((s?.attribution?.recent ?? []).map((r) => [r.height, r.poolLabel ?? r.tagText ?? null]));
|
|
195
|
+
const med = (b) => (Array.isArray(b.p) ? b.p[2] : b.avgFeerate ?? null);
|
|
196
|
+
const cubes = recent.slice(0, 8).map((b) => `<a class="xblk f${feeBand(med(b))}" href="#explorer/block/${b.height}">
|
|
197
|
+
<span class="xblk-h">${fmt.num(b.height)}</span>
|
|
198
|
+
<span class="xblk-face"><b>~${med(b) ?? '–'} sat/vB</b>${Array.isArray(b.p) ? `<small>${b.p[0]} – ${b.p[4]} sat/vB</small>` : '<small></small>'}<em>${b.size != null ? fmt.bytes(b.size, 1) : '–'}</em><small>${fmt.num(b.txs)} transactions</small><small>${b.time ? fmt.ago(b.time * 1000) : ''}</small></span>
|
|
199
|
+
<span class="xblk-pool">${fmt.esc(pools.get(b.height) ?? '')}</span></a>`).join('');
|
|
200
|
+
const rows = recent.slice(0, 15).map((b) => `<tr><td>${blockLink(b.height, fmt.num(b.height))}</td>
|
|
201
|
+
<td>${b.time ? fmt.ago(b.time * 1000) : '–'}</td><td>${fmt.esc(pools.get(b.height) ?? '–')}</td>
|
|
202
|
+
<td class="r">${fmt.num(b.txs)}</td><td class="r">${b.weight != null ? `${(b.weight / 1e6).toFixed(2)} <small>MWU</small>` : '–'}</td>
|
|
203
|
+
<td class="r">${b.totalfee != null ? btcv(fmt, b.totalfee) : '–'}</td></tr>`).join('');
|
|
204
|
+
return `${hero('Latest blocks', '')}
|
|
205
|
+
<div class="xblocks">${cubes}</div>
|
|
206
|
+
<div class="xtablecard"><table class="xtable"><thead><tr><th>height</th><th>mined</th><th>pool</th><th class="r">transactions</th><th class="r">weight</th><th class="r">fees</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="xdim">no blocks seen yet</td></tr>'}</tbody></table></div>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function errorHtml(d, fmt) {
|
|
210
|
+
return `<div class="caveat bad"><b>${fmt.esc(d?.error?.message ?? 'the node refused')}</b>${d?.hint ? `<div class="tiny">${fmt.esc(d.hint)}</div>` : ''}</div>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Copy an id. The monitor is served over plain http on the LAN, where navigator.clipboard does
|
|
214
|
+
// not exist (it needs a secure context), so the old select-and-copy path is kept as the fallback.
|
|
215
|
+
function copyText(text) {
|
|
216
|
+
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) return navigator.clipboard.writeText(text);
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
const ta = document.createElement('textarea');
|
|
219
|
+
ta.value = text;
|
|
220
|
+
ta.setAttribute('readonly', '');
|
|
221
|
+
ta.className = 'xclip';
|
|
222
|
+
document.body.appendChild(ta);
|
|
223
|
+
ta.select();
|
|
224
|
+
const ok = document.execCommand?.('copy');
|
|
225
|
+
ta.remove();
|
|
226
|
+
if (ok) resolve(); else reject(new Error('copy refused'));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function bindSearch(h) {
|
|
231
|
+
if (X.bound) return;
|
|
232
|
+
const form = document.getElementById('xSearch');
|
|
233
|
+
const input = document.getElementById('xQuery');
|
|
234
|
+
const body = document.getElementById('xBody');
|
|
235
|
+
if (!form || !input) return;
|
|
236
|
+
X.bound = true;
|
|
237
|
+
form.addEventListener('submit', async (e) => {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
const q = input.value.trim();
|
|
240
|
+
if (!q) return;
|
|
241
|
+
const node = h.state.node ? `&node=${enc(h.state.node)}` : '';
|
|
242
|
+
try {
|
|
243
|
+
const d = await h.api(`/api/x/search?q=${enc(q)}${node}`);
|
|
244
|
+
if (d.ok) { location.hash = `#explorer/${d.type}/${enc(d.id)}`; input.value = ''; }
|
|
245
|
+
else h.toast?.(d.error?.message ?? 'nothing found', 'bad');
|
|
246
|
+
} catch (err) { h.toast?.(err.message, 'bad'); }
|
|
247
|
+
});
|
|
248
|
+
body?.addEventListener('click', (e) => {
|
|
249
|
+
const btn = e.target.closest?.('[data-copy]');
|
|
250
|
+
if (!btn) return;
|
|
251
|
+
copyText(btn.dataset.copy)
|
|
252
|
+
.then(() => { btn.classList.add('done'); setTimeout(() => btn.classList.remove('done'), 1200); h.toast?.('copied', 'ok'); })
|
|
253
|
+
.catch(() => h.toast?.('the browser refused to copy', 'bad'));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function load(route, key, h) {
|
|
258
|
+
X.loading = true; X.key = key; X.data = null; X.error = null;
|
|
259
|
+
const node = h.state.node ? `&node=${enc(h.state.node)}` : '';
|
|
260
|
+
const url = route.kind === 'block' ? `/api/x/block?id=${enc(route.id)}&page=${route.page}${node}`
|
|
261
|
+
: route.kind === 'tx' ? `/api/x/tx?txid=${enc(route.id)}${node}`
|
|
262
|
+
: `/api/x/address?addr=${enc(route.id)}&page=${route.page}${node}`;
|
|
263
|
+
try {
|
|
264
|
+
const d = await h.api(url);
|
|
265
|
+
if (X.key !== key) return; // a newer route took over while this one was out
|
|
266
|
+
if (d.ok) X.data = d; else X.error = d;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (X.key === key) X.error = { error: { message: err.message } };
|
|
269
|
+
} finally {
|
|
270
|
+
if (X.key === key) X.loading = false;
|
|
271
|
+
h.render();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function renderExplorer(s, state, h) {
|
|
276
|
+
const body = document.getElementById('xBody');
|
|
277
|
+
if (!body) return;
|
|
278
|
+
bindSearch(h);
|
|
279
|
+
const route = parseRoute(state.xroute);
|
|
280
|
+
const key = `${route.kind}|${route.id ?? ''}|${route.page ?? 0}|${state.node ?? ''}`;
|
|
281
|
+
if (route.kind !== 'home' && X.key !== key && !(X.loading && X.key === key)) load(route, key, h);
|
|
282
|
+
const fmt = h.fmt;
|
|
283
|
+
const html = route.kind === 'home' ? homeHtml(s, fmt)
|
|
284
|
+
: X.key === key && X.data ? (route.kind === 'block' ? blockHtml(X.data, fmt) : route.kind === 'tx' ? txHtml(X.data, fmt) : addressHtml(X.data, fmt))
|
|
285
|
+
: X.key === key && X.error ? errorHtml(X.error, fmt)
|
|
286
|
+
: '<div class="note">asking the node…</div>';
|
|
287
|
+
if (html !== X.shown) { body.innerHTML = html; X.shown = html; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// THE FLOW. Where the value came from and where it went, drawn the way the eye wants it: every
|
|
291
|
+
// input a band as thick as its share of the value, running in straight from the left edge (its
|
|
292
|
+
// tail notched like an arrow's fletching), curving into ONE narrow trunk where they merge, and
|
|
293
|
+
// fanning out again to the outputs, each ending in an arrow tip. One gradient runs across the
|
|
294
|
+
// whole picture -- violet at the edges, electric blue in the trunk -- so the bands read as one
|
|
295
|
+
// current. The fee leaves the trunk as a thin gold stream that rises toward the top right and
|
|
296
|
+
// fades out; a coinbase input glows gold; an OP_RETURN output is a grey sliver. SVG rather than
|
|
297
|
+
// canvas so every band is a link (an input to the transaction that made it, an output to its
|
|
298
|
+
// address) with its own tooltip. Past FLOW_MAX bands a side folds the rest into one "N more" band.
|
|
299
|
+
// Pure: summary in, markup out.
|
|
300
|
+
export const FLOW_MAX = 24;
|
|
301
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
302
|
+
|
|
303
|
+
function flowDefs(W) {
|
|
304
|
+
const g = (id, stops, x1 = 0, x2 = W) => `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="0" x2="${x2}" y2="0">${stops.map(([o, c, a = 1]) => `<stop offset="${o}" stop-color="${c}" stop-opacity="${a}"/>`).join('')}</linearGradient>`;
|
|
305
|
+
return `<defs>${[
|
|
306
|
+
g('xg-flow', [[0, '#3b1a78', 0.25], [0.06, '#8f3cf2'], [0.3, '#6a4cf6'], [0.5, '#1d8fff'], [0.7, '#6a4cf6'], [0.94, '#9a45f5'], [1, '#b56cff']]),
|
|
307
|
+
g('xg-cb', [[0, '#6b4a00', 0.3], [0.06, '#f5b400'], [0.3, '#e0913a'], [0.5, '#1d8fff']]),
|
|
308
|
+
g('xg-nul', [[0.5, '#1d8fff'], [0.75, '#6c7686'], [1, '#8a93a1']]),
|
|
309
|
+
g('xg-more', [[0, '#39414d', 0.3], [0.08, '#6c7686'], [0.5, '#1d8fff'], [0.92, '#6c7686'], [1, '#8a93a1']]),
|
|
310
|
+
g('xg-fee', [[0, '#1d8fff'], [0.45, '#ffb13d'], [1, '#ffb13d', 0]], W / 2, W * 0.97),
|
|
311
|
+
].join('')}</defs>`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function flowSvg(t, fmt, W = 1000) {
|
|
315
|
+
const fold = (items) => (items.length <= FLOW_MAX ? items : [...items.slice(0, FLOW_MAX - 1), {
|
|
316
|
+
kind: 'more', value: items.slice(FLOW_MAX - 1).reduce((a, x) => a + (x.value ?? 0), 0),
|
|
317
|
+
title: `${fmt.num(items.length - FLOW_MAX + 1)} more`,
|
|
318
|
+
}]);
|
|
319
|
+
const left = fold(t.vin.map((v) => (v.coinbase
|
|
320
|
+
? { kind: 'cb', value: t.outSat, title: 'coinbase: the subsidy and the block\'s fees' }
|
|
321
|
+
: { kind: 'in', value: v.value, href: `#explorer/tx/${enc(v.txid)}`, title: `${v.address ?? v.type ?? 'input'} · ${fmt.btc(v.value)} BTC` })));
|
|
322
|
+
const right = fold(t.vout.map((o) => ({
|
|
323
|
+
kind: o.type === 'nulldata' ? 'nul' : 'out', value: o.value,
|
|
324
|
+
href: o.address ? `#explorer/address/${enc(o.address)}` : null,
|
|
325
|
+
title: `${o.address ?? (o.type === 'nulldata' ? 'OP_RETURN' : o.type ?? 'output')} · ${fmt.btc(o.value)} BTC`,
|
|
326
|
+
})));
|
|
327
|
+
const fee = !t.coinbase && t.fee > 0 ? { kind: 'fee', value: t.fee, title: `fee · ${fmt.num(t.fee)} sat` } : null;
|
|
328
|
+
|
|
329
|
+
const T = 150, MIN = 2, GAP = 14, PAD = 16;
|
|
330
|
+
const known = [...left, ...right].every((x) => Number.isFinite(x.value));
|
|
331
|
+
const total = known ? Math.max(1, left.reduce((a, x) => a + x.value, 0), right.reduce((a, x) => a + x.value, 0) + (fee?.value ?? 0)) : 1;
|
|
332
|
+
const thick = (x, n) => Math.max(MIN, known ? (x.value / total) * T : T / n);
|
|
333
|
+
const sum = (xs) => xs.reduce((a, x) => a + x.h, 0);
|
|
334
|
+
const L0 = left.map((x) => ({ ...x, h: thick(x, left.length) }));
|
|
335
|
+
const R0 = right.map((x) => ({ ...x, h: thick(x, right.length) }));
|
|
336
|
+
const F0 = fee ? { ...fee, h: Math.max(1.5, known ? (fee.value / total) * T : 2) } : null;
|
|
337
|
+
|
|
338
|
+
// ONE RIVER, TWO BANKS THE SAME HEIGHT (operator, 2026-09-13: "how do we fix that broken seam in
|
|
339
|
+
// the middle so it smoothly transitions").
|
|
340
|
+
//
|
|
341
|
+
// The two halves of the trunk meet at x = W/2, and each used to be centred on its OWN total --
|
|
342
|
+
// the left on sum(L), the right on sum(R) + the fee. Those totals are equal in VALUE (in = out +
|
|
343
|
+
// fee, which is what `total` above is built from) but not in PIXELS, because MIN floors every
|
|
344
|
+
// band to 2px. Measured on the reported transaction -- one fat input, three dust outputs of
|
|
345
|
+
// 0.006px each floored to 2px -- the right bank gained 5.98px of padding the left never got, the
|
|
346
|
+
// two centre lines fell 3.7px apart, and the trunk stepped at the join on both edges.
|
|
347
|
+
//
|
|
348
|
+
// The clamp has to stay: a 0.006px band is invisible, and a dust output is still an output. So
|
|
349
|
+
// the surplus is spread instead of stepped. Each side is scaled to the same trunk height, which
|
|
350
|
+
// restores the invariant the picture is meant to show -- both banks of one river are the same
|
|
351
|
+
// height -- and makes the seam flush by construction rather than by luck. The distortion is the
|
|
352
|
+
// clamp's, not this: rescaling only stops one side carrying it alone.
|
|
353
|
+
const sumL0 = sum(L0), sumR0 = sum(R0) + (F0?.h ?? 0);
|
|
354
|
+
const trunk = Math.max(sumL0, sumR0);
|
|
355
|
+
const kL = sumL0 > 0 ? trunk / sumL0 : 1, kR = sumR0 > 0 ? trunk / sumR0 : 1;
|
|
356
|
+
const L = L0.map((x) => ({ ...x, h: x.h * kL }));
|
|
357
|
+
const Rt = R0.map((x) => ({ ...x, h: x.h * kR }));
|
|
358
|
+
const F = F0 ? { ...F0, h: F0.h * kR } : null;
|
|
359
|
+
const span = (xs) => sum(xs) + GAP * Math.max(0, xs.length - 1);
|
|
360
|
+
const feeRoom = F ? F.h + GAP * 2 : 0;
|
|
361
|
+
const H = Math.ceil(Math.max(span(L), span(Rt) + feeRoom, T + 40) + PAD * 2);
|
|
362
|
+
|
|
363
|
+
const xm = W / 2, xb = W * 0.44, xa = W * 0.17, xb2 = W * 0.56, xa2 = W * 0.83;
|
|
364
|
+
const cxL = (xa + xb) / 2, cxR = (xb2 + xa2) / 2;
|
|
365
|
+
const band = (x, d, fill) => {
|
|
366
|
+
const path = `<path class="xb-${x.kind}" d="${d}" fill="${fill}"><title>${fmt.esc(x.title)}</title></path>`;
|
|
367
|
+
return x.href ? `<a href="${x.href}">${path}</a>` : path;
|
|
368
|
+
};
|
|
369
|
+
const fillOf = (k) => (k === 'cb' ? 'url(#xg-cb)' : k === 'nul' ? 'url(#xg-nul)' : k === 'more' ? 'url(#xg-more)' : 'url(#xg-flow)');
|
|
370
|
+
|
|
371
|
+
// inputs: straight in from the edge, curving into the left half of the trunk
|
|
372
|
+
// both banks start at the SAME trunk top, so the halves meet flush at x = W/2
|
|
373
|
+
let y = (H - span(L)) / 2, c = (H - trunk) / 2;
|
|
374
|
+
const ins = L.map((x) => {
|
|
375
|
+
const n = Math.min(14, x.h / 2 + 3);
|
|
376
|
+
const d = `M0 ${r1(y)}L${r1(xa)} ${r1(y)}C${r1(cxL)} ${r1(y)} ${r1(cxL)} ${r1(c)} ${r1(xb)} ${r1(c)}L${r1(xm)} ${r1(c)}`
|
|
377
|
+
+ `L${r1(xm)} ${r1(c + x.h)}L${r1(xb)} ${r1(c + x.h)}C${r1(cxL)} ${r1(c + x.h)} ${r1(cxL)} ${r1(y + x.h)} ${r1(xa)} ${r1(y + x.h)}`
|
|
378
|
+
+ `L0 ${r1(y + x.h)}L${r1(n)} ${r1(y + x.h / 2)}Z`;
|
|
379
|
+
y += x.h + GAP; c += x.h;
|
|
380
|
+
return band(x, d, fillOf(x.kind));
|
|
381
|
+
}).join('');
|
|
382
|
+
|
|
383
|
+
// the right half of the trunk: the fee on top, then the outputs
|
|
384
|
+
let cr = (H - trunk) / 2; // the same top edge as the inputs: no step at the seam
|
|
385
|
+
let feePath = '';
|
|
386
|
+
if (F) {
|
|
387
|
+
const top = PAD, xe = W * 0.97;
|
|
388
|
+
const d = `M${r1(xm)} ${r1(cr)}L${r1(xb2)} ${r1(cr)}C${r1(W * 0.7)} ${r1(cr)} ${r1(W * 0.72)} ${r1(top)} ${r1(xe)} ${r1(top)}`
|
|
389
|
+
+ `L${r1(xe)} ${r1(top + F.h)}C${r1(W * 0.72)} ${r1(top + F.h)} ${r1(W * 0.7)} ${r1(cr + F.h)} ${r1(xb2)} ${r1(cr + F.h)}L${r1(xm)} ${r1(cr + F.h)}Z`;
|
|
390
|
+
feePath = band(F, d, 'url(#xg-fee)');
|
|
391
|
+
cr += F.h;
|
|
392
|
+
}
|
|
393
|
+
let yr = PAD + feeRoom + ((H - PAD * 2 - feeRoom) - span(Rt)) / 2;
|
|
394
|
+
const outs = Rt.map((x) => {
|
|
395
|
+
const tip = Math.min(22, x.h / 2 + 4);
|
|
396
|
+
const d = `M${r1(xm)} ${r1(cr)}L${r1(xb2)} ${r1(cr)}C${r1(cxR)} ${r1(cr)} ${r1(cxR)} ${r1(yr)} ${r1(xa2)} ${r1(yr)}`
|
|
397
|
+
+ `L${r1(W - tip)} ${r1(yr)}L${r1(W)} ${r1(yr + x.h / 2)}L${r1(W - tip)} ${r1(yr + x.h)}L${r1(xa2)} ${r1(yr + x.h)}`
|
|
398
|
+
+ `C${r1(cxR)} ${r1(yr + x.h)} ${r1(cxR)} ${r1(cr + x.h)} ${r1(xb2)} ${r1(cr + x.h)}L${r1(xm)} ${r1(cr + x.h)}Z`;
|
|
399
|
+
yr += x.h + GAP; cr += x.h;
|
|
400
|
+
return band(x, d, fillOf(x.kind));
|
|
401
|
+
}).join('');
|
|
402
|
+
|
|
403
|
+
return `<svg class="xflow" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" height="${H}" role="img" aria-label="${fmt.esc(`${t.vin.length} inputs to ${t.vout.length} outputs`)}">`
|
|
404
|
+
+ `${flowDefs(W)}${ins}${feePath}${outs}</svg>`;
|
|
405
|
+
}
|