Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
574 lines (544 loc) · 42.3 KB
/
Copy pathindex.html
File metadata and controls
574 lines (544 loc) · 42.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bitcoin-kernel/browser-node — a Bitcoin testnet4 node in a tab</title>
<style>
:root { --bg:#fff; --fg:#16181d; --mut:#5b6470; --bd:#e6e8eb; --pan:#fafbfc; --ac:#e8830c; --ac2:#0969da; --good:#1a7f37; --bad:#cf222e; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color-scheme:light; }
body { background:var(--bg); color:var(--fg); font:14px/1.5 var(--mono); max-width:860px; margin:24px auto; padding:0 16px; }
h1 { font-size:18px; color:var(--fg); } h2 { font-size:14px; color:var(--mut); margin-top:24px; text-transform:uppercase; letter-spacing:.05em; }
a { color:var(--ac2); }
button { background:var(--pan); color:var(--fg); border:1px solid var(--bd); border-radius:6px; padding:8px 14px; cursor:pointer; font:inherit; }
button:hover { background:#f0f2f4; border-color:var(--ac); } button:disabled { opacity:.45; cursor:default; }
#log { background:var(--pan); border:1px solid var(--bd); border-radius:6px; padding:12px; height:200px; overflow:auto; white-space:pre-wrap; }
.ok{color:var(--good)} .warn{color:var(--ac)} .err{color:var(--bad)} .dim{color:var(--mut)}
table { border-collapse:collapse; margin-top:8px; } td { padding:2px 14px 2px 0; } td.n{ text-align:right; color:var(--fg); }
#bar { height:6px; background:var(--bd); border-radius:3px; overflow:hidden; margin:8px 0; }
#bar>i { display:block; height:100%; width:0; background:var(--good); transition:width .2s; }
.pill{ display:inline-block; background:var(--pan); border:1px solid var(--bd); border-radius:999px; padding:1px 8px; font-size:12px; }
</style>
</head>
<body>
<h1>bitcoin-kernel / browser-node — a Bitcoin testnet4 node in a tab</h1>
<p class="dim">Bootstrap a UTXO set from a torrented or Bitcoin Core <i>assumeUTXO</i> snapshot, validate blocks
forward, follow the chain, and live-sync headers — all in this tab, on the <code>bitcoin-kernel</code> engine.</p>
<p><a href="how-it-works.html"><b>📖 How it works</b></a> · <a href="verify.html"><b>✓ Verify the UTXO set (SwiftSync, 32 bytes)</b></a> · <a href="node.html"><b>▶ Run the node</b></a> · <a href="fullchain.html">from genesis</a> · or the acts below.</p>
<p class="dim" style="border:1px solid var(--bd);background:var(--pan);border-radius:6px;padding:8px 12px">
<b>Acts ③ ④ ⑥ work here on GitHub Pages.</b> Acts ① (WebTorrent seeding) and ⑤ (the live p2p bridge) need a
local server — see the <a href="https://github.com/bitcoin-kernel/browser-node#run-it">README</a>.</p>
<p>
<button id="go">① Load snapshot via WebTorrent</button>
<button id="http">② Fallback: load via HTTP webseed</button>
</p>
<div id="bar"><i></i></div>
<h2>Log</h2>
<div id="log"></div>
<h2>① Bootstrap — coin view from torrented snapshot</h2>
<div id="stats" class="dim">— not loaded yet —</div>
<h2>② Forward block validation</h2>
<p class="dim">Validate the next <b>real testnet4 block (#26000)</b> forward against its coin view — full consensus:
prevout resolution, scripts/signatures (BIP143), fees, coinbase amount, maturity, witness commitment.
The <span class="pill">bitcoin-kernel</span> engine runs in this tab; the coin view holds the 28 real prevouts the block spends.</p>
<p><button id="validate">③ Validate block 26000 forward</button></p>
<div id="validation" class="dim">— not validated yet —</div>
<h2>③ Follow the chain — multi-block</h2>
<p class="dim">Validate a run of <b>consecutive real blocks (26000→26020)</b>, applying each to the UTXO set so the
next block can spend its outputs, and verifying linkage block-to-block. This is a node's inner loop — the step
from "accepts a block" to "follows the chain." This range is a consolidation cascade: most inputs spend coins
created earlier <i>in the run</i>, so it only works if the apply step is correct.</p>
<p><button id="follow">④ Follow chain 26000 → 26020</button></p>
<div id="followsum" class="dim">— not run yet —</div>
<div style="overflow:auto"><table id="followtbl"></table></div>
<h2>④ Live feed — follow the network over a WS↔TCP bridge</h2>
<p class="dim">A browser can't open raw TCP, so the tab speaks the Bitcoin p2p protocol over a WebSocket to a
thin local <span class="pill">bridge</span> that relays frames to a real testnet4 peer (here, our own synced node).
The tab does the version/verack handshake, syncs the <b>whole header chain from genesis</b>, and fully validates
every header (PoW, BIP94 difficulty, linkage, most-work reorg) — then tails the live tip. The bridge is untrusted:
it can't forge a valid header. The chain is <b>persisted to OPFS</b>, so a reload <b>resumes from disk</b> instead of
re-syncing from genesis.</p>
<p><button id="live">⑤ Connect & sync live</button></p>
<div id="livestatus" class="dim">— not connected —</div>
<div id="livetip"></div>
<h2>⑤ assumeUTXO — parse Core's <code>dumptxoutset</code> snapshot</h2>
<p class="dim">Parse the <b>real Bitcoin Core snapshot we torrented</b> (<code>utxo-testnet4-120000.dat</code>, the v2
compressed format) directly in the tab, then forward-validate the first post-snapshot block (#120001) against the
coin view it produces. This is the bridge from a Core assumeUTXO snapshot to a usable, validating node — and the
ultimate parser proof: real signatures only verify if every decompressed amount and script is exactly right.</p>
<p><button id="snap">⑥ Parse Core snapshot → validate #120001</button></p>
<div id="snapresult" class="dim">— not run yet —</div>
<h2>⑥ Persist the UTXO set — OPFS checkpoint</h2>
<p class="dim">The coin view lives in RAM (fast synchronous lookups), and is <b>checkpointed to OPFS</b> — the same model
Bitcoin Core uses (flush the chainstate periodically, not every block). Build a coin view from the Core snapshot,
checkpoint it, then <b>resume it from disk on reload</b> — no re-parsing. This is UTXO-set persistence; at full
14M-coin scale the same code moves into a Web Worker with sync access handles (see README).</p>
<p>
<button id="ckpt">⑦ Build + checkpoint UTXO to OPFS</button>
<button id="resume">⑧ Resume UTXO from OPFS</button>
</p>
<div id="ckptresult" class="dim">— not run yet —</div>
<h2>⑦ WASM signature verification</h2>
<p class="dim">Signature verification is the bottleneck for inscription-flood blocks (~14k sigs each). The engine's
default is pure-JS secp256k1; this swaps in a <b>WASM libsecp256k1</b> backend via the engine's
<code>setVerifyBackend</code> hook. Gated by a consensus check: WASM must agree with pure-JS on every verdict —
you don't swap consensus crypto on faith.</p>
<p><button id="wasm">⑨ Benchmark secp: pure-JS vs WASM (block #26000)</button></p>
<div id="wasmresult" class="dim">— not run yet —</div>
<h2>⑧ Run the node in a Web Worker — scale</h2>
<p class="dim">At full 14M-coin scale, validating inscription-flood blocks (~14k sigs) and writing multi-GB UTXO
checkpoints would freeze the tab. The fix: run the engine, the WASM secp backend, the UTXO set, and OPFS
<b>sync access handles</b> (Worker-only, the fast I/O path) inside a <b>Web Worker</b>. Below: the same
validation runs in the Worker (UI stays responsive) and on the main thread (UI freezes) — measured by the
largest gap between animation ticks.</p>
<p><button id="worker">⑩ Validate in a Worker vs on the main thread</button></p>
<div id="workerresult" class="dim">— not run yet —</div>
<h2>⑨ SwiftSync — stateless validation (past the memory ceiling)</h2>
<p class="dim">The full UTXO set is ~25 GB — too big for a tab. <a href="https://gist.github.com/RubenSomsen/a61a37d14182ccd78760e477c78133cd">SwiftSync</a>
removes the need to hold it: since <code>all outputs − all inputs = the UTXO set</code>, you feed every created output (+)
and spent input (−) into a <b>constant-size accumulator</b>. With the start + terminal snapshots, a valid chain
cancels to <b>zero</b> — proving no double-spends or fabricated coins, with <b>32 bytes of state</b> instead of 25 GB.
This is the repo's own SwiftSync accumulator (verified across all of testnet4), run here over a real block range.</p>
<p><button id="swiftsync">⑪ SwiftSync-validate blocks 26000→26020</button></p>
<div id="ssresult" class="dim">— not run yet —</div>
<h2>⑩ SwiftSync at scale — the full UTXO set in 32 bytes</h2>
<p class="dim">The proof that this breaks the ceiling: streaming the <b>real full testnet4 UTXO set (14,129,063 coins,
826 MB)</b> through the accumulator commits it with <b>RSS flat at ~0.9 GB</b> (the input file) — the set-state
never leaves 32 bytes, vs ~25 GB to hold it (<code>tools/swiftsync-commit.mjs</code>, commitment
<code>af37b01d…</code>). Below, the same accumulator streams millions of outpoints in the <b>worker</b> (UI stays
responsive) to show the 32-byte state holds at scale, in the tab.</p>
<p><button id="scale">⑫ Stream millions of coins → 32-byte state</button></p>
<div id="scaleresult" class="dim">— not run yet —</div>
<h2>⑪ SwiftSync hints — build the UTXO set from a tiny file</h2>
<p class="dim">The full SwiftSync flow: generate a compact <b>hints file</b> (which outputs survive), <b>reconstruct</b> the
UTXO set from blocks + hints with <b>no spend processing</b> (fast, parallelizable), and <b>verify</b> it with the
32-byte accumulator. The hints are tiny (~25 bytes/block — the whole testnet4 chain ≈ a few MB), and they carry no
trust: wrong hints just fail the accumulator check.</p>
<p><button id="hints">⑬ Generate hints → reconstruct → verify</button></p>
<div id="hintsresult" class="dim">— not run yet —</div>
<script type="module">
import WebTorrent from './webtorrent.min.js';
import { ShardedUtxo } from './sharded-utxo-browser.js';
import { loadEngine, loadCoinView, validateForward, coinviewOf } from './validate-forward.js';
import { followChain } from './follow-chain.js';
import { connect as liveConnect, syncToTip, tail } from './live-feed.js';
import { DumpReader, parseHeader, coins } from './dumptxoutset.js';
import { checkpoint, resume, checkpointInfo, opfsAvailable as opfsCoins } from './opfs-coins-store.js';
import { setVerifyBackend } from './engine/codec/secp256k1.js';
import { sha256 } from './engine/codec/hash.js';
import { Accumulator } from './swiftsync/accumulator.js';
import { encodeOutpoint } from './swiftsync/outpoint.js';
const $log = document.getElementById('log'), $bar = document.querySelector('#bar>i'), $stats = document.getElementById('stats');
const log = (m, c='') => { const t = new Date().toISOString().slice(11,19); $log.innerHTML += `<span class="dim">${t}</span> <span class="${c}">${m}</span>\n`; $log.scrollTop = $log.scrollHeight; };
const setBar = (f) => $bar.style.width = (Math.max(0,Math.min(1,f))*100).toFixed(1)+'%';
const fmt = (n) => n.toLocaleString();
const classify = (spk) =>
/^76a914[0-9a-f]{40}88ac$/.test(spk) ? 'P2PKH' :
/^a914[0-9a-f]{40}87$/.test(spk) ? 'P2SH' :
/^0014[0-9a-f]{40}$/.test(spk) ? 'P2WPKH' :
/^0020[0-9a-f]{64}$/.test(spk) ? 'P2WSH' :
/^5120[0-9a-f]{64}$/.test(spk) ? 'P2TR' : 'other';
function renderStats(utxo, meta, loadSec) {
let totalSats = 0n, coinbase = 0; const types = {}; const samples = [];
for (const [key, v] of utxo.entries()) {
const t1 = v.indexOf('\t'), t2 = v.indexOf('\t', t1+1), t3 = v.lastIndexOf('\t');
totalSats += BigInt(v.slice(0,t1));
if (v.slice(t3+1) === '1') coinbase++;
const ty = classify(v.slice(t1+1,t2)); types[ty] = (types[ty]||0)+1;
if (samples.length < 8) samples.push(key);
}
// prevout lookup micro-benchmark — the operation block validation actually does
const ITER = 500000; let hits = 0; const t0 = performance.now();
for (let i=0;i<ITER;i++) if (utxo.get(samples[i % samples.length])) hits++;
const ns = (performance.now()-t0)*1e6/ITER;
const rows = Object.entries(types).sort((a,b)=>b[1]-a[1])
.map(([t,n])=>`<tr><td>${t}</td><td class="n">${fmt(n)}</td><td class="dim">${(100*n/utxo.size).toFixed(1)}%</td></tr>`).join('');
$stats.innerHTML = `<table>
<tr><td>height</td><td class="n">${meta.height ?? '?'}</td><td class="dim">${meta.network||''}</td></tr>
<tr><td>coins loaded</td><td class="n">${fmt(utxo.size)}</td><td class="dim">into ${utxo.n} shards</td></tr>
<tr><td>load time</td><td class="n">${loadSec.toFixed(2)} s</td><td class="dim">parse + build</td></tr>
<tr><td>total unspent</td><td class="n">${(Number(totalSats)/1e8).toLocaleString()}</td><td class="dim">tBTC</td></tr>
<tr><td>coinbase coins</td><td class="n">${fmt(coinbase)}</td></tr>
<tr><td>prevout lookup</td><td class="n">${ns.toFixed(0)} ns</td><td class="dim">${(1e9/ns/1e6).toFixed(1)}M gets/s · ${hits===ITER?'all hit ✓':'MISS'}</td></tr>
</table><div style="margin-top:8px" class="dim">script types</div><table>${rows}</table>`;
log(`coin view ready: ${fmt(utxo.size)} coins, ${(Number(totalSats)/1e8).toLocaleString()} tBTC, lookups ${ns.toFixed(0)}ns`, 'ok');
}
async function loadFromStream(stream, bytes, label) {
const utxo = new ShardedUtxo(64);
const t0 = performance.now();
const meta = await utxo.loadFromStream(stream, bytes, (lines, read, total) => {
if (total) setBar(read/total);
if ((lines % 262144) === 0) log(`${label}: ${fmt(lines)} coins parsed…`, 'dim');
});
setBar(1);
const loadSec = (performance.now()-t0)/1000;
log(`${label}: parsed ${fmt(utxo.size)} coins in ${loadSec.toFixed(2)}s`, 'ok');
renderStats(utxo, meta, loadSec);
}
// ── Path ②: HTTP webseed (always works on localhost; proves the ShardedUtxo path) ──
document.getElementById('http').onclick = async () => {
try {
log('fetching snapshot.ndjson over HTTP…');
const res = await fetch('snapshot.ndjson');
if (!res.ok) { log('snapshot.ndjson not found — act ① is local-only: run `node tools/gen-snapshot.mjs 250000 snapshot.ndjson` then `node serve.mjs`. (Acts ③④⑥ work here.)', 'warn'); return; }
const bytes = +res.headers.get('content-length') || 0;
await loadFromStream(res.body, bytes, 'http');
} catch (e) { log('HTTP load failed: '+e.message, 'err'); }
};
// ── Path ①: WebTorrent (real magnet, wss trackers + ws= webseed) ──
document.getElementById('go').onclick = async () => {
document.getElementById('go').disabled = true;
try {
const mres = await fetch('magnet.txt');
if (!mres.ok) { log('magnet.txt not found — act ① is local-only: run `node serve.mjs` and `node seed.mjs`. (Acts ③④⑥ work here.)', 'warn'); document.getElementById('go').disabled = false; return; }
const magnet = (await mres.text()).trim();
log('magnet: '+magnet.slice(0,64)+'…', 'dim');
const client = new WebTorrent();
client.on('error', e => log('client error: '+e.message, 'err'));
log('adding torrent — connecting to swarm…');
client.add(magnet, async (torrent) => {
log(`metadata ready: ${torrent.files[0].name} (${(torrent.length/1048576).toFixed(1)} MB)`, 'ok');
const iv = setInterval(() => log(
`peers ${torrent.numPeers} · ${(100*torrent.progress).toFixed(0)}% · ↓${(torrent.downloadSpeed/1048576).toFixed(2)} MB/s`, 'dim'), 1000);
torrent.on('done', async () => {
clearInterval(iv);
log('torrent complete — streaming into ShardedUtxo', 'ok');
const blob = await torrent.files[0].blob();
await loadFromStream(blob.stream(), blob.size, 'webtorrent');
});
});
} catch (e) { log('WebTorrent load failed: '+e.message+' — try path ②', 'err'); }
};
// ── Path ③: forward block validation (the node accepting the next block) ──
const ruleRows = (res) => res.map(x =>
`<tr><td>${x.ok===true?'<span class="ok">✓</span>':x.ok===false?'<span class="err">✗ FAIL</span>':'<span class="dim">– skip</span>'}</td><td class="dim">${x.rule}</td></tr>`).join('');
document.getElementById('validate').onclick = async () => {
const btn = document.getElementById('validate'); btn.disabled = true;
const $v = document.getElementById('validation');
try {
log('— forward block validation —', 'ok');
await loadEngine(log);
const { coinview } = await loadCoinView('data/snapshot-26000.ndjson', log);
const r = await validateForward({ blockUrl: 'data/block-26000.hex', height: 26000, coinview }, log);
$v.innerHTML = `<table>
<tr><td>block</td><td class="n">26000</td><td class="dim">${r.txs} txs</td></tr>
<tr><td>inputs resolved</td><td class="n">${r.resolved}/${r.inputs}</td><td class="dim">from the coin view</td></tr>
<tr><td>validated in</td><td class="n">${r.ms.toFixed(0)} ms</td><td class="dim">pure-JS secp256k1</td></tr>
</table>
<div class="dim" style="margin-top:8px">structure rules</div><table>${ruleRows(r.structure)}</table>
<div class="dim" style="margin-top:8px">context rules — against the coin view</div><table>${ruleRows(r.context)}</table>
<p class="${r.ok?'ok':'err'}">${r.ok?'✅ block 26000 fully validated forward — the bootstrapped node accepts it':'❌ validation failed'}</p>`;
log(r.ok ? `block 26000 ACCEPTED — ${r.resolved}/${r.inputs} inputs, scripts verified, ${r.ms.toFixed(0)}ms` : 'block REJECTED', r.ok?'ok':'err');
} catch (e) { $v.innerHTML = `<span class="err">error: ${e.message}</span>`; log('validation error: '+e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ④: follow the chain — validate consecutive blocks, applying UTXO updates ──
document.getElementById('follow').onclick = async () => {
const btn = document.getElementById('follow'); btn.disabled = true;
const $sum = document.getElementById('followsum'), $tbl = document.getElementById('followtbl');
$tbl.innerHTML = `<tr class="dim"><td>height</td><td>txs</td><td>inputs</td><td>link</td><td>valid</td><td>applied</td><td>UTXO set</td><td>ms</td></tr>`;
try {
log('— follow the chain 26000→26020 —', 'ok');
const { codec, be } = await loadEngine(log);
const { snap } = await loadCoinView('data/range-seed.ndjson', log); // seed UTXO set
const startSize = snap.size;
const range = await (await fetch('data/range.json')).json();
log(`seed ${startSize} coins; following ${range.blocks.length} blocks…`);
const cell = (v) => `<td class="n">${v}</td>`;
const r = await followChain({ range, codec, be, snap, coinview: coinviewOf(snap),
onBlock: (b) => {
$tbl.innerHTML += `<tr>${cell('#'+b.height)}${cell(b.txs)}${cell(b.inputs)}` +
`<td class="n ${b.linked===false?'err':'ok'}">${b.linked===null?'—':b.linked?'✓':'✗'}</td>` +
`<td class="n ${b.ok?'ok':'err'}">${b.ok?'✓':'✗'}</td>` +
`<td class="n dim">−${b.applied.spent}/+${b.applied.created}</td>${cell(b.utxoSize)}${cell(b.ms.toFixed(0))}</tr>`;
}});
const tot = r.results.reduce((s,b)=>s+b.ms,0);
$sum.innerHTML = `<p class="${r.validated===r.total?'ok':'err'}">${r.validated===r.total?'✅':'❌'} followed ${fmt(r.validated)}/${fmt(r.total)} blocks · ` +
`UTXO set ${fmt(startSize)} → ${fmt(snap.size)} coins · ${(tot/1000).toFixed(1)}s total (pure-JS secp)</p>`;
log(`chain followed: ${r.validated}/${r.total} blocks, UTXO ${startSize}→${snap.size}`, r.validated===r.total?'ok':'err');
} catch (e) { $sum.innerHTML = `<span class="err">error: ${e.message}</span>`; log('follow error: '+e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑤: live feed — header sync + tail over the WS↔TCP bridge ──
let liveStop = null;
document.getElementById('live').onclick = async () => {
const btn = document.getElementById('live'); btn.disabled = true;
const $s = document.getElementById('livestatus'), $t = document.getElementById('livetip');
try {
if (liveStop) { liveStop(); liveStop = null; }
log('— live feed —', 'ok');
const jl = (n) => fetch(`./engine/schema/${n}.jsonld`).then(r => r.json());
const [core, proof, p2p, chain, validate] = await Promise.all(['core','proof','p2p','chain','validate'].map(jl));
const vectors = await (await fetch('./data/testnet4.json')).json();
const t0 = performance.now();
const session = await liveConnect({ bridgeUrl: 'ws://localhost:8334', schemas: { core, proof, p2p, chain, validate }, vectors, log, persist: true });
const resumed = session.resumedFrom;
$s.innerHTML = `<span class="ok">handshake ok</span> — ${resumed ? `resumed ${fmt(resumed)} headers from OPFS, syncing new…` : 'syncing header chain from genesis…'}`;
await syncToTip(session, { onBatch: ({ tip, reorg }) => {
if (reorg) log(`reorg: rolled back ${reorg.depth} at height ${fmt(reorg.atHeight)}`, 'warn');
$s.innerHTML = `syncing… validated to height <b>${fmt(tip.height)}</b>`;
}});
const tip = session.store.tip();
const secs = ((performance.now()-t0)/1000).toFixed(1);
const how = session.persisted ? (resumed ? `resumed ${fmt(resumed)} from OPFS + synced to` : `synced from genesis + persisted to OPFS:`) : 'synced from genesis:';
$s.innerHTML = `<span class="ok">✅ ${how} ${fmt(tip.height)} headers in ${secs}s</span> · tip <span class="dim">${tip.hash.slice(0,24)}…</span><br><span class="dim">${session.persisted ? 'chain saved to OPFS — reload the page and click ⑤ to resume. ' : ''}watching for new blocks (live tail)…</span>`;
log(`live: ${fmt(tip.height)} headers in ${secs}s${session.persisted ? (resumed ? ` (resumed ${fmt(resumed)} from OPFS)` : ' (persisted to OPFS)') : ''}`, 'ok');
$t.innerHTML = `<table><tr class="dim"><td>live tip</td><td class="n">#${fmt(tip.height)}</td><td class="dim">${tip.hash.slice(0,20)}…</td></tr></table>`;
liveStop = tail(session, { intervalMs: 8000, log, onTip: (t) => {
log(`new block #${fmt(t.height)} ${t.hash.slice(0,16)}… (+${t.added})`, 'ok');
$t.innerHTML = `<table><tr class="ok"><td>live tip</td><td class="n">#${fmt(t.height)}</td><td class="dim">${t.hash.slice(0,20)}… (just appended)</td></tr></table>`;
}});
btn.textContent = '⑤ Live — tailing tip';
} catch (e) { $s.innerHTML = `<span class="err">error: ${e.message}</span> <span class="dim">(is the bridge running on :8334?)</span>`; log('live error: '+e.message, 'err'); console.error(e); btn.disabled = false; }
};
// ── Path ⑥: parse Core's dumptxoutset snapshot, then forward-validate #120001 ──
const classifySpk = (s) => s.startsWith('0014')?'P2WPKH':s.startsWith('5120')?'P2TR':s.startsWith('0020')?'P2WSH':s.startsWith('76a914')?'P2PKH':s.startsWith('a914')?'P2SH':'other';
document.getElementById('snap').onclick = async () => {
const btn = document.getElementById('snap'); btn.disabled = true;
const $r = document.getElementById('snapresult');
try {
log('— parse Core dumptxoutset snapshot —', 'ok');
log('fetching utxo-prefix.dat (Core v2 snapshot)…');
const bytes = new Uint8Array(await (await fetch('data/utxo-prefix.dat')).arrayBuffer());
const r = new DumpReader(bytes);
const hdr = parseHeader(r);
log(`header: version ${hdr.version}, base ${hdr.baseHash.slice(0,16)}…, coins_count ${fmt(hdr.coinsCount)}`, 'ok');
const types = {}; let n = 0; let sample = null; const t0 = performance.now();
for (const c of coins(r)) { n++; const t = classifySpk(c.scriptPubKey); types[t] = (types[t]||0)+1; if (!sample && c.scriptPubKey.startsWith('0014')) sample = c; }
const ms = performance.now() - t0;
const trow = Object.entries(types).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`<tr><td>${k}</td><td class="n">${fmt(v)}</td><td class="dim">${(100*v/n).toFixed(1)}%</td></tr>`).join('');
log(`parsed ${fmt(n)} coins from the prefix in ${ms.toFixed(0)}ms`, 'ok');
// forward-validate #120001 against the coin view derived from the parsed Core snapshot
log('validating block #120001 against the parsed-snapshot coin view…');
const { coinview } = await loadCoinView('data/snapshot-120001.ndjson', log);
const v = await validateForward({ blockUrl: 'data/block-120001.hex', height: 120001, coinview }, log);
$r.innerHTML = `<table>
<tr><td>snapshot format</td><td class="n">Core dumptxoutset v${hdr.version}</td><td class="dim">${hdr.netMagic} (testnet4)</td></tr>
<tr><td>base block</td><td class="n">#120000</td><td class="dim">${hdr.baseHash.slice(0,20)}…</td></tr>
<tr><td>coins in full snapshot</td><td class="n">${fmt(hdr.coinsCount)}</td><td class="dim">13.87M</td></tr>
<tr><td>parsed from prefix</td><td class="n">${fmt(n)}</td><td class="dim">${ms.toFixed(0)}ms, in-tab</td></tr>
</table>
<div class="dim" style="margin-top:8px">script types (decompressed from Core format)</div><table>${trow}</table>
<div class="dim" style="margin-top:8px">forward-validate #120001 against the parsed snapshot</div>
<p class="${v.ok?'ok':'err'}">${v.ok?`✅ block #120001 fully validated (${v.txs} txs, ${v.resolved} snapshot inputs + intra-block, scripts verified, ${v.ms.toFixed(0)}ms) — a Core assumeUTXO snapshot is now a validating coin view in the browser`:'❌ validation failed'}</p>`;
log(v.ok ? `#120001 ACCEPTED against parsed Core snapshot (${v.ms.toFixed(0)}ms)` : '#120001 REJECTED', v.ok?'ok':'err');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('snapshot error: '+e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑦/⑧: persist the UTXO set to OPFS (checkpoint) and resume it ──
const CKPT = 'utxo-checkpoint.ndjson';
function buildCoinViewFromPrefix(bytes) {
const r = new DumpReader(bytes); const hdr = parseHeader(r);
const snap = new ShardedUtxo(64);
for (const c of coins(r)) snap.set(`${c.txid}:${c.vout}`, `${c.value}\t${c.scriptPubKey}\t${c.height}\t${c.coinbase?1:0}`);
return { snap, hdr };
}
document.getElementById('ckpt').onclick = async () => {
const btn = document.getElementById('ckpt'); btn.disabled = true;
const $r = document.getElementById('ckptresult');
try {
if (!opfsCoins()) { $r.innerHTML = '<span class="err">OPFS not available in this browser</span>'; btn.disabled = false; return; }
log('— checkpoint UTXO set to OPFS —', 'ok');
log('building a coin view from the Core snapshot prefix…');
const bytes = new Uint8Array(await (await fetch('data/utxo-prefix.dat')).arrayBuffer());
const { snap, hdr } = buildCoinViewFromPrefix(bytes);
log(`coin view: ${fmt(snap.size)} coins; writing checkpoint to OPFS…`);
const t0 = performance.now();
const c = await checkpoint(snap, CKPT, { network: 'testnet4', source: 'core dumptxoutset prefix', base: hdr.baseHash });
const ms = performance.now() - t0;
const info = await checkpointInfo(CKPT);
$r.innerHTML = `<table>
<tr><td>coins checkpointed</td><td class="n">${fmt(c)}</td><td class="dim">RAM → OPFS</td></tr>
<tr><td>OPFS file</td><td class="n">${(info.bytes/1048576).toFixed(2)} MB</td><td class="dim">${CKPT}</td></tr>
<tr><td>write time</td><td class="n">${ms.toFixed(0)} ms</td></tr>
</table><p class="ok">✅ UTXO set persisted to OPFS — reload the page and click ⑧ to resume it from disk.</p>`;
log(`checkpointed ${fmt(c)} coins to OPFS (${(info.bytes/1048576).toFixed(2)} MB, ${ms.toFixed(0)}ms)`, 'ok');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('checkpoint error: '+e.message, 'err'); console.error(e); }
btn.disabled = false;
};
document.getElementById('resume').onclick = async () => {
const btn = document.getElementById('resume'); btn.disabled = true;
const $r = document.getElementById('ckptresult');
try {
if (!opfsCoins()) { $r.innerHTML = '<span class="err">OPFS not available</span>'; btn.disabled = false; return; }
log('— resume UTXO set from OPFS —', 'ok');
const snap = new ShardedUtxo(64);
const t0 = performance.now();
const meta = await resume(snap, CKPT);
if (!meta) { $r.innerHTML = '<span class="warn">no OPFS checkpoint yet — click ⑦ first</span>'; log('no checkpoint found — run ⑦', 'warn'); btn.disabled = false; return; }
const ms = performance.now() - t0;
// prove it is a live, queryable coin view
let sample = null; for (const [k] of snap.entries()) { sample = k; break; }
const hit = sample ? !!snap.get(sample) : false;
$r.innerHTML = `<table>
<tr><td>resumed from OPFS</td><td class="n">${fmt(snap.size)} coins</td><td class="dim">${meta.source||''}</td></tr>
<tr><td>resume time</td><td class="n">${ms.toFixed(0)} ms</td><td class="dim">no re-parse of the snapshot</td></tr>
<tr><td>prevout lookup</td><td class="n">${hit?'✓ hit':'✗'}</td><td class="dim">${sample?sample.slice(0,18)+'…':''}</td></tr>
</table><p class="ok">✅ UTXO set restored from OPFS — survives reloads.</p>`;
log(`resumed ${fmt(snap.size)} coins from OPFS in ${ms.toFixed(0)}ms`, 'ok');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('resume error: '+e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑨: WASM secp benchmark — pure-JS vs WASM, gated by verdict equivalence ──
document.getElementById('wasm').onclick = async () => {
const btn = document.getElementById('wasm'); btn.disabled = true;
const $r = document.getElementById('wasmresult');
try {
log('— WASM secp benchmark —', 'ok');
const { codec, be } = await loadEngine(log);
const { coinview } = await loadCoinView('data/snapshot-26000.ndjson', log);
const block = codec.decode('Block', (await (await fetch('data/block-26000.hex')).text()).trim());
const sigs = 28, N = 20;
const bench = () => { const t0 = performance.now(); let pass = 0; for (let i = 0; i < N; i++) { const res = be.validateBlockContext(block, { height: 26000, utxo: coinview }).results; if (res.every(x => x.ok !== false)) pass++; } return { ms: (performance.now() - t0) / N, pass }; };
setVerifyBackend(null); bench(); // pure-JS, warm
const js = bench();
log('loading secp256k1.wasm (1.2 MB)…');
const { wasmBackend } = await import('./wasm-secp.js'); // top-level await instantiates the wasm
setVerifyBackend(wasmBackend); bench(); // WASM, warm
const wa = bench();
setVerifyBackend(null); // reset so other acts use the default
const vps = (ms) => Math.round(sigs / (ms / 1000));
const speedup = (js.ms / wa.ms).toFixed(1);
const equiv = js.pass === N && wa.pass === N;
$r.innerHTML = `<table>
<tr><td>block</td><td class="n">#26000</td><td class="dim">${sigs} ECDSA sigs · ${N}× each</td></tr>
<tr><td>pure-JS secp</td><td class="n">${js.ms.toFixed(1)} ms</td><td class="dim">≈ ${fmt(vps(js.ms))} verifies/s</td></tr>
<tr><td>WASM secp</td><td class="n">${wa.ms.toFixed(1)} ms</td><td class="dim">≈ ${fmt(vps(wa.ms))} verifies/s</td></tr>
<tr><td>speedup</td><td class="n">${speedup}×</td><td class="dim">${equiv ? 'verdicts identical ✓' : 'MISMATCH ✗'}</td></tr>
</table><p class="${equiv ? 'ok' : 'err'}">${equiv ? `✅ WASM secp is ${speedup}× faster and agrees with pure-JS on every verdict` : '❌ verdict mismatch — backend rejected'}</p>`;
log(`WASM secp ${speedup}× faster, verdicts identical`, equiv ? 'ok' : 'err');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('wasm error: ' + e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑩: run the node in a Web Worker (scale) — A/B against the main thread ──
let _worker = null, _rpcId = 0; const _rpc = new Map(); let _onWorkerProgress = null;
function workerCall(cmd, args) {
if (!_worker) {
_worker = new Worker('node-worker.js', { type: 'module' });
_worker.onmessage = (e) => { const d = e.data; if (d.progress) { _onWorkerProgress && _onWorkerProgress(d); return; } const p = _rpc.get(d.id); if (!p) return; _rpc.delete(d.id); d.ok ? p.resolve(d.result) : p.reject(new Error(d.error)); };
_worker.onerror = (e) => log('worker error: ' + (e.message || e.filename), 'err');
}
const id = ++_rpcId; _worker.postMessage({ id, cmd, args });
return new Promise((resolve, reject) => _rpc.set(id, { resolve, reject }));
}
// main-thread liveness meter: largest gap between 16ms ticks (big gap = UI frozen)
function meter() { let ticks = 0, max = 0, last = performance.now(); const iv = setInterval(() => { const n = performance.now(); const g = n - last; if (g > max) max = g; last = n; ticks++; }, 16); return () => { clearInterval(iv); const finalGap = performance.now() - last; if (finalGap > max) max = finalGap; return { ticks, max }; }; }
async function followOnMainThread() {
const { codec, be } = await loadEngine();
const snap = new ShardedUtxo(64);
const seed = await (await fetch('data/range-seed.ndjson')).text();
let first = true; for (const l of seed.split('\n')) { if (!l) continue; if (first) { first = false; continue; } const [k, v] = JSON.parse(l); snap.set(k, v); }
const range = await (await fetch('data/range.json')).json();
await followChain({ range, codec, be, snap, coinview: coinviewOf(snap) });
}
document.getElementById('worker').onclick = async () => {
const btn = document.getElementById('worker'); btn.disabled = true;
const $r = document.getElementById('workerresult');
try {
log('— web worker / scale —', 'ok');
log('spawning worker; loading engine + WASM secp inside it…');
await workerCall('init');
// A) heavy validation IN the worker — measure main-thread responsiveness
let stop = meter();
const w = await workerCall('followRange');
const wm = stop();
const ck = await workerCall('checkpoint'); // OPFS sync access handle (worker-only)
log(`worker: followed ${fmt(w.validated)}/${fmt(w.total)} blocks, UTXO ${fmt(w.utxoStart)}→${fmt(w.utxoEnd)} in ${w.ms.toFixed(0)}ms; checkpoint ${(ck.bytes/1048576).toFixed(2)}MB`, 'ok');
// B) same validation on the MAIN thread — measure the freeze
log('now running the same on the main thread (will freeze the UI)…');
await new Promise(r => setTimeout(r, 30));
stop = meter();
await followOnMainThread();
const mm = stop();
$r.innerHTML = `<table>
<tr><td>task</td><td class="dim" colspan="2">follow + validate 21 blocks (26000–26020), apply UTXO updates</td></tr>
<tr><td>in Web Worker</td><td class="n">${w.ms.toFixed(0)} ms</td><td class="dim"><span class="ok">UI responsive</span> — max frame gap <b>${wm.max.toFixed(0)}ms</b> (${fmt(wm.ticks)} ticks)</td></tr>
<tr><td>on main thread</td><td class="n">—</td><td class="dim"><span class="err">UI frozen</span> — max frame gap <b>${mm.max.toFixed(0)}ms</b> (${fmt(mm.ticks)} ticks)</td></tr>
<tr><td>OPFS checkpoint</td><td class="n">${(ck.bytes/1048576).toFixed(2)} MB</td><td class="dim">written via sync access handle (Worker-only)</td></tr>
</table><p class="ok">✅ engine + WASM secp + OPFS sync-handle persistence run in a Worker — UI stays responsive (${wm.max.toFixed(0)}ms gap) vs frozen on the main thread (${mm.max.toFixed(0)}ms gap).</p>`;
log(`worker keeps UI responsive (${wm.max.toFixed(0)}ms gap) vs main-thread freeze (${mm.max.toFixed(0)}ms gap)`, 'ok');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('worker error: ' + e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑪: SwiftSync — stateless set-transition validation (32-byte accumulator) ──
document.getElementById('swiftsync').onclick = async () => {
const btn = document.getElementById('swiftsync'); btn.disabled = true;
const $r = document.getElementById('ssresult');
try {
log('— SwiftSync stateless validation —', 'ok');
const { codec } = await loadEngine(log);
const NULL = '00'.repeat(32);
const seedText = await (await fetch('data/range-seed.ndjson')).text();
const range = await (await fetch('data/range.json')).json();
const op = (txid, vout) => encodeOutpoint({ txid, vout });
const buildAcc = () => {
const acc = new Accumulator({ sha256 }); // 32-byte state, salt=null (reference)
let seedN = 0, created = 0, spent = 0; const survivors = new Set();
for (const l of seedText.split('\n').slice(1)) { if (!l) continue; const [k] = JSON.parse(l); const i = k.lastIndexOf(':'); acc.add(op(k.slice(0, i), +k.slice(i + 1))); seedN++; }
for (const hex of range.blocks) { const block = codec.decode('Block', hex);
for (const tx of block.transactions) { const txid = codec.txid(tx);
for (const inp of tx.inputs) { if (inp.prevout.txid === NULL) continue; acc.spend(op(inp.prevout.txid, inp.prevout.vout)); spent++; survivors.delete(`${inp.prevout.txid}:${inp.prevout.vout}`); }
for (let v = 0; v < tx.outputs.length; v++) { const s = tx.outputs[v].scriptPubKey; if (typeof s === 'string' && (s.startsWith('6a') || s.length > 20000)) continue; acc.add(op(txid, v)); created++; survivors.add(`${txid}:${v}`); } } }
for (const k of survivors) { const i = k.lastIndexOf(':'); acc.spend(op(k.slice(0, i), +k.slice(i + 1))); }
return { acc, seedN, created, spent, terminal: survivors.size };
};
const t0 = performance.now();
const h = buildAcc(); const zero = h.acc.isZero(); const ms = performance.now() - t0;
const digest = Array.from(h.acc.digest()).map(b => b.toString(16).padStart(2, '0')).join('');
// tamper: one input that spends a coin that was never created (fabricated / double-spend)
const t = buildAcc(); t.acc.spend(op('ff'.repeat(32), 0)); const tamperRejected = !t.acc.isZero();
$r.innerHTML = `<table>
<tr><td>range</td><td class="n">26000–26020</td><td class="dim">${fmt(range.blocks.length)} blocks</td></tr>
<tr><td>events</td><td class="n">${fmt(h.seedN)} + ${fmt(h.created)} − ${fmt(h.spent)} − ${fmt(h.terminal)}</td><td class="dim">start + created − spent − terminal</td></tr>
<tr><td>accumulator state</td><td class="n">32 bytes</td><td class="dim">vs ~25 GB UTXO set · ${ms.toFixed(0)}ms</td></tr>
<tr><td>closed sum</td><td class="n ${zero ? 'ok' : 'err'}">${zero ? 'ZERO ✓' : 'non-zero ✗'}</td><td class="dim">digest ${digest.slice(0, 16)}…</td></tr>
<tr><td>tamper (fabricated spend)</td><td class="n ${tamperRejected ? 'ok' : 'err'}">${tamperRejected ? 'non-zero ✓' : 'ZERO ✗'}</td><td class="dim">${tamperRejected ? 'rejected' : 'MISSED'}</td></tr>
</table><p class="${zero && tamperRejected ? 'ok' : 'err'}">${zero && tamperRejected ? '✅ set-transition verified with 32 bytes of state, and a single fabricated spend is detected (sum ≠ 0) — no 25 GB UTXO set needed.' : '❌ unexpected'}</p>`;
log(`SwiftSync: closed sum ZERO (32-byte state, ${ms.toFixed(0)}ms); fabricated spend rejected`, zero && tamperRejected ? 'ok' : 'err');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('swiftsync error: ' + e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑫: SwiftSync at scale — stream millions of coins through the 32-byte accumulator (in the worker) ──
document.getElementById('scale').onclick = async () => {
const btn = document.getElementById('scale'); btn.disabled = true;
const $r = document.getElementById('scaleresult'); const N = 3_000_000;
try {
log('— SwiftSync at scale —', 'ok');
log(`streaming ${fmt(N)} outpoints through the accumulator in the worker…`);
// liveness meter: prove the UI stays responsive while the worker churns
let ticks = 0, last = performance.now(), maxGap = 0;
const iv = setInterval(() => { const n = performance.now(); const g = n - last; if (g > maxGap) maxGap = g; last = n; ticks++; }, 16);
_onWorkerProgress = (d) => { if (d.progress === 'scale') $r.innerHTML = `<span class="dim">processing… ${fmt(d.done)} / ${fmt(d.total)}</span>`; };
const r = await workerCall('scaleAccumulate', { n: N });
_onWorkerProgress = null; clearInterval(iv);
const heldGB = (N * 1900 / 1073741824).toFixed(1); // ~1,900 B/coin (measured)
$r.innerHTML = `<table>
<tr><td>coins streamed</td><td class="n">${fmt(r.n)}</td><td class="dim">synthetic outpoints, in the worker</td></tr>
<tr><td>accumulator state</td><td class="n">${r.stateBytes} bytes</td><td class="dim">vs ~${heldGB} GB to hold them · digest ${r.digest}…</td></tr>
<tr><td>throughput</td><td class="n">${fmt(r.perSec)}/s</td><td class="dim">${(r.ms / 1000).toFixed(1)}s · UI responsive (max gap ${maxGap.toFixed(0)}ms)</td></tr>
</table><p class="ok">✅ ${fmt(r.n)} coins committed with 32 bytes of state, UI never froze. The real full set — 14.1M coins — commits the same way (0.9 GB RSS = the file, not 25 GB).</p>`;
log(`scale: ${fmt(r.n)} coins → 32-byte state, ${fmt(r.perSec)}/s, UI responsive (${maxGap.toFixed(0)}ms gap)`, 'ok');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('scale error: ' + e.message, 'err'); console.error(e); }
btn.disabled = false;
};
// ── Path ⑬: full SwiftSync — hints + reconstruct + verify (in the worker) ──
document.getElementById('hints').onclick = async () => {
const btn = document.getElementById('hints'); btn.disabled = true;
const $r = document.getElementById('hintsresult');
try {
log('— SwiftSync hints: generate → reconstruct → verify —', 'ok');
await workerCall('init');
const h = await workerCall('swiftsyncHints');
$r.innerHTML = `<table>
<tr><td>range</td><td class="n">26000–26020</td><td class="dim">${fmt(h.blocks)} blocks · ${h.ms.toFixed(0)}ms</td></tr>
<tr><td>hints file</td><td class="n">${fmt(h.hintsBytes)} B</td><td class="dim">Elias-Fano · ${h.perBlock} B/block · whole chain ≈ ${h.chainMB} MB</td></tr>
<tr><td>reconstructed UTXO</td><td class="n">${fmt(h.reconstructed)} coins</td><td class="dim">from blocks + hints, no spend processing</td></tr>
<tr><td>accumulator verify</td><td class="n ${h.verified ? 'ok' : 'err'}">${h.verified ? 'ZERO ✓' : '✗'}</td><td class="dim">${h.verified ? 'hints honest, set correct' : 'inconsistent'}</td></tr>
</table><p class="${h.verified ? 'ok' : 'err'}">${h.verified ? `✅ UTXO set rebuilt from a ${fmt(h.hintsBytes)}-byte hints file (no spends processed) and verified by the 32-byte accumulator — SwiftSync end to end. The whole testnet4 chain's hints are ≈ ${h.chainMB} MB.` : '❌ hints inconsistent'}</p>`;
log(`SwiftSync hints: ${fmt(h.hintsBytes)}-byte file → reconstructed ${fmt(h.reconstructed)} coins, accumulator-verified (whole chain ≈ ${h.chainMB} MB)`, h.verified ? 'ok' : 'err');
} catch (e) { $r.innerHTML = `<span class="err">error: ${e.message}</span>`; log('hints error: ' + e.message, 'err'); console.error(e); }
btn.disabled = false;
};
log('ready. ① torrent → ③ validate → ④ follow → ⑤ live → ⑥ snapshot → ⑦⑧ persist → ⑨ WASM → ⑩ worker → ⑪⑫⑬ SwiftSync.', 'ok');
</script>
</body>
</html>