Skip to content

Commit 5dbc145

Browse files
Melvin Carvalhoclaude
andcommitted
Add mesh.html — browser↔browser block propagation (PoC)
Open in two tabs: they become Bitcoin peers over a direct WebRTC data channel (meeting in a room on a JSS pod, no server in the data path), then relay a block inv → getdata → block, validating it (PoW + merkle). New, additive; nothing existing touched. - peer-mesh.js: MeshPeer — joins a room, offerer/answerer by join order, non-trickle ICE; speaks real Bitcoin messages over the channel. - test-mesh.mjs: node-side proof — two peers relay block #26000, validated (29 txs, correct hash), no server in the path. - mesh's own room (distinct from the bridge's room). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 61339c4 commit 5dbc145

3 files changed

Lines changed: 283 additions & 0 deletions

File tree

‎mesh.html‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>bitcoin-kernel/browser-node — mesh: browser↔browser block propagation</title>
7+
<style>
8+
: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; }
9+
body { background:var(--bg); color:var(--fg); font:14px/1.5 var(--mono); max-width:760px; margin:24px auto; padding:0 16px; }
10+
h1 { font-size:18px; } a { color:var(--ac2); }
11+
button { background:var(--pan); color:var(--fg); border:1px solid var(--bd); border-radius:6px; padding:9px 16px; cursor:pointer; font:inherit; }
12+
button:hover { background:#f0f2f4; border-color:var(--ac); } button:disabled { opacity:.45; cursor:default; }
13+
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:14px 0; }
14+
.card { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--pan); }
15+
.card .k { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
16+
.card .v { font-size:20px; color:var(--fg); margin-top:3px; }
17+
.card .s { color:var(--mut); font-size:12px; }
18+
#log { background:var(--pan); border:1px solid var(--bd); border-radius:6px; padding:12px; height:200px; overflow:auto; white-space:pre-wrap; margin-top:12px; }
19+
.ok{color:var(--good)} .warn{color:var(--ac)} .err{color:var(--bad)} .dim{color:var(--mut)}
20+
.dot{ display:inline-block; width:9px; height:9px; border-radius:50%; background:var(--mut); margin-right:6px; vertical-align:middle; }
21+
.dot.live{ background:var(--good); animation:pulse 2s ease-in-out infinite; }
22+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
23+
</style>
24+
</head>
25+
<body>
26+
<h1>bitcoin-kernel / browser-node — mesh <span class="dim">(block propagation · PoC)</span></h1>
27+
<p class="dim"><b>Open this page in two tabs</b> (or send the link to a friend). The two tabs become Bitcoin peers to each other
28+
over <b>WebRTC — no server in the data path</b> — meeting in a room on a signaling server. Then one tab <b>relays a block</b> to
29+
the other: it announces it (<code>inv</code>), the other requests it (<code>getdata</code>), receives the block, and
30+
<b>validates it</b> (proof-of-work + merkle root). That's block propagation, in a browser mesh.
31+
<a href="node.html">the node</a> · <a href="how-it-works.html">how it works</a></p>
32+
33+
<p>
34+
<button id="connect">▶ Connect to the mesh</button>
35+
<button id="share" disabled>📤 Relay block #26,000 to the peer</button>
36+
<span id="status" class="dim">idle</span>
37+
</p>
38+
39+
<div class="grid">
40+
<div class="card"><div class="k"><span class="dot" id="dot"></span>mesh</div><div class="v" id="mesh">—</div><div class="s" id="role">browser ↔ browser</div></div>
41+
<div class="card"><div class="k">blocks relayed</div><div class="v" id="relayed">0</div><div class="s">inv → getdata → block</div></div>
42+
<div class="card"><div class="k">last block validated</div><div class="v" id="last">—</div><div class="s" id="lasthash">PoW + merkle</div></div>
43+
</div>
44+
<div id="log"></div>
45+
46+
<p class="dim" style="margin-top:14px; border-top:1px solid var(--bd); padding-top:10px">
47+
PoC scope: the relayed block is a bundled sample (<code>data/block-26000.hex</code>), validated for structure + proof-of-work
48+
(no UTXO set, so not full-consensus). Next steps: source the block live from the network (via the bridge), relay across more
49+
than two peers, and stream the full chain. Default room <code>c0ffeebabe123456</code>; override with
50+
<code>?room=&lt;hex&gt;</code> and the signaling server with <code>?signal=wss://your.pod/.webrtc</code>.</p>
51+
52+
<script type="module">
53+
import { Codec } from './engine/codec/codec.js';
54+
import { P2pEngine } from './engine/codec/p2p.js';
55+
import { BlockEngine } from './engine/codec/blocks.js';
56+
import { MeshPeer } from './peer-mesh.js';
57+
58+
const $ = (id) => document.getElementById(id);
59+
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; };
60+
const fmt = (n) => Number(n).toLocaleString();
61+
const setStatus = (m, c='') => $('status').innerHTML = `<span class="${c}">${m}</span>`;
62+
const MSG_BLOCK = 2;
63+
64+
const q = new URLSearchParams(location.search);
65+
const signalUrl = q.get('signal') || 'wss://melvincarvalho.com/.webrtc';
66+
const room = q.get('room') || 'c0ffeebabe123456'; // mesh's own room (distinct from the bridge's room)
67+
68+
let codec, p2p, be, peer = null;
69+
const have = new Map(); // blockHash -> hex (blocks we can serve)
70+
let relayed = 0;
71+
72+
async function loadEngine() {
73+
const jl = (n) => fetch(`./engine/schema/${n}.jsonld`).then(r => r.json());
74+
const [core, proof, p2pS, chain, validate, script] = await Promise.all(['core','proof','p2p','chain','validate','script'].map(jl));
75+
codec = new Codec(core, proof, p2pS);
76+
p2p = P2pEngine.fromSchemas(codec, p2pS, chain, 'btc:testnet4');
77+
be = BlockEngine.fromSchemas(codec, chain, validate, script, 'btc:testnet4');
78+
}
79+
80+
// Handle a Bitcoin message arriving from the mesh peer.
81+
function onMessage(msg) {
82+
if (msg.command === 'inv') {
83+
for (const it of msg.payload?.items ?? []) {
84+
if (!have.has(it.hash)) { log(`← inv: peer has block ${it.hash.slice(0,16)}… → requesting (getdata)`); peer.send('getdata', { items: [{ type: MSG_BLOCK, hash: it.hash }] }); }
85+
}
86+
} else if (msg.command === 'getdata') {
87+
for (const it of msg.payload?.items ?? []) {
88+
const hex = have.get(it.hash);
89+
if (hex) { log(`← getdata for ${it.hash.slice(0,16)}… → sending the block`); peer.send('block', hex); }
90+
}
91+
} else if (msg.command === 'block') {
92+
const block = msg.payload;
93+
const hash = codec.blockHash(block.header);
94+
const failed = be.validateBlockStructure(block).results.filter(r => r.ok === false).map(r => r.rule);
95+
if (failed.length) { log(`← block ${hash.slice(0,16)}… ✗ INVALID: ${failed.join(', ')}`, 'err'); return; }
96+
const reHex = Array.from(codec.encode('Block', block), (b) => b.toString(16).padStart(2, '0')).join('');
97+
have.set(hash, reHex); // now we hold it too — could relay onward (mesh)
98+
relayed++; $('relayed').textContent = fmt(relayed);
99+
$('last').textContent = '#26,000'; $('lasthash').textContent = hash.slice(0, 22) + '…';
100+
log(`✅ received + validated block ${hash.slice(0,16)}… (${block.transactions.length} txs, PoW + merkle ✓) — from a browser peer, no server`, 'ok');
101+
}
102+
}
103+
104+
$('connect').onclick = async () => {
105+
$('connect').disabled = true;
106+
try {
107+
setStatus('loading engine…', 'warn');
108+
if (!be) await loadEngine();
109+
setStatus(`joining the mesh room ${room.slice(0,10)}…`, 'warn');
110+
log(`connecting to the mesh (signaling ${signalUrl})…`);
111+
peer = new MeshPeer(p2p, codec, { log, onMessage, onOpen: (role) => {
112+
$('dot').classList.add('live'); $('mesh').textContent = 'connected';
113+
$('role').textContent = `you are the ${role}`;
114+
$('share').disabled = false;
115+
setStatus('● connected to a peer — relay a block', 'ok');
116+
log(`mesh connected (you are the ${role}) — a direct browser↔browser channel, no server in the path`, 'ok');
117+
} });
118+
await peer.connect(signalUrl, { room });
119+
} catch (e) {
120+
setStatus('mesh error: ' + e.message, 'err');
121+
log('mesh: ' + e.message + ' — is the page open in a second tab? (one tab waits for the other)', 'err');
122+
$('connect').disabled = false;
123+
}
124+
};
125+
126+
$('share').onclick = async () => {
127+
try {
128+
const hex = (await (await fetch('./data/block-26000.hex')).text()).trim();
129+
const block = codec.decode('Block', hex);
130+
const hash = codec.blockHash(block.header);
131+
have.set(hash, hex);
132+
log(`announcing block #26,000 ${hash.slice(0,16)}… to the peer (inv)`, 'ok');
133+
peer.send('inv', { items: [{ type: MSG_BLOCK, hash }] });
134+
} catch (e) { log('share failed: ' + e.message, 'err'); }
135+
};
136+
137+
log(`idle — press ▶ Connect, then open this page in a second tab. Room ${room.slice(0,10)}… via ${signalUrl}.`, 'dim');
138+
</script>
139+
</body>
140+
</html>

