-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathspv.html
More file actions
192 lines (178 loc) · 13.2 KB
/
Copy pathspv.html
File metadata and controls
192 lines (178 loc) · 13.2 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
<!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 — watch-only SPV wallet (testnet4)</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.55 var(--mono); max-width:780px; margin:24px auto; padding:0 16px 60px; }
h1 { font-size:18px; } a { color:var(--ac2); }
button { background:var(--pan); color:var(--fg); border:1px solid var(--bd); border-radius:6px; padding:9px 15px; cursor:pointer; font:inherit; }
button:hover { background:#f0f2f4; border-color:var(--ac); } button:disabled { opacity:.45; cursor:default; }
input { font:inherit; border:1px solid var(--bd); border-radius:6px; padding:8px 9px; width:100%; box-sizing:border-box; }
label { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
table { border-collapse:collapse; width:100%; margin-top:14px; }
td, th { border:1px solid var(--bd); padding:7px 9px; text-align:left; }
th { background:var(--pan); color:var(--mut); font-weight:700; font-size:12px; }
td.addr { word-break:break-all; }
.pill { display:inline-block; font-size:11px; border:1px solid var(--bd); border-radius:999px; padding:1px 8px; color:var(--mut); }
.ok{color:var(--good)} .err{color:var(--bad)} .dim{color:var(--mut)}
.row { display:flex; gap:8px; align-items:center; margin:10px 0; }
code { background:var(--pan); border:1px solid var(--bd); border-radius:4px; padding:1px 5px; font-size:.92em; }
</style>
</head>
<body>
<h1>bitcoin-kernel / browser-node — watch-only SPV wallet <span class="dim">(testnet4)</span></h1>
<p class="dim">A <b>watch-only</b> wallet: paste an extended public key (<code>tpub…</code> for testnet, <code>xpub…</code> for mainnet)
and it derives your receiving <b>addresses</b> — <b>no private keys anywhere</b> (this codebase is verification-only). Below,
it <b>verifies SPV inclusion proofs</b> (②) and <b>scans the chain</b> for your payments + balance (③).
Generate your own wallet on <a href="keys.html">the keys page</a>. <a href="how-it-works.html">how it works</a> · <a href="node.html">the node</a></p>
<div class="row"><label style="min-width:120px">extended pubkey</label></div>
<input id="xkey" spellcheck="false" value="tpubDCxX2sYFS5bDkSe5GKKYHjBW7tgyN1R3UchpLJvdbf54ohxeGRtd8MbDUe1cguVHe4vnK68DsuD5MXjxi9EXx16rb9EnNsaF5KT99CinaJz">
<div class="row" style="margin-top:10px">
<button id="derive">▶ Derive addresses</button>
<span class="dim">count <input id="count" type="number" value="5" min="1" max="50" style="width:60px"></span>
<span id="net" class="pill">—</span>
<span id="status" class="dim"></span>
</div>
<div id="out"></div>
<p style="font-weight:700; font-size:15px; margin:28px 0 4px">② Verify a transaction is in a block <span class="dim">— SPV</span></p>
<p class="dim">An SPV client confirms a payment with a tiny <b>merkle proof</b> plus the block header — no full block, no full chain.
Here we take a real testnet4 block (#26,000), build a BIP37 proof for one transaction, and verify it (proof-of-work, a well-formed
tree, the root matching the header, and the tx included). In a real wallet the proof comes from a peer; we build it here to show the check.</p>
<div class="row">
<button id="verify">▸ Verify inclusion</button>
<span class="dim">tx index <input id="txi" type="number" value="7" min="0" style="width:60px"></span>
<span id="spvstatus" class="dim"></span>
</div>
<div id="spvout"></div>
<p style="font-weight:700; font-size:15px; margin:28px 0 4px">③ Scan the chain for your payments</p>
<p class="dim">Watch your first 20 receiving addresses for payments. With no compact-filter peer available, this is a
<b>full-block scan</b> over the bridge from a start height to the tip — flat memory, cheap for a recent range (first run
syncs headers, ~15s, then cached). Fund <code>0/0</code> from a faucet, set the start height near when you funded, and Scan. (Needs the bridge.)</p>
<div class="row">
<button id="scan">▸ Scan for payments</button>
<span class="dim">from height <input id="start" type="number" placeholder="tip−200" min="1" style="width:100px"></span>
<span id="scanstatus" class="dim"></span>
</div>
<div id="scanout"></div>
<p class="dim" style="margin-top:18px; border-top:1px solid var(--bd); padding-top:10px">
Watch-only, BIP32 (BIP84 <code>m/84'/net'/0'</code> account · receiving chain <code>0/i</code>, P2WPKH). Derivation verified
against the BIP84 vector (<code>test-spv-derive.mjs</code>); SPV proof against a real block (<code>test-spv-proof.mjs</code>); scan
against the network (<code>test-scan.mjs</code>). The default key is an <b>example</b> — generate your own on <a href="keys.html">the keys page</a>.</p>
<script type="module">
import { Bip32 } from './engine/codec/wallet.js';
import { ScriptEngine } from './engine/codec/script.js';
import { Codec } from './engine/codec/codec.js';
import { SpvEngine } from './engine/codec/spv.js';
import { dsha256, hexToBytes, bytesToHex } from './engine/codec/hash.js';
import { connect as liveConnect, syncToTip } from './live-feed.js';
const $ = (id) => document.getElementById(id);
let seTestnet = null, seMainnet = null, codec = null, spv = null, blockCache = null, schemas = null, vectors = null;
const q = new URLSearchParams(location.search);
const signalUrl = q.get('signal') || 'wss://melvincarvalho.com/.webrtc';
const bridgeRoom = q.get('bridgeRoom') || 'b17c0192abad1deacafe'; // the bridge's room (network source for the scan)
const MSG_WITNESS_BLOCK = 1073741826;
async function loadEngines() {
const jl = (n) => fetch(`./engine/schema/${n}.jsonld`).then(r => r.json());
const [script, chain, core, proof, p2p, validate] = await Promise.all(['script','chain','core','proof','p2p','validate'].map(jl));
seTestnet = ScriptEngine.fromSchemas(script, chain, 'btc:testnet4');
seMainnet = ScriptEngine.fromSchemas(script, chain, 'btc:mainnet');
codec = new Codec(core, proof, p2p);
spv = SpvEngine.fromSchemas(codec, validate);
schemas = { core, proof, p2p, chain, validate };
vectors = await (await fetch('./data/testnet4.json')).json();
}
function derive() {
const xkey = $('xkey').value.trim();
const node = Bip32.decode(xkey);
if (!node) { $('status').innerHTML = '<span class="err">not a valid xpub / tpub</span>'; $('out').innerHTML = ''; $('net').textContent = '—'; return; }
const testnet = xkey.startsWith('tpub');
const se = testnet ? seTestnet : seMainnet;
$('net').textContent = testnet ? 'testnet4 · tb1…' : 'mainnet · bc1…';
const n = Math.max(1, Math.min(50, parseInt($('count').value, 10) || 5));
let rows = '';
for (let i = 0; i < n; i++) {
const child = Bip32.derivePath(node, `0/${i}`);
const addr = se.classify(Bip32.scriptPubKey(child, 'p2wpkh')).address;
rows += `<tr><td>0/${i}</td><td class="addr">${addr}</td></tr>`;
}
$('out').innerHTML = `<table><tr><th style="width:70px">path</th><th>receiving address (P2WPKH)</th></tr>${rows}</table>`;
$('status').innerHTML = `<span class="ok">✓ derived ${n}</span>`;
}
$('derive').onclick = derive;
$('xkey').addEventListener('keydown', (e) => { if (e.key === 'Enter') derive(); });
// ── ② SPV merkle-proof verification ──
// Build a BIP37 partial merkle tree (CPartialMerkleTree) proving txids[matchIdx].
function buildProof(txids, matchIdx) {
const n = txids.length;
const leaves = txids.map((t) => hexToBytes(t).reverse());
const width = (h) => (n + (1 << h) - 1) >>> h;
let height = 0; while (width(height) > 1) height++;
const calc = (h, pos) => { if (h === 0) return leaves[pos]; const l = calc(h - 1, pos * 2); const r = (pos * 2 + 1 < width(h - 1)) ? calc(h - 1, pos * 2 + 1) : l; return dsha256(new Uint8Array([...l, ...r])); };
const underMatch = (h, pos) => matchIdx >= (pos << h) && matchIdx < ((pos + 1) << h);
const vBits = [], vHash = [];
const traverse = (h, pos) => { const f = underMatch(h, pos); vBits.push(f ? 1 : 0); if (h === 0 || !f) vHash.push(bytesToHex(calc(h, pos).slice().reverse())); else { traverse(h - 1, pos * 2); if (pos * 2 + 1 < width(h - 1)) traverse(h - 1, pos * 2 + 1); } };
traverse(height, 0);
const flags = new Uint8Array(Math.ceil(vBits.length / 8));
vBits.forEach((b, i) => { if (b) flags[i >> 3] |= 1 << (i & 7); });
return { txCount: n, hashes: vHash, flags: bytesToHex(flags) };
}
async function verifyInclusion() {
if (!blockCache) { const hex = (await (await fetch('./data/block-26000.hex')).text()).trim(); blockCache = codec.decode('Block', hex); blockCache._bytes = hex.length / 2; }
const block = blockCache;
const txids = block.transactions.map((tx) => codec.txid(tx));
const i = Math.max(0, Math.min(txids.length - 1, parseInt($('txi').value, 10) || 0));
const proof = buildProof(txids, i);
const r = spv.verify({ header: block.header, ...proof }, { txid: txids[i] });
const psize = proof.hashes.length * 32 + proof.flags.length / 2;
const rows = r.results.map((c) => `<tr><td>${c.ok ? '<span class="ok">✓</span>' : '<span class="err">✗</span>'}</td><td>${c.label}</td></tr>`).join('');
$('spvout').innerHTML = `<p class="dim">tx <code class="addr">${txids[i]}</code> · index ${i} of ${txids.length}</p>` +
`<table><tr><th style="width:40px"></th><th>SPV check</th></tr>${rows}</table>` +
`<p class="dim" style="margin-top:8px">merkle proof: <b>${proof.hashes.length} hashes + ${proof.flags.length / 2}-byte flags ≈ ${psize} bytes</b> vs the full block's ${block._bytes} bytes — verified against block #26,000's PoW-validated header.</p>`;
$('spvstatus').innerHTML = r.ok ? '<span class="ok">✓ included</span>' : '<span class="err">× failed</span>';
}
$('verify').onclick = verifyInclusion;
// ── ③ Scan the chain for payments to your addresses ──
async function scanChain() {
const xkey = $('xkey').value.trim();
const node = Bip32.decode(xkey);
const setS = (m, c = '') => $('scanstatus').innerHTML = `<span class="${c}">${m}</span>`;
if (!node) { setS('derive a valid xpub/tpub first', 'err'); return; }
const se = xkey.startsWith('tpub') ? seTestnet : seMainnet;
$('scan').disabled = true; $('scanout').innerHTML = '';
const watch = new Map(); // scriptPubKey -> { path, addr }
for (let i = 0; i < 20; i++) { const spk = Bip32.scriptPubKey(Bip32.derivePath(node, `0/${i}`), 'p2wpkh'); watch.set(spk, { path: `0/${i}`, addr: se.classify(spk).address }); }
let session = null;
try {
setS('connecting + syncing headers (first time ~15s; cached after)…', 'warn');
session = await liveConnect({ signalUrl, room: bridgeRoom, schemas, vectors, log: (m) => setS(m, 'warn'), persist: true });
await syncToTip(session);
const tip = session.store.height;
const start = Math.max(1, Math.min(tip, parseInt($('start').value, 10) || Math.max(1, tip - 200)));
const utxos = []; let balance = 0; const BATCH = 50; const peer = session.peer;
for (let h = start; h <= tip; h += BATCH) {
const hashes = []; for (let k = 0; k < BATCH && h + k <= tip; k++) hashes.push(session.codec.blockHash(session.store.headerAt(h + k)));
peer.send('getdata', { items: hashes.map((hash) => ({ type: MSG_WITNESS_BLOCK, hash })) });
await peer.collect('block', hashes.length, { onItem: (msg) => {
const block = msg.payload, height = session.store.heightOf(session.codec.blockHash(block.header));
for (const tx of block.transactions) { const txid = session.codec.txid(tx); tx.outputs.forEach((o, vout) => { if (watch.has(o.scriptPubKey)) { utxos.push({ height, txid, vout, value: o.value, ...watch.get(o.scriptPubKey) }); balance += o.value; } }); }
}, timeoutMs: 40000 });
setS(`scanning #${Math.min(h + BATCH - 1, tip)} / #${tip} — found ${utxos.length}`, 'warn');
}
peer.close();
const rows = utxos.map((u) => { const spend = `wallet.html?fundAddr=${encodeURIComponent(u.addr)}&txid=${u.txid}&vout=${u.vout}&value=${u.value}`; return `<tr><td>${u.path}</td><td class="addr">${u.addr}</td><td>#${u.height}</td><td>${u.value.toLocaleString()}</td><td><a href="${spend}">spend →</a></td></tr>`; }).join('');
$('scanout').innerHTML = utxos.length
? `<p class="dim" style="margin-top:8px"><b>balance: ${(balance / 1e8).toLocaleString()} tBTC</b> (${balance.toLocaleString()} sats) · ${utxos.length} output(s) in #${start}…#${tip}</p><table><tr><th>path</th><th>address</th><th>block</th><th>value (sats)</th><th></th></tr>${rows}</table>`
: `<p class="dim" style="margin-top:8px">no payments to your first 20 addresses in #${start}…#${tip}. Fund <code>0/0</code> from a testnet4 faucet, then set the start height near when you funded.</p>`;
setS(utxos.length ? `✓ balance ${(balance / 1e8).toLocaleString()} tBTC` : '✓ scan complete — nothing found', utxos.length ? 'ok' : 'dim');
} catch (e) { setS('scan error: ' + e.message + ' — is the bridge running?', 'err'); try { session?.peer?.close(); } catch {} }
$('scan').disabled = false;
}
$('scan').onclick = scanChain;
await loadEngines();
$('status').textContent = 'engine ready — press Derive';
</script>
</body>
</html>