-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhow-it-works.html
More file actions
276 lines (233 loc) · 20.6 KB
/
Copy pathhow-it-works.html
File metadata and controls
276 lines (233 loc) · 20.6 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>How to fit a Bitcoin full node in a browser tab — bitcoin-kernel/browser-node</title>
<meta name="description" content="Validating Bitcoin from genesis, in a browser tab, with 32 bytes of state. How browser-node works.">
<style>
:root { --bg:#fff; --fg:#16181d; --mut:#5b6470; --bd:#e6e8eb; --pan:#fafbfc; --ac:#e8830c; --ac2:#0969da; --good:#1a7f37; --bad:#cf222e;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color-scheme:light; }
* { box-sizing:border-box; }
body { background:var(--bg); color:var(--fg); font:17px/1.7 var(--sans); max-width:720px; margin:0 auto; padding:40px 20px 80px; -webkit-font-smoothing:antialiased; }
a { color:var(--ac2); text-decoration:none; } a:hover { text-decoration:underline; }
h1 { font-size:30px; line-height:1.2; letter-spacing:-.02em; margin:0 0 8px; }
h2 { font-size:21px; letter-spacing:-.01em; margin:42px 0 8px; }
.dek { font-size:18px; color:var(--mut); margin:0 0 6px; }
.meta { color:var(--mut); font-size:14px; font-family:var(--mono); border-bottom:1px solid var(--bd); padding-bottom:24px; margin-bottom:8px; }
code { font-family:var(--mono); font-size:.88em; background:var(--pan); border:1px solid var(--bd); border-radius:4px; padding:1px 5px; }
.big { font-family:var(--mono); border-left:3px solid var(--ac); background:var(--pan); padding:12px 16px; margin:22px 0; border-radius:0 6px 6px 0; }
.big b { color:var(--ac); }
blockquote { margin:22px 0; padding-left:18px; border-left:3px solid var(--bd); color:var(--fg); font-size:19px; }
.acts { font-family:var(--mono); font-size:13.5px; background:var(--pan); border:1px solid var(--bd); border-radius:8px; padding:14px 16px; line-height:1.9; }
.foot { margin-top:48px; border-top:1px solid var(--bd); padding-top:18px; color:var(--mut); font-size:14px; }
hr { border:0; border-top:1px solid var(--bd); margin:34px 0; }
em { color:var(--fg); }
figure { margin:30px 0; }
figure svg { width:100%; height:auto; display:block; background:#fff; }
figcaption { color:var(--mut); font-size:13.5px; font-family:var(--mono); text-align:center; margin-top:10px; line-height:1.5; }
.svgtxt { font-family:var(--mono); }
</style>
</head>
<body>
<p class="dek">bitcoin-kernel / browser-node</p>
<h1>How to fit a Bitcoin full node in a browser tab</h1>
<p class="dek">Validating the chain from genesis — proof-of-work, signatures, no double-spends — in a tab, with 32 bytes of state.</p>
<p class="meta">testnet4 · a build log · <a href="index.html">the demo →</a></p>
<p>Open a browser tab and validate Bitcoin in it. Not query an explorer, not trust a server — actually check
the proof-of-work, verify the signatures, enforce the no-double-spend rule, from the genesis block forward.</p>
<p>Four people will tell you this is impossible, and all four are right:</p>
<ul>
<li><b>The chain is gigabytes.</b> Even testnet4 is ~12 GB of blocks.</li>
<li><b>The UTXO set is gigabytes.</b> To check that an input spends a real, unspent coin, a normal node holds the
entire set of unspent outputs in memory. On testnet4 that's <b>14 million coins</b>.</li>
<li><b>A browser can't open a TCP socket.</b> Bitcoin's peer-to-peer protocol is raw TCP on port 8333. A tab can
do WebSocket, WebRTC, and HTTP — and nothing else.</li>
<li><b>JavaScript is too slow.</b> Verifying a single ECDSA signature in pure JS is glacial; a real block can have
thousands.</li>
</ul>
<p>This is the story of beating each one — and of the small, beautiful piece of arithmetic that beats the
hardest of them.</p>
<h2>It starts with a torrent</h2>
<p>The spark was an ordinary question: <em>can we sync testnet4 fast?</em> Modern Bitcoin Core can, using
<b>assumeUTXO</b> — you load a snapshot of the UTXO set at some height, get a usable node in minutes, and validate
the history underneath in the background. The snapshot is just a file. Files can be torrented. So the first
experiment was mundane: download a Core UTXO snapshot over BitTorrent, <code>loadtxoutset</code>, done in a few
minutes instead of hours.</p>
<p>Then the actual idea: <em>WebTorrent runs in a browser.</em> A tab can pull that snapshot peer-to-peer over
WebRTC, no server in the middle. And there's a pure-JavaScript implementation of Bitcoin's consensus rules — the
<a href="https://github.com/bitcoin-kernel/node">bitcoin-kernel</a> engine — that runs anywhere JS does. Put those
together and the absurd premise has a shape: <em>bootstrap a node from a torrented snapshot, in a tab.</em></p>
<h2>Building the node, one "but how do you…" at a time</h2>
<p>Each capability is a small answer to an obvious objection.</p>
<p><b>But how do you hold the coins?</b> A single JavaScript <code>Map</code> throws a tantrum past ~16.7 million
entries, so the coin view is <em>sharded</em> across 64 maps. Lookups land in under 40 nanoseconds — fast enough
that the UTXO set is never the bottleneck during validation.</p>
<p><b>But how do you actually validate a block?</b> You hand the engine the block plus a coin view and it runs the
real rules — prevout resolution, fees, coinbase maturity, the witness commitment, and the scripts: every
signature, checked. To prove it isn't rubber-stamping, flip a single satoshi in one coin's value and the BIP143
signature it commits to fails. The block is rejected. That's the whole game: a forgery has nowhere to hide.</p>
<p><b>But how do you follow the chain?</b> You apply each block to the coin view — remove the coins it spends, add
the ones it creates — so the next block can spend them. Run it over a stretch of real blocks and watch the UTXO
set evolve, every signature verified along the way.</p>
<p><b>But a tab can't open TCP.</b> Right. So a ~40-line <em>bridge</em> relays the raw Bitcoin p2p protocol
between a WebSocket (which the tab speaks) and a real peer (which speaks TCP). The tab does the version handshake,
asks for headers, and validates the entire header chain itself — proof-of-work, the testnet4 difficulty rules,
linkage, reorgs. The bridge is pure plumbing: it can withhold data, but it <em>cannot forge a valid header</em>,
because the tab does the checking. With it, the tab reached the live tip — and caught blocks that were mined
<em>while it was running</em>.</p>
<p><b>But that bridge only ran on localhost.</b> At first, yes. Then it learned to speak <em>WebRTC</em>: the tab and
the bridge meet in a room on a signaling server — a <a href="https://github.com/JavaScriptSolidServer/JavaScriptSolidServer">JavaScript
Solid Server</a> pod — then connect directly, NAT-traversed and encrypted, with no open port and no certificate. So the
bridge can live anywhere, and the live feed works the moment a stranger clicks the link, signaled entirely through
someone's pod. The same WebRTC that delivers the snapshot now carries the p2p stream too. (A separate page just
<a href="tip.html">follows the live tip</a> — validating each new header as testnet4 mines it.)</p>
<figure>
<svg viewBox="0 0 680 290" class="svgtxt" role="img" aria-label="The node lives entirely in the browser tab. A thin WS-to-TCP bridge relays Bitcoin p2p to public peers; WebTorrent delivers the snapshot directly.">
<defs>
<marker id="ar" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto"><path d="M0,0 L8,4.5 L0,9 z" fill="#5b6470"/></marker>
<marker id="arb" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto"><path d="M0,0 L8,4.5 L0,9 z" fill="#0969da"/></marker>
</defs>
<!-- tab -->
<rect x="18" y="46" width="250" height="208" rx="12" fill="#fafbfc" stroke="#e6e8eb"/>
<line x1="18" y1="76" x2="268" y2="76" stroke="#e6e8eb"/>
<circle cx="36" cy="61" r="4" fill="#e6e8eb"/><circle cx="50" cy="61" r="4" fill="#e6e8eb"/><circle cx="64" cy="61" r="4" fill="#e6e8eb"/>
<text x="156" y="66" text-anchor="middle" font-size="13" font-weight="700" fill="#16181d">Browser tab — the node</text>
<g font-size="11" fill="#16181d" text-anchor="middle">
<rect x="32" y="90" width="100" height="32" rx="6" fill="#fff" stroke="#e6e8eb"/><text x="82" y="110">consensus engine</text>
<rect x="146" y="90" width="106" height="32" rx="6" fill="#fff" stroke="#e6e8eb"/><text x="199" y="110">UTXO · 32-byte Σ</text>
<rect x="32" y="130" width="100" height="32" rx="6" fill="#fff" stroke="#e6e8eb"/><text x="82" y="150">WASM secp256k1</text>
<rect x="146" y="130" width="106" height="32" rx="6" fill="#fff" stroke="#e6e8eb"/><text x="199" y="150">OPFS storage</text>
</g>
<text x="143" y="188" text-anchor="middle" font-size="11.5" fill="#1a7f37">every rule checked in the tab ✓</text>
<text x="143" y="208" text-anchor="middle" font-size="10.5" fill="#5b6470">no server trusted</text>
<!-- upper path: bridge -> peers -->
<line x1="268" y1="110" x2="320" y2="110" stroke="#5b6470" marker-end="url(#ar)"/>
<text x="385" y="73" text-anchor="middle" font-size="10" fill="#e8830c">the only non-browser piece ↓</text>
<rect x="326" y="88" width="118" height="44" rx="8" fill="#fff" stroke="#e8830c" stroke-width="1.5"/>
<text x="385" y="106" text-anchor="middle" font-size="11.5" font-weight="700" fill="#16181d">WS↔TCP bridge</text>
<text x="385" y="121" text-anchor="middle" font-size="10" fill="#5b6470">≈40-line relay</text>
<line x1="444" y1="110" x2="496" y2="110" stroke="#5b6470" marker-end="url(#ar)"/>
<rect x="502" y="88" width="160" height="44" rx="8" fill="#fafbfc" stroke="#e6e8eb"/>
<text x="582" y="106" text-anchor="middle" font-size="11.5" font-weight="700" fill="#16181d">public testnet4 peers</text>
<text x="582" y="121" text-anchor="middle" font-size="10" fill="#5b6470">Bitcoin p2p</text>
<text x="385" y="150" text-anchor="middle" font-size="10" fill="#5b6470">headers · blocks</text>
<!-- lower path: webtorrent, direct -->
<path d="M268,210 C 330,210 340,214 392,214" fill="none" stroke="#0969da" stroke-dasharray="4 3" marker-end="url(#arb)"/>
<text x="330" y="203" font-size="9.5" fill="#0969da">WebRTC, direct</text>
<rect x="398" y="192" width="264" height="46" rx="8" fill="#fff" stroke="#0969da" stroke-opacity=".5"/>
<text x="530" y="211" text-anchor="middle" font-size="11.5" font-weight="700" fill="#16181d">WebTorrent swarm</text>
<text x="530" y="227" text-anchor="middle" font-size="9.5" fill="#5b6470">snapshot + hints — browser-native, no relay</text>
</svg>
<figcaption>Everything that validates lives in the tab. The bridge is the one piece a browser can't be — a dumb<br>byte-relay that can withhold but never forge. The snapshot comes peer-to-peer, straight into the page.</figcaption>
</figure>
<p><b>But the snapshot is in Bitcoin Core's format.</b> So you write a parser for it — Core's <code>dumptxoutset</code>
v2 layout, with its compressed amounts and script encodings. The proof that the parser is correct is delicious:
feed a real block's real signatures against coins decoded from the file, and they verify. A segwit signature
commits to the amount and the script, so if a single byte of the decompression were wrong, the signatures would
fail. They pass. The torrented Core snapshot is now a working coin view in the tab.</p>
<p><b>But it forgets everything on reload.</b> So it persists to <b>OPFS</b>, the browser's origin-private file
system. The header chain is checkpointed to disk; a reload <em>resumes from disk in under two seconds</em> instead
of re-syncing 140,000 headers from genesis.</p>
<p><b>But JavaScript is slow at crypto.</b> So you drop in a <b>WebAssembly</b> build of libsecp256k1 — but only
behind a gate: it must agree with the pure-JS verifier on every test vector before it's trusted. It does, and it's
several times faster.</p>
<p><b>But heavy validation freezes the tab.</b> So the whole engine — validation, the coin view, the WASM crypto,
the disk I/O — moves into a <b>Web Worker</b>. The same work that locks the UI for a full second on the main
thread runs in the worker with the page staying perfectly responsive.</p>
<p>Piece by piece, the tab became a node: bootstrap, validate, follow, sync live, persist, accelerate. And then it
hit a wall that no amount of engineering gets through.</p>
<h2>The 25-gigabyte wall</h2>
<p>To validate the chain forward to the present, you need the present UTXO set. So: load the real one, all 14
million coins, and measure honestly what it costs in memory.</p>
<div class="big">14,129,063 coins, held as a coin view: ~<b>1,900 bytes each</b> → about <b>25 GB</b> of RAM.</div>
<p>It didn't even fit in Node with a 24 GB heap — it crashed. A browser tab gets a few gigabytes at most. There is
no clever encoding that turns 25 GB into something a tab can hold; you can shave it, but not by an order of
magnitude. Holding the full UTXO set in a tab is simply not on the menu.</p>
<p>This is the honest moment in the project. Everything up to here was engineering. This was a wall.</p>
<h2>The punchline: 32 bytes</h2>
<p>Here is the idea that walks through the wall, and it's the kind of thing that makes you grin. It comes from a
2025 proposal called <a href="https://gist.github.com/RubenSomsen/a61a37d14182ccd78760e477c78133cd">SwiftSync</a>,
and it rests on a single line of arithmetic:</p>
<blockquote>every output ever created − every input ever spent = the UTXO set</blockquote>
<p>Read it again, because it's the whole trick. Every coin that gets created is eventually spent — <em>except</em>
the ones that make up the current UTXO set. So if you take the set of all outputs and subtract the set of all
inputs, everything that was created-then-spent cancels, and what survives is exactly the unspent coins.</p>
<p>Now do that subtraction with a <em>hash</em> instead of a giant table. Keep one running number. As you stream
the chain block by block, <b>add</b> a hash of every output created and <b>subtract</b> a hash of every input
spent. A coin that's created and later spent is added once and subtracted once — it vanishes. What's left at the
end is a fingerprint of precisely the coins still unspent: the UTXO set, distilled to a constant
<b>32 bytes</b>.</p>
<figure>
<svg viewBox="0 0 680 132" class="svgtxt" role="img" aria-label="Add a hash for every created output, subtract one for every spent input. Created-then-spent coins cancel; the unspent survivors are the whole UTXO set, held as 32 bytes.">
<defs><marker id="ar2" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto"><path d="M0,0 L8,4.5 L0,9 z" fill="#5b6470"/></marker></defs>
<text x="196" y="20" text-anchor="middle" font-size="10.5" fill="#5b6470">created, then later spent → cancels</text>
<path d="M76,52 C 120,28 232,28 272,52" fill="none" stroke="#5b6470" stroke-opacity=".35" stroke-dasharray="3 3"/>
<path d="M136,52 C 188,36 296,36 332,52" fill="none" stroke="#5b6470" stroke-opacity=".3" stroke-dasharray="3 3"/>
<g font-size="12" font-weight="700" text-anchor="middle">
<g opacity=".45"><rect x="54" y="52" width="44" height="30" rx="6" fill="#fff" stroke="#1a7f37"/><text x="76" y="72" fill="#1a7f37">+A</text></g>
<g opacity=".45"><rect x="114" y="52" width="44" height="30" rx="6" fill="#fff" stroke="#1a7f37"/><text x="136" y="72" fill="#1a7f37">+B</text></g>
<rect x="174" y="50" width="44" height="34" rx="6" fill="#eaf6ee" stroke="#1a7f37" stroke-width="1.6"/><text x="196" y="72" fill="#1a7f37">+C</text>
<g opacity=".45"><rect x="250" y="52" width="44" height="30" rx="6" fill="#fff" stroke="#e8830c"/><text x="272" y="72" fill="#e8830c">−A</text></g>
<g opacity=".45"><rect x="310" y="52" width="44" height="30" rx="6" fill="#fff" stroke="#e8830c"/><text x="332" y="72" fill="#e8830c">−B</text></g>
</g>
<text x="196" y="103" text-anchor="middle" font-size="9.5" fill="#1a7f37">never spent → survives</text>
<line x1="362" y1="67" x2="416" y2="67" stroke="#5b6470" marker-end="url(#ar2)"/>
<rect x="422" y="44" width="210" height="46" rx="9" fill="#fafbfc" stroke="#e6e8eb"/>
<text x="527" y="64" text-anchor="middle" font-size="12" font-weight="700" fill="#16181d">= the unspent set · 32 bytes</text>
<text x="527" y="80" text-anchor="middle" font-size="10" fill="#5b6470">two 128-bit lanes, always</text>
</svg>
<figcaption>Add a hash for every output created, subtract one for every input spent. Created-then-spent pairs<br>vanish; the survivors are the UTXO set — and the running total is always just 32 bytes.</figcaption>
</figure>
<div class="big">We streamed the full testnet4 UTXO set — all <b>14,129,063 coins</b> — through this accumulator.
Memory stayed flat at the size of the input, never the set. The set-state never left <b>32 bytes</b>.
What was 25 GB is now a number you could write on a napkin.</div>
<figure>
<svg viewBox="0 0 680 100" class="svgtxt" role="img" aria-label="25 gigabytes held in RAM versus a 32-byte number — the same set, the same guarantee.">
<defs><marker id="ar3" markerWidth="9" markerHeight="9" refX="7.5" refY="4.5" orient="auto"><path d="M0,0 L8,4.5 L0,9 z" fill="#5b6470"/></marker></defs>
<rect x="18" y="26" width="470" height="50" rx="8" fill="#fdecec" stroke="#cf222e" stroke-opacity=".55"/>
<text x="253" y="48" text-anchor="middle" font-size="13" font-weight="700" fill="#16181d">25 GB held in RAM</text>
<text x="253" y="65" text-anchor="middle" font-size="10.5" fill="#5b6470">14,129,063 coins · crashed a 24 GB heap</text>
<line x1="494" y1="51" x2="526" y2="51" stroke="#5b6470" marker-end="url(#ar3)"/>
<rect x="532" y="35" width="130" height="32" rx="8" fill="#eaf6ee" stroke="#1a7f37"/>
<text x="597" y="55" text-anchor="middle" font-size="13" font-weight="700" fill="#1a7f37">32 bytes</text>
</svg>
<figcaption>The same set, the same guarantee against double-spends and forgeries. Only one of them fits in a tab.</figcaption>
</figure>
<p>And it's not a trust shortcut. If a single block tried to spend a coin that never existed, or spend one twice,
the sums wouldn't cancel — the final number wouldn't match the known commitment, and validation fails. The hints
that make it fast (a tiny file marking which outputs survive — about <b>25 bytes per block</b>, a few megabytes
for the whole chain) carry no authority either: wrong hints just make the check fail. They can't make a bad chain
look good.</p>
<p>So the demo does the thing the wall said was impossible. It streams blocks <em>from the genesis block forward</em>,
in the tab, in a worker, feeding each output and input into the accumulator — and verifies the result against an
independently computed commitment. The whole early chain checks out holding nothing but those 32 bytes. The rest of
the chain is the same loop; it's just more data to stream (and most of that data is inscription witness bytes the
accumulator doesn't even need).</p>
<h2>What it is, and what it isn't</h2>
<p>Be honest about the edges. The live feed needs that little bridge — a tab will never speak raw TCP, and that
bridge can eclipse you even though it can't forge anything. assumeUTXO trusts a snapshot until the history beneath
it is checked. And the full genesis-to-tip stream is bound by bandwidth, not by cleverness — it's the one lap left
to run end to end in a tab.</p>
<p>But every load-bearing piece is built, measured, and live: real consensus validation, a real Core snapshot
parsed and validated against, a live peer feed, persistence, WASM crypto, a worker for scale, and — the one that
matters most — validation that doesn't need the 25 GB set at all. Thirteen small demonstrations, one running-node
dashboard, and a from-genesis run, in a 3.8 MB repository where the largest file is the libsecp256k1 binary.</p>
<div class="acts">
① bootstrap from a torrented snapshot · ③ validate a block forward · ④ follow the chain ·
⑤ live header feed over the bridge · ⑥ parse Core's <code>dumptxoutset</code> · ⑦⑧ persist to OPFS ·
⑨ WASM secp256k1 · ⑩ run it in a Worker · ⑪ SwiftSync, stateless · ⑫ the full set in 32 bytes ·
⑬ hints → reconstruct → verify
</div>
<p>The point was never a product. It was to find out whether a Bitcoin full node in a browser tab is a contradiction
in terms — and to answer it honestly, with running code and real numbers. It isn't a contradiction. The hardest
wall came down to a subtraction.</p>
<div class="foot">
Try it: <a href="index.html">the acts, explained</a> · <a href="node.html">the running node</a> ·
<a href="tip.html">follow the tip</a> · <a href="fullchain.html">validate from genesis</a> ·
<a href="https://github.com/bitcoin-kernel/browser-node">source</a><br>
Built on <a href="https://github.com/bitcoin-kernel/node">bitcoin-kernel/node</a> and the
<code>@bitcoin-desktop/schema</code> consensus engine. AGPL-3.0.
</div>
</body>
</html>