‎peer-mesh.js‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// MeshPeer: a browser-to-browser Bitcoin peer. Two tabs join the same room on a
2+
// signaling server (a JSS pod's /.webrtc) and connect DIRECTLY over a WebRTC data
3+
// channel — no server in the data path — then speak real Bitcoin p2p messages
4+
// (inv / getdata / block). This is the propagation transport: a tab can relay a
5+
// block to another tab. Role is decided by join order: the peer already in the
6+
// room answers; a later joiner offers (so two tabs pair up deterministically).
7+
//
8+
// Non-trickle ICE (room signaling has no candidate relay): gather candidates into
9+
// the SDP before sending. Mirrors the proven node-side relay (rtc-signaling.mjs).
10+
export class MeshPeer {
11+
constructor(engine, codec, { onOpen, onMessage, log } = {}) {
12+
this.engine = engine; this.codec = codec;
13+
this.onOpen = onOpen; this.onMessage = onMessage; this.log = log || (() => {});
14+
this.buf = new Uint8Array(0);
15+
this.dc = null; this.pc = null; this.ws = null; this.role = null;
16+
}
17+
18+
connect(signalUrl, { room = 'b17c0192abad1deacafe', iceServers = [{ urls: 'stun:stun.l.google.com:19302' }], timeout = 25000 } = {}) {
19+
return new Promise((resolve, reject) => {
20+
const ws = this.ws = new WebSocket(signalUrl);
21+
const offerId = 'o' + Math.random().toString(36).slice(2);
22+
let settled = false, announceTimer = null, offerSdp = null;
23+
const stop = () => { clearTimeout(to); clearInterval(announceTimer); };
24+
const to = setTimeout(() => { if (!settled) { settled = true; stop(); reject(new Error('mesh connect timeout')); } }, timeout);
25+
26+
const setupDC = (dc) => {
27+
this.dc = dc; dc.binaryType = 'arraybuffer';
28+
dc.onopen = () => { if (settled) return; settled = true; stop(); this.onOpen?.(this.role); resolve(this); };
29+
dc.onmessage = (ev) => this.#onData(new Uint8Array(ev.data));
30+
dc.onclose = () => this.log('mesh peer disconnected', 'warn');
31+
};
32+
33+
// Later joiner → offerer: create the channel + offer, announce it.
34+
const becomeOfferer = async () => {
35+
this.role = 'offerer';
36+
const pc = this.pc = new RTCPeerConnection({ iceServers });
37+
setupDC(pc.createDataChannel('mesh'));
38+
await pc.setLocalDescription(await pc.createOffer());
39+
await iceComplete(pc);
40+
offerSdp = pc.localDescription.sdp;
41+
const announce = () => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'announce', resource: room, offers: [{ sdp: offerSdp, offer_id: offerId }] })); };
42+
announce(); announceTimer = setInterval(announce, 2500);
43+
};
44+
45+
// Already in the room → answerer: answer an incoming offer.
46+
const answerOffer = async (m) => {
47+
this.role = 'answerer';
48+
const pc = this.pc = new RTCPeerConnection({ iceServers });
49+
pc.ondatachannel = (ev) => setupDC(ev.channel);
50+
await pc.setRemoteDescription({ type: 'offer', sdp: m.sdp });
51+
await pc.setLocalDescription(await pc.createAnswer());
52+
await iceComplete(pc);
53+
ws.send(JSON.stringify({ type: 'answer', resource: room, to: m.from, offer_id: m.offer_id, sdp: pc.localDescription.sdp }));
54+
};
55+
56+
ws.onopen = () => ws.send(JSON.stringify({ type: 'announce', resource: room, offers: [] })); // join, learn who's here
57+
ws.onmessage = async (ev) => {
58+
let m; try { m = JSON.parse(ev.data); } catch { return; }
59+
if (m.type === 'resource-peers' && this.role === null) {
60+
if (m.count > 0) becomeOfferer().catch((e) => { if (!settled) { settled = true; stop(); reject(e); } });
61+
else { this.role = 'waiting'; this.log('in the room — waiting for a peer to join (open this page in another tab)…'); }
62+
} else if (m.type === 'offer' && m.resource === room && this.role === 'waiting') {
63+
answerOffer(m).catch((e) => this.log('answer failed: ' + e.message, 'warn'));
64+
} else if (m.type === 'answer' && m.resource === room && m.offer_id === offerId && this.role === 'offerer' && this.pc && !this.pc.currentRemoteDescription) {
65+
try { await this.pc.setRemoteDescription({ type: 'answer', sdp: m.sdp }); } catch {}
66+
}
67+
};
68+
ws.onerror = () => { if (!settled) { settled = true; stop(); reject(new Error('signaling error')); } };
69+
});
70+
}
71+
72+
send(command, payload) { if (this.dc?.readyState === 'open') this.dc.send(this.engine.encodeMessage(command, payload)); }
73+
close() { try { this.dc?.close(); } catch {} try { this.pc?.close(); } catch {} try { this.ws?.close(); } catch {} }
74+
75+
#onData(chunk) {
76+
const merged = new Uint8Array(this.buf.length + chunk.length);
77+
merged.set(this.buf); merged.set(chunk, this.buf.length);
78+
const { messages, consumed } = this.engine.decodeStream(merged);
79+
this.buf = merged.slice(consumed);
80+
for (const msg of messages) this.onMessage?.(msg);
81+
}
82+
}
83+
84+
function iceComplete(pc) {
85+
return new Promise((res) => {
86+
if (pc.iceGatheringState === 'complete') return res();
87+
const c = () => { if (pc.iceGatheringState === 'complete') { pc.removeEventListener('icegatheringstatechange', c); res(); } };
88+
pc.addEventListener('icegatheringstatechange', c);
89+
});
90+
}

