-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwallet.html
More file actions
267 lines (247 loc) · 18.6 KB
/
Copy pathwallet.html
File metadata and controls
267 lines (247 loc) · 18.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
<!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 — wallet (sign a testnet4 spend)</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; } h2 { font-size:14px; color:var(--mut); text-transform:uppercase; letter-spacing:.04em; margin:22px 0 8px; } 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; }
.warn-box { border:1px solid var(--ac); background:#fff7ed; border-radius:8px; padding:10px 13px; margin:14px 0; }
input { width:100%; box-sizing:border-box; border:1px solid var(--bd); border-radius:6px; padding:8px 9px; font:inherit; background:var(--pan); color:var(--fg); }
label { display:block; color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.04em; margin:10px 0 3px; }
.row { display:flex; gap:10px; } .row > div { flex:1; }
.field { margin:12px 0; }
.field .k { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.field .v { word-break:break-all; border:1px solid var(--bd); border-radius:6px; padding:8px 9px; background:var(--pan); margin-top:4px; user-select:all; }
.secret .v { border-color:var(--bad); background:#fff5f5; }
.ok{color:var(--good)} .err{color:var(--bad)} .dim{color:var(--mut)}
code { background:var(--pan); border:1px solid var(--bd); border-radius:4px; padding:1px 5px; font-size:.92em; }
fieldset { border:1px solid var(--bd); border-radius:8px; padding:4px 14px 14px; margin:0; }
.coins-table { border-collapse:collapse; width:100%; } .coins-table td, .coins-table th { border:1px solid var(--bd); padding:6px 9px; text-align:left; } .coins-table th { color:var(--mut); font-size:12px; }
.coins-table button { padding:4px 9px; }
</style>
</head>
<body>
<h1>bitcoin-kernel / browser-node — wallet <span class="dim">(sign a testnet4 spend)</span></h1>
<p class="dim">Build and <b>sign</b> a P2WPKH spending transaction in your browser. The signing key never leaves this tab.
Load a <a href="keys.html">wallet mnemonic</a>, point at a UTXO funding your <code>0/0</code> address (find it with the
<a href="spv.html">watch-only scan</a>), and this page produces a signed, broadcast-ready transaction — verified against the
node's own BIP143 verifier before you ever see it. <a href="how-it-works.html">how it works</a></p>
<div class="warn-box dim">
⚠ <b>Testnet4 only — throwaway.</b> Your mnemonic is your wallet; it stays in this tab and vanishes on reload.
This page <b>signs and broadcasts</b> — the signed transaction is announced to a real testnet4 peer over the bridge. You can also copy the raw hex and push it elsewhere.</div>
<h2>1 · Load wallet</h2>
<input id="mnemonic" spellcheck="false" placeholder="twelve word BIP39 mnemonic …">
<p style="margin-top:8px"><button id="load">▶ Load wallet</button> <span id="loadStatus" class="dim"></span></p>
<div id="wallet"></div>
<div id="coins"></div>
<h2>2 · Spend</h2>
<fieldset id="spendBox" disabled>
<label>funding address — which of your addresses holds this UTXO (auto-found among your first 20)</label><input id="fundAddr" spellcheck="false" placeholder="tb1q… (defaults to your 0/0)">
<div class="row">
<div><label>UTXO txid</label><input id="utxoTxid" spellcheck="false" placeholder="funding transaction id"></div>
<div style="flex:0 0 90px"><label>vout</label><input id="utxoVout" inputmode="numeric" value="0"></div>
</div>
<label>UTXO value (sats) — the full amount of that output</label><input id="utxoValue" inputmode="numeric" placeholder="100000">
<label>send to (testnet4 address)</label><input id="dest" spellcheck="false" placeholder="tb1q…">
<div class="row">
<div><label>amount to send (sats)</label><input id="amount" inputmode="numeric" placeholder="90000"></div>
<div style="flex:0 0 130px"><label>fee (sats)</label><input id="fee" inputmode="numeric" value="200"></div>
</div>
<p style="margin-top:12px"><button id="build">▶ Build & sign</button> <span id="buildStatus" class="dim"></span></p>
</fieldset>
<div id="out"></div>
<p class="dim" style="margin-top:18px; border-top:1px solid var(--bd); padding-top:10px">
Signs the input at whichever <code>m/84'/1'/0'/{0,1}/i</code> address holds the UTXO — it searches your first 20 receiving
and change addresses for the funding address and uses that key (P2WPKH, BIP143). Change returns to the funding address.
The signature is produced by the bundled WASM secp and the finished transaction is checked by the node's own
<code>ScriptInterpreter.verifyInput</code> before display — the same code path that validates blocks. Broadcast fans the
tx out to several testnet4 peers over the bridge (<code>inv</code> → <code>getdata</code> → <code>tx</code>, the bridge dialing
a random peer per connection) and stops once one admits it — so a single peer missing an unconfirmed parent can't sink it.
Proven end-to-end in <code>test-sign.mjs</code> / <code>test-wallet.mjs</code> / <code>test-broadcast.mjs</code> /
<code>test-broadcast-multi.mjs</code>. The UTXO fields here are pre-filled when you arrive from a <b>spend →</b> link on the
<a href="mempool.html">mempool watch</a> or <a href="spv.html">SPV scan</a>. Your coins are tracked locally across reloads
(<code>wallet-store.js</code>): a spend drops the input and tracks the change, so the balance follows without re-scanning.
Next: multi-UTXO coin selection.</p>
<script type="module">
import { mnemonicToSeed, deriveSigningKey, signEcdsa, toDer } from './wasm-keygen.js';
import { Codec } from './engine/codec/codec.js';
import { ScriptEngine, addressToScript } from './engine/codec/script.js';
import { ScriptInterpreter } from './engine/codec/interpreter.js';
import { hash160, bytesToHex } from './engine/codec/hash.js';
import { connect as liveConnect } from './live-feed.js';
import { broadcastToPeers } from './broadcast.js';
import { loadSet, saveSet, applyTx, addUtxo, removeUtxo, listUtxos, balance } from './wallet-store.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
const signalUrl = q.get('signal') || 'wss://melvincarvalho.com/.webrtc';
const bridgeRoom = q.get('bridgeRoom') || 'b17c0192abad1deacafe';
let codec = null, se = null, interp = null, seed = null;
let schemas = null, vectors = null, signedTx = null;
let ownScripts = null, walletId = null, utxoSet = {}; // persistent per-wallet coin set
const GAP = 20; // how many indices to search per chain when locating the funding address
// derive (key, scriptPubKey, address) for one BIP84 index — the same derivation keys.html shows
function at(change, index) {
const key = deriveSigningKey(seed, { coin: 1, change, index });
const scriptPubKey = '0014' + bytesToHex(hash160(key.pub));
return { key, scriptPubKey, address: se.classify(scriptPubKey).address, change, index };
}
// find which of the wallet's first GAP receiving/change addresses equals targetAddr
function findKeyForAddress(targetAddr) {
for (const change of [0, 1]) for (let i = 0; i < GAP; i++) { const e = at(change, i); if (e.address === targetAddr) return e; }
return null;
}
async function loadEngine() {
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));
se = ScriptEngine.fromSchemas(script, chain, 'btc:testnet4');
codec = new Codec(core, proof, p2p);
interp = new ScriptInterpreter(codec, se);
schemas = { core, proof, p2p, chain, validate };
vectors = await fetch('./data/testnet4.json').then(r => r.json());
}
async function loadWallet() {
const m = $('mnemonic').value.trim().toLowerCase().split(/\s+/).filter(Boolean).join(' ');
const words = m ? m.split(' ').length : 0;
if (![12, 15, 18, 21, 24].includes(words)) { $('loadStatus').innerHTML = '<span class="err">expects a 12/15/18/21/24-word BIP39 mnemonic</span>'; return; }
seed = await mnemonicToSeed(m, '');
let rows = '';
for (let i = 0; i < 5; i++) { const e = at(0, i); rows += `<tr><td>0/${i}</td><td class="addr">${e.address}</td></tr>`; }
$('wallet').innerHTML =
`<div class="field"><div class="k">your receiving addresses — fund one on testnet4, then spend it below</div>` +
`<table style="border-collapse:collapse;width:100%"><tr><th style="width:70px;border:1px solid var(--bd);padding:6px 9px">path</th><th style="border:1px solid var(--bd);padding:6px 9px">address (P2WPKH)</th></tr>${rows}</table></div>`;
if (!$('fundAddr').value) $('fundAddr').value = at(0, 0).address; // default to 0/0 (unless prefilled from a link)
// the wallet's own scripts (first 20 receiving + change) — for recognising change
ownScripts = new Map(); for (const c of [0, 1]) for (let i = 0; i < GAP; i++) { const e = at(c, i); ownScripts.set(e.scriptPubKey, e.address); }
walletId = at(0, 0).address; // stable per-wallet key for storage
utxoSet = loadSet(walletId); renderCoins();
$('spendBox').disabled = false;
$('loadStatus').innerHTML = '<span class="ok">✓ wallet loaded (key in this tab only)</span>';
}
// The wallet's known coins, persisted per wallet in localStorage. Updated as you
// spend (spent input dropped, change tracked) — see wallet-store.js.
function renderCoins() {
if (!walletId) return;
const us = listUtxos(utxoSet);
if (!us.length) { $('coins').innerHTML = '<p class="dim" style="margin-top:8px">No tracked coins yet — build a spend (or arrive from a <b>spend →</b> link) and your change is tracked here across reloads.</p>'; return; }
const rows = us.map((u) => `<tr><td>${u.value.toLocaleString()}</td><td class="addr">${u.txid}:${u.vout}</td><td><button data-use="${u.txid}:${u.vout}">use</button> <button data-forget="${u.txid}:${u.vout}" title="forget (e.g. spent elsewhere)">✕</button></td></tr>`).join('');
$('coins').innerHTML =
`<div class="field"><div class="k">your coins — balance ${balance(utxoSet).toLocaleString()} sats (${(balance(utxoSet) / 1e8).toLocaleString()} tBTC)</div>` +
`<table class="coins-table"><tr><th style="width:120px">value (sats)</th><th>outpoint</th><th style="width:120px"></th></tr>${rows}</table></div>`;
$('coins').querySelectorAll('[data-use]').forEach((b) => { b.onclick = () => useUtxo(b.getAttribute('data-use')); });
$('coins').querySelectorAll('[data-forget]').forEach((b) => { b.onclick = () => { const [t, v] = b.getAttribute('data-forget').split(':'); utxoSet = removeUtxo(utxoSet, t, Number(v)); saveSet(walletId, utxoSet); renderCoins(); }; });
}
// load one of your coins into the spend form
function useUtxo(key) {
const u = utxoSet[key]; if (!u) return;
$('fundAddr').value = u.address; $('utxoTxid').value = u.txid; $('utxoVout').value = u.vout; $('utxoValue').value = u.value;
$('buildStatus').innerHTML = `<span class="dim">loaded ${u.value.toLocaleString()} sats — set amount + destination, then Build</span>`;
}
function buildAndSign() {
$('out').innerHTML = ''; $('buildStatus').textContent = '';
try {
if (!seed) throw new Error('load a wallet first');
const fundAddr = $('fundAddr').value.trim();
const utxo = findKeyForAddress(fundAddr);
if (!utxo) throw new Error(`funding address not found among your first ${GAP} receiving/change addresses — is it from this wallet?`);
const txid = $('utxoTxid').value.trim().toLowerCase();
const vout = parseInt($('utxoVout').value, 10);
const utxoValue = parseInt($('utxoValue').value, 10);
const dest = $('dest').value.trim();
const amount = parseInt($('amount').value, 10);
const fee = parseInt($('fee').value, 10);
if (!/^[0-9a-f]{64}$/.test(txid)) throw new Error('UTXO txid must be 64 hex chars');
if (!Number.isInteger(vout) || vout < 0) throw new Error('vout must be a non-negative integer');
for (const [n, v] of [['UTXO value', utxoValue], ['amount', amount], ['fee', fee]]) if (!Number.isInteger(v) || v < 0) throw new Error(`${n} must be a non-negative integer (sats)`);
const destScript = addressToScript(dest, se.params);
if (!destScript) throw new Error('destination is not a valid testnet4 address');
const change = utxoValue - amount - fee;
if (change < 0) throw new Error(`amount + fee (${amount + fee}) exceeds the UTXO value (${utxoValue})`);
const outputs = [{ value: amount, scriptPubKey: destScript }];
if (change > 0) outputs.push({ value: change, scriptPubKey: utxo.scriptPubKey }); // change back to the funding address
const tx = {
version: 2, marker: 0, flag: 1,
inputs: [{ prevout: { txid, vout }, scriptSig: '', sequence: 0xffffffff }],
outputs, witness: [[]], lockTime: 0,
};
// BIP143 sighash over the implied P2PKH scriptCode → sign with the funding key → witness [sig+SIGHASH_ALL, pubkey]
const scriptCode = '76a914' + bytesToHex(hash160(utxo.key.pub)) + '88ac';
const sighash = interp.sighashWitnessV0(tx, 0, scriptCode, utxoValue, 0x01);
tx.witness[0] = [bytesToHex(toDer(signEcdsa(sighash, utxo.key.priv))) + '01', bytesToHex(utxo.key.pub)];
// verify against the node's OWN verifier before showing anything
const prevout = { scriptPubKey: utxo.scriptPubKey, value: utxoValue };
const r = interp.verifyInput(tx, 0, prevout, [prevout]);
if (r.ok !== true) throw new Error('engine rejected the signed input: ' + JSON.stringify(r));
const rawHex = codec.encodeHex('Transaction', tx);
const finalTxid = codec.txid(tx);
signedTx = tx;
// track the input we're spending so the balance reflects it before the spend lands
utxoSet = addUtxo(utxoSet, { txid, vout, value: utxoValue, scriptPubKey: utxo.scriptPubKey, address: fundAddr });
saveSet(walletId, utxoSet); renderCoins();
const chain = utxo.change ? '1' : '0';
const changeLine = change > 0 ? `<tr><td class="dim">change → your ${chain}/${utxo.index}</td><td>${change} sats</td></tr>` : `<tr><td class="dim">change</td><td>none</td></tr>`;
$('out').innerHTML =
`<div class="field"><div class="k">✓ verified — signed transaction (${rawHex.length / 2} bytes)</div>` +
`<table style="border-collapse:collapse;width:100%;margin:6px 0"><tr><td class="dim" style="width:140px">from ${chain}/${utxo.index}</td><td>${fundAddr.slice(0, 16)}… (${utxoValue} sats)</td></tr><tr><td class="dim">to ${dest.slice(0, 12)}…</td><td>${amount} sats</td></tr>${changeLine}<tr><td class="dim">fee</td><td>${fee} sats</td></tr></table></div>` +
`<div class="field"><div class="k">txid</div><div class="v">${finalTxid}</div></div>` +
`<div class="field secret"><div class="k">raw transaction — or broadcast it below, or paste into any testnet4 node / explorer</div><div class="v">${rawHex}</div></div>` +
`<p style="margin-top:10px"><button id="bcast">▶ Broadcast to testnet4</button> <span id="bcastStatus" class="dim"></span></p>` +
`<div id="bcastOut"></div>`;
$('bcast').onclick = broadcast;
$('buildStatus').innerHTML = '<span class="ok">✓ signed & verified under the node BIP143</span>';
} catch (e) {
$('buildStatus').innerHTML = `<span class="err">${e.message}</span>`;
}
}
// Broadcast over the bridge to SEVERAL peers (the bridge dials a random peer per
// connection), succeeding as soon as one admits it — so a single peer that lacks
// an unconfirmed parent can't sink the spend. Same broadcastToPeers proven in
// test-broadcast-multi.mjs against the real peer list.
const BROADCAST_PEERS = 3;
async function broadcast() {
if (!signedTx) return;
$('bcast').disabled = true; $('bcastOut').innerHTML = '';
let n = 0;
try {
const out = await broadcastToPeers(async (i) => {
n = i + 1;
$('bcastStatus').innerHTML = `<span class="dim">peer ${n}/${BROADCAST_PEERS}: connecting over the bridge…</span>`;
const session = await liveConnect({ signalUrl, room: bridgeRoom, schemas, vectors, log: (m) => { $('bcastStatus').innerHTML = `<span class="dim">peer ${n}/${BROADCAST_PEERS}: ${m}</span>`; } });
$('bcastStatus').innerHTML = `<span class="dim">peer ${n}/${BROADCAST_PEERS}: announcing transaction…</span>`;
return session.peer;
}, signedTx, codec, { peers: BROADCAST_PEERS, needAccepts: 1 });
if (out.accepted > 0) {
// the spend landed — drop the spent input, track the change, persist
utxoSet = applyTx(utxoSet, signedTx, ownScripts, codec);
saveSet(walletId, utxoSet); renderCoins();
$('bcastOut').innerHTML = `<div class="field"><div class="k ok">✓ accepted into the mempool (${out.accepted} of ${out.tried} peer(s))</div><div class="v">txid ${out.txid}<br>A peer admitted the transaction — it is now relaying across testnet4. Your coins above are updated (input spent, change tracked).</div></div>`;
}
else
$('bcastOut').innerHTML = `<div class="field"><div class="k err">✗ no peer admitted it (tried ${out.tried})</div><div class="v">${out.results.map((r, i) => `peer ${i + 1}: ${r.detail}`).join('<br>')}<br><br>Usually the input is still <b>unconfirmed</b> and these peers don't have its parent tx, or it's already spent. Wait for the funding tx to confirm, then broadcast again (the signed tx is unchanged).</div></div>`;
$('bcastStatus').innerHTML = out.accepted > 0 ? '<span class="ok">✓ broadcast</span>' : '<span class="dim">done</span>';
} catch (e) {
$('bcastStatus').innerHTML = `<span class="err">broadcast failed: ${e.message}</span>`;
} finally {
$('bcast').disabled = false;
}
}
// Prefill the spend form from query params — mempool.html / spv.html link a found
// UTXO straight here: ?fundAddr=&txid=&vout=&value= (&dest=&amount=&fee= optional).
function prefillFromQuery() {
const map = { fundAddr: 'fundAddr', txid: 'utxoTxid', vout: 'utxoVout', value: 'utxoValue', dest: 'dest', amount: 'amount', fee: 'fee' };
let any = false;
for (const [param, id] of Object.entries(map)) { const v = q.get(param); if (v != null && v !== '') { $(id).value = v; any = true; } }
if (any) $('loadStatus').innerHTML = '<span class="dim">UTXO pre-filled from a link — load your wallet to sign it</span>';
}
$('load').onclick = loadWallet;
$('build').onclick = buildAndSign;
prefillFromQuery();
await loadEngine();
if (!q.get('txid')) $('loadStatus').textContent = 'engine ready — load a wallet';
</script>
</body>
</html>