‎test-mesh.mjs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// De-risk the mesh relay: two peers join the same room (no bridge, no server in
2+
// the path) and relay a Bitcoin block between them over a direct WebRTC data
3+
// channel. Proves the browser↔browser propagation transport that mesh.html uses
4+
// (here both ends are node-datachannel; the browser mirrors it with RTCPeerConnection).
5+
import { readFile } from 'node:fs/promises';
6+
import nodeDataChannel from 'node-datachannel';
7+
import { Codec } from './engine/codec/codec.js';
8+
import { P2pEngine } from './engine/codec/p2p.js';
9+
import { startSignaling } from './signaling-stub.mjs';
10+
import { connectAsOfferer, connectAsAnswerer } from './rtc-signaling.mjs';
11+
12+
const D = new URL('./', import.meta.url);
13+
const jl = async (n) => JSON.parse(await readFile(new URL(`engine/schema/${n}.jsonld`, D), 'utf8'));
14+
const PORT = 9095, ROOM = 'b17c0192abad1deacafe';
15+
const SIGNAL = `ws://localhost:${PORT}/.webrtc`;
16+
17+
const codec = new Codec(await jl('core'), await jl('proof'), await jl('p2p'));
18+
const p2p = P2pEngine.fromSchemas(codec, await jl('p2p'), await jl('chain'), 'btc:testnet4');
19+
const blockHex = (await readFile(new URL('data/block-26000.hex', D), 'utf8')).trim();
20+
const expectHash = codec.blockHash(codec.decode('Block', blockHex).header);
21+
const log = (m) => console.log(m);
22+
23+
const sig = startSignaling(PORT, { log });
24+
let done = false;
25+
const end = (c) => { if (done) return; done = true; try { sig.close(); } catch {} try { nodeDataChannel.cleanup(); } catch {} setImmediate(() => process.exit(c)); };
26+
const to = setTimeout(() => { console.log('\n❌ timeout'); end(1); }, 30000);
27+
28+
log(`\nrelaying block #26000 peer→peer in room ${ROOM} (no server in the data path)…`);
29+
30+
// Peer A = the source: answerer. When a peer connects, it serves the block.
31+
connectAsAnswerer({ signalUrl: SIGNAL, room: ROOM, log, onChannel: (dc) => {
32+
dc.onMessage((m) => { if ((Buffer.isBuffer(m) ? m.toString() : String(m)) === 'getdata') serve(dc); });
33+
dc.onOpen(() => { log(' A: peer connected → announcing it has block #26000 (inv)'); dc.sendMessage('inv:26000'); });
34+
} });
35+
function serve(dc) { log(' A: got getdata → sending the block over WebRTC'); dc.sendMessageBinary(Buffer.from(p2p.encodeMessage('block', blockHex))); }
36+
37+
// Peer B = the receiver: offerer. Asks for the block, validates what arrives.
38+
const B = await connectAsOfferer({ signalUrl: SIGNAL, room: ROOM, log });
39+
let buf = Buffer.alloc(0);
40+
B.dc.onMessage((m) => {
41+
if (typeof m === 'string' || (!Buffer.isBuffer(m) && !(m instanceof Uint8Array))) {
42+
if (String(m).startsWith('inv:')) { log(' B: received inv → requesting the block (getdata)'); B.dc.sendMessage('getdata'); }
43+
return;
44+
}
45+
buf = Buffer.concat([buf, Buffer.isBuffer(m) ? m : Buffer.from(m)]);
46+
const { messages, consumed } = p2p.decodeStream(new Uint8Array(buf)); buf = buf.subarray(consumed);
47+
for (const msg of messages) if (msg.command === 'block') {
48+
const h = codec.blockHash(msg.payload.header);
49+
clearTimeout(to);
50+
if (h === expectHash) { log(`\n✅ B received + validated block #26000 from peer A over WebRTC — ${h.slice(0, 16)}… (${msg.payload.transactions.length} txs)`); log(' browser↔browser block propagation works.'); end(0); }
51+
else { log('\n❌ hash mismatch'); end(1); }
52+
}
53+
});

0 commit comments

Comments
 (0)