-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
345 lines (329 loc) · 24.9 KB
/
Copy pathindex.html
File metadata and controls
345 lines (329 loc) · 24.9 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>taproot</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.json">
<style>
:root{ --slate:#2a3140; --accent:#f7931a; --accent-d:#d97b06; --line:#e2e6eb; --mut:#6b7480; --bad:#c0392b; }
*{box-sizing:border-box}
body{margin:0;font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1f2733;background:#f4f6f8}
.top{display:flex;align-items:center;gap:.6rem;background:var(--slate);color:#fff;padding:.7rem 1rem}
.top b{font-weight:800;letter-spacing:.04em}
.top .exp{font-size:.62rem;background:rgba(247,147,26,.18);color:var(--accent);border:1px solid rgba(247,147,26,.4);padding:.1rem .4rem;border-radius:5px;font-weight:700;letter-spacing:.04em}
.top a{margin-left:auto;color:#aeb6c2;text-decoration:none;font-size:.85rem}
.wrap{max-width:560px;margin:0 auto;padding:1rem}
.bar{display:flex;align-items:center;gap:.6rem;margin-bottom:1rem}
select,input[type=text],input[type=number]{font:inherit;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;background:#fff;width:100%}
.bar select{flex:1}
.tabs{display:flex;gap:.4rem;margin-bottom:1rem}
.tabs button{flex:1;background:#fff;color:#1f2733;border:1px solid var(--line);font-weight:600}
.tabs button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:1.1rem;margin-bottom:1rem;text-align:center}
.bal{font-size:2.4rem;font-weight:800;color:var(--accent);line-height:1}
.bal .u{font-size:.4em;color:var(--mut);font-weight:600}
.sub{color:var(--mut);font-size:.85rem;margin-top:.2rem}
#qr{display:inline-block;margin:.8rem auto;background:#fff;padding:.5rem;border-radius:10px;border:1px solid var(--line)}
#qr img,#qr canvas{display:block}
.addr{font:12px ui-monospace,Menlo,monospace;word-break:break-all;background:#f8fafc;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;cursor:pointer}
.addr:hover{border-color:var(--accent)}
.row{display:flex;gap:.5rem;justify-content:center;margin-top:.6rem;flex-wrap:wrap}
button,a.btn{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:.45rem .9rem;font:inherit;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block}
button:hover,a.btn:hover{background:var(--accent-d)}
button:disabled{opacity:.5;cursor:not-allowed}
a.btn.ghost,button.ghost{background:#fff;color:#1f2733;border:1px solid var(--line)}
.npub{font:11px ui-monospace,Menlo,monospace;color:var(--mut);word-break:break-all;margin-top:.8rem}
.muted{color:var(--mut)}
.note{font-size:.8rem;color:var(--mut);margin-top:.4rem}
.form{text-align:left}
.form label{display:block;font-weight:600;font-size:.85rem;margin:.7rem 0 .15rem}
.hint{font-size:.75rem;color:var(--mut);margin-top:.15rem}
.maxrow{display:flex;justify-content:space-between;align-items:baseline}
.maxrow a{font-size:.78rem;color:var(--accent);cursor:pointer;font-weight:600}
.warn{background:#fff4ec;border:1px solid var(--accent);color:var(--accent-d);border-radius:8px;padding:.5rem .7rem;font-size:.82rem;margin:.6rem 0;text-align:left}
.danger{background:#fdecea;border:1px solid var(--bad);color:var(--bad);border-radius:8px;padding:.6rem .7rem;font-size:.84rem;margin:.6rem 0;text-align:left}
.summary{text-align:left;font-size:.88rem;background:#f8fafc;border:1px solid var(--line);border-radius:8px;padding:.6rem .7rem;margin:.6rem 0}
.summary div{display:flex;justify-content:space-between;gap:.6rem;padding:.12rem 0}
.summary b{font-weight:700}
.summary .v{font-family:ui-monospace,Menlo,monospace;word-break:break-all;text-align:right}
.rawhex{font:10px ui-monospace,Menlo,monospace;word-break:break-all;background:#11151c;color:#9fe0a0;border-radius:8px;padding:.5rem .6rem;max-height:120px;overflow:auto;text-align:left}
.err{color:var(--bad);font-size:.84rem;margin-top:.4rem;text-align:left}
.ok{color:#1e7e34;font-size:.9rem;margin-top:.5rem;text-align:left;word-break:break-all}
.toast{position:fixed;left:50%;bottom:1.2rem;transform:translateX(-50%);background:var(--slate);color:#fff;padding:.5rem 1rem;border-radius:9px;font-size:.88rem;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:1}
</style>
</head>
<body>
<div class="top"><b>taproot</b><span class="exp">EXPERIMENTAL</span><a href="../../../">↑ root</a></div>
<div class="wrap"><div id="app"></div></div>
<div class="toast" id="toast"></div>
<script src="https://unpkg.com/xlogin"></script>
<script src="https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"></script>
<script src="https://unpkg.com/@cmdcode/tapscript@1.4.6/dist/bundle.min.js"></script>
<script>
// taproot - a multi-currency taproot wallet keyed by your identity (nostr/Solid).
// Your secp256k1 key IS your taproot address per chain (raw-key, witness v1).
// Phase 1: receive + balance.
// Phase 2: send - build + BIP341-sighash + BIP340 schnorr-sign (raw key, via
// @cmdcode/tapscript) + broadcast. Every input signature is verified
// under your output key BEFORE broadcast; mainnet needs a typed confirm.
"use strict";
var CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l", BECH32=1, BECH32M=0x2bc830a3;
var NETWORKS = {
bitcoin_testnet4:{ name:"Bitcoin Testnet4", coin:"tBTC", prefix:"tb", api:"https://mempool.space/testnet4/api", exp:"https://mempool.space/testnet4", mempool:true, send:true, addrNet:"testnet" },
bitcoin_testnet3:{ name:"Bitcoin Testnet3", coin:"tBTC", prefix:"tb", api:"https://mempool.space/testnet/api", exp:"https://mempool.space/testnet", mempool:true, send:true, addrNet:"testnet" },
bitcoin_mainnet:{ name:"Bitcoin", coin:"BTC", prefix:"bc", api:"https://mempool.space/api", exp:"https://mempool.space", mempool:true, send:true, addrNet:"main", real:true },
litecoin_mainnet:{ name:"Litecoin", coin:"LTC", prefix:"ltc", exp:"https://litecoinspace.org" },
litecoin_testnet:{ name:"Litecoin Testnet", coin:"tLTC", prefix:"tltc", exp:"https://litecoinspace.org/testnet" },
vertcoin_mainnet:{ name:"Vertcoin", coin:"VTC", prefix:"vtc", exp:"https://vtc5.trezor.io" },
vertcoin_testnet:{ name:"Vertcoin Testnet", coin:"tVTC", prefix:"tvtc", exp:"https://testnet.vtconline.org" }
};
var app=document.getElementById("app"), toastEl=document.getElementById("toast");
var XONLY=null, NET="bitcoin_testnet4", TAB="receive", UTXOS=null, FEES=null, BUILT=null;
function me(){ return window.xlogin && window.xlogin.id; }
function authFetch(u,o){ return ((window.xlogin && window.xlogin.authFetch)||fetch)(u,o); }
function esc(s){ return String(s==null?"":s).replace(/[&<>"]/g,function(c){return {"&":"&","<":"<",">":">",'"':"""}[c];}); }
function hexToU8(h){ var b=new Uint8Array(h.length/2); for(var i=0;i<b.length;i++)b[i]=parseInt(h.slice(i*2,i*2+2),16); return b; }
function u8ToHex(u){ return Array.prototype.map.call(u,function(b){return ("0"+(b&255).toString(16)).slice(-2);}).join(""); }
function isHex(s){ return typeof s==="string"&&s.length===64&&/^[0-9a-f]{64}$/i.test(s); }
function toast(t){ toastEl.textContent=t; toastEl.classList.add("show"); clearTimeout(toast._t); toast._t=setTimeout(function(){toastEl.classList.remove("show");},1600); }
// --- bech32 / bech32m (proven, matches the reference's taproot derivation) ---
function polymod(v){ var G=[0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3],c=1; for(var x of v){var b=c>>25;c=((c&0x1ffffff)<<5)^x;for(var i=0;i<5;i++)if((b>>i)&1)c^=G[i];} return c; }
function hrpExpand(h){ var r=[]; for(var c of h)r.push(c.charCodeAt(0)>>5); r.push(0); for(var c2 of h)r.push(c2.charCodeAt(0)&31); return r; }
function convertBits(d,from,to,pad){ var a=0,b=0,r=[],m=(1<<to)-1; for(var v of d){a=(a<<from)|v;b+=from;while(b>=to){b-=to;r.push((a>>b)&m);}} if(pad&&b>0)r.push((a<<(to-b))&m); return r; }
function bechEncode(hrp,data5,spec){ var enc=hrpExpand(hrp).concat(data5,[0,0,0,0,0,0]); var mod=polymod(enc)^spec; var ck=[0,1,2,3,4,5].map(function(i){return (mod>>(5*(5-i)))&31;}); var s=hrp+"1"; data5.concat(ck).forEach(function(v){s+=CHARSET[v];}); return s; }
function bech32Decode(str){ str=str.toLowerCase(); var pos=str.lastIndexOf("1"); if(pos<1)return null; var hrp=str.slice(0,pos), data=[]; for(var c of str.slice(pos+1)){ var v=CHARSET.indexOf(c); if(v<0)return null; data.push(v); } return data.length<6?null:{ hrp:hrp, data:data.slice(0,-6) }; }
function taprootAddress(xonly,prefix){ return bechEncode(prefix,[1].concat(convertBits(xonly,8,5,true)),BECH32M); } // witness v1, raw key
function npub(xonly){ return bechEncode("npub",convertBits(xonly,8,5,true),BECH32); }
// --- identity key (nostr pubkey, or Solid WebID CID Multikey) ---
function getLd(u){ return authFetch(u,{headers:{Accept:"application/ld+json"}}).then(function(r){return r.ok?r.json().catch(function(){return null;}):null;}).catch(function(){return null;}); }
function decodeMultibaseXOnly(mb){ try{ var bytes; if(mb[0]==="f")bytes=hexToU8(mb.slice(1)); else return null; if(bytes[0]===0xe7&&bytes[1]===0x01)bytes=bytes.slice(2); if(bytes.length===33&&(bytes[0]===2||bytes[0]===3))bytes=bytes.slice(1); return bytes.length===32?bytes:null; }catch(e){return null;} }
function decodeMultibaseSecret(mb){ try{ var bytes; if(mb[0]==="f")bytes=hexToU8(mb.slice(1)); else return null; if(bytes[0]===0x81&&bytes[1]===0x26)bytes=bytes.slice(2); return bytes.length===32?bytes:null; }catch(e){return null;} }
function identityXOnly(){
var x=window.xlogin; if(!x||!x.id) return Promise.resolve(null);
if(x.type==="nostr"){ return Promise.resolve().then(function(){ return window.nostr?window.nostr.getPublicKey():String(x.id); }).then(function(pk){ return isHex(pk)?hexToU8(pk):null; }).catch(function(){return null;}); }
return getLd(String(x.id).replace(/#.*$/,"")).then(function(doc){
var vms=(doc&&doc.verificationMethod)||[]; if(!Array.isArray(vms))vms=[vms];
for(var i=0;i<vms.length;i++){ var mb=vms[i]&&vms[i].publicKeyMultibase; var xo=mb&&decodeMultibaseXOnly(mb); if(xo)return xo; }
return null;
}).catch(function(){return null;});
}
// read the owner-only secp256k1 secret from the pod (Solid signing path).
// Never stored, never sent - used in-memory only to produce a local signature.
function loadSecret(){
var x=window.xlogin; if(!x||!x.id) return Promise.resolve(null);
// privkey lives at <storage>/private/privkey.jsonld; derive storage root from the WebID
var root=String(x.id).replace(/#.*$/,"").replace(/\/profile\/card$|\/[^\/]*$/,"");
return getLd(root+"/private/privkey.jsonld").then(function(doc){
if(!doc) return null;
var subs=Array.isArray(doc["@graph"])?doc["@graph"]:[doc];
for(var i=0;i<subs.length;i++){
var mb=subs[i]&&(subs[i].secretKeyMultibase||subs[i]["sec:secretKeyMultibase"]||subs[i]["https://w3id.org/security#secretKeyMultibase"]);
if(mb&&mb["@value"])mb=mb["@value"];
var s=mb&&decodeMultibaseSecret(mb); if(s)return s;
}
return null;
}).catch(function(){return null;});
}
// --- UTXOs / balance ---
function fetchUTXOs(addr,net){
if(!net.mempool||!net.api) return Promise.resolve(null);
return fetch(net.api+"/address/"+addr+"/utxo").then(function(r){return r.ok?r.json():null;})
.then(function(u){ return Array.isArray(u)?u:null; }).catch(function(){return null;});
}
function fetchFees(net){ return fetch(net.api+"/v1/fees/recommended").then(function(r){return r.ok?r.json():null;}).catch(function(){return null;}); }
function balanceSats(){ return UTXOS?UTXOS.reduce(function(s,u){return s+(u.value||0);},0):null; }
// --- recipient -> scriptPubKey asm (validates network for addresses) ---
function recipientScript(input,net){
input=(input||"").trim();
if(!input) throw new Error("Enter a recipient.");
if(input.toLowerCase().indexOf("npub1")===0){
var d=bech32Decode(input); if(!d||d.hrp!=="npub") throw new Error("Bad npub.");
var bytes=convertBits(d.data,5,8,false); if(bytes.length!==32) throw new Error("npub is not a 32-byte key.");
return ["OP_1", u8ToHex(Uint8Array.from(bytes))];
}
if(isHex(input)) return ["OP_1", input.toLowerCase()];
var dec=window.tapscript.Address.decode(input); // throws on invalid address
if(net.addrNet && dec.network!==net.addrNet) throw new Error("That address is "+dec.network+", but you're on "+net.name+".");
return window.tapscript.Address.toScriptPubKey(input);
}
// --- fee + coin selection (taproot key-path vsize estimate) ---
function estVsize(nIn,nOut){ return Math.ceil(10.5 + 57.5*nIn + 43*nOut); }
function selectCoins(utxos,amount,rate,opReturn){
var nOutBase = 1 + (opReturn?1:0); // recipient (+ op_return)
var sorted=utxos.slice().sort(function(a,b){return b.value-a.value;});
var ins=[],total=0;
for(var i=0;i<sorted.length;i++){
ins.push(sorted[i]); total+=sorted[i].value;
var fee=Math.ceil(estVsize(ins.length,nOutBase+1)*rate); // assume change
if(total>=amount+fee){
var change=total-amount-fee, DUST=330;
if(change<DUST){ fee=Math.ceil(estVsize(ins.length,nOutBase)*rate); if(total-amount-fee<0) continue; fee=total-amount; change=0; }
return { ins:ins, total:total, fee:fee, change:change, vsize:estVsize(ins.length,nOutBase+(change>0?1:0)) };
}
}
throw new Error("Insufficient funds for that amount + fee.");
}
// --- build + sign + verify (raw-key taproot, via @cmdcode/tapscript) ---
function buildAndSign(recipient,amountSats,rate,opReturnHex){
var net=NETWORKS[NET], T=window.tapscript;
var rScript=recipientScript(recipient,net); // throws on bad input
var sel=selectCoins(UTXOS,amountSats,rate,!!opReturnHex);
var myScript=["OP_1", u8ToHex(XONLY)];
var vout=[{ value:amountSats, scriptPubKey:rScript }];
if(opReturnHex) vout.unshift({ value:0, scriptPubKey:["OP_RETURN", opReturnHex] });
if(sel.change>0) vout.push({ value:sel.change, scriptPubKey:myScript });
var txdata={ version:2, locktime:0,
vin: sel.ins.map(function(u){ return { txid:u.txid, vout:u.vout, sequence:0xfffffffd, prevout:{ value:u.value, scriptPubKey:myScript } }; }),
vout: vout };
// sign every input, then VERIFY each under our output key before we trust it
var x=window.xlogin;
function finish(){
for(var i=0;i<txdata.vin.length;i++){ if(!T.Signer.taproot.verify(txdata,i,{pubkey:u8ToHex(XONLY)})) throw new Error("Signature check failed on input "+i+" - not broadcasting."); }
var hex=T.Tx.encode(txdata).hex;
return { hex:hex, sel:sel, vout:vout, txdata:txdata, net:net,
toScript:rScript, amount:amountSats };
}
if(x && x.type==="nostr"){
if(!window.nostr || typeof window.nostr.signSchnorr!=="function")
return Promise.reject(new Error("Your nostr extension can't sign Schnorr. Use one that supports signSchnorr (e.g. Alby/nos2x), or sign in with Solid."));
var chain=Promise.resolve();
for(var i=0;i<txdata.vin.length;i++){ (function(idx){ chain=chain.then(function(){ var h=T.Signer.taproot.hash(txdata,idx).hex; return Promise.resolve(window.nostr.signSchnorr(h)).then(function(sig){ txdata.vin[idx].witness=[sig]; }); }); })(i); }
return chain.then(finish);
}
return loadSecret().then(function(sk){
if(!sk) throw new Error("Couldn't read your signing key from the pod (/private/privkey.jsonld). Sign in with a pod that has a provisioned key.");
for(var i=0;i<txdata.vin.length;i++) txdata.vin[i].witness=[ T.Signer.taproot.sign(sk,txdata,i).hex ];
sk.fill(0); // wipe the secret from memory
return finish();
});
}
function broadcast(hex,net){
return fetch(net.api+"/tx",{ method:"POST", body:hex }).then(function(r){
return r.text().then(function(t){ if(!r.ok) throw new Error(t||("HTTP "+r.status)); return t.trim(); });
});
}
// --- rendering ---
function fmtCoin(sats,coin){ return (sats/1e8).toLocaleString(undefined,{maximumFractionDigits:8})+" "+coin; }
function render(){
if(!me()){ app.innerHTML='<div class="card" style="text-align:left"><h2 style="margin:.2rem 0">taproot</h2><p>A multi-currency <b>taproot wallet</b> keyed by your identity — your key is your address across Bitcoin, Litecoin & Vertcoin.</p><p class="muted">Sign in (login pill, bottom-right) to receive, check balances, and send.</p></div>'; return; }
if(!XONLY){ app.innerHTML='<div class="card muted">Deriving your key…</div>'; return; }
var net=NETWORKS[NET], addr=taprootAddress(XONLY,net.prefix);
var opts=Object.keys(NETWORKS).map(function(k){return '<option value="'+k+'"'+(k===NET?" selected":"")+'>'+esc(NETWORKS[k].name)+'</option>';}).join("");
var tabs = net.send
? '<div class="tabs"><button id="t-receive" class="'+(TAB==="receive"?"on":"")+'">Receive</button><button id="t-send" class="'+(TAB==="send"?"on":"")+'">Send</button></div>'
: '';
app.innerHTML = '<div class="bar"><select id="net">'+opts+'</select></div>'+tabs+'<div id="panel"></div>';
document.getElementById("net").onchange=function(){ NET=this.value; UTXOS=null; BUILT=null; if(!NETWORKS[NET].send)TAB="receive"; render(); refresh(); };
if(net.send){ document.getElementById("t-receive").onclick=function(){TAB="receive";render();}; document.getElementById("t-send").onclick=function(){TAB="send";render();}; }
if(TAB==="send"&&net.send) renderSend(net,addr); else renderReceive(net,addr);
}
function renderReceive(net,addr){
var bal=balanceSats();
document.getElementById("panel").innerHTML =
'<div class="card">'+
'<div class="bal" id="bal">'+(bal==null?"—":esc(fmtCoin(bal,net.coin).replace(" "+net.coin,'')))+'<span class="u"> '+esc(net.coin)+'</span></div>'+
'<div class="sub" id="balsub">'+(bal==null?("receive on "+esc(net.name)):(bal.toLocaleString()+" sats · "+esc(net.name)))+'</div>'+
'<div id="qr"></div>'+
'<div class="addr" id="addr" title="click to copy">'+esc(addr)+'</div>'+
'<div class="row"><button id="copy">Copy</button><a class="btn ghost" href="'+esc(net.exp)+'/address/'+esc(addr)+'" target="_blank" rel="noopener">Explorer ↗</a></div>'+
(net.mempool?"":'<div class="note">Balance & send via explorer for this chain.</div>')+
'<div class="npub" title="your nostr identity (same key)">nostr: '+esc(npub(XONLY))+'</div>'+
'</div>';
document.getElementById("copy").onclick=function(){ navigator.clipboard&&navigator.clipboard.writeText(addr); toast("Address copied"); };
document.getElementById("addr").onclick=function(){ navigator.clipboard&&navigator.clipboard.writeText(addr); toast("Address copied"); };
try{ var q=document.getElementById("qr"); q.innerHTML=""; new QRCode(q,{text:addr,width:160,height:160,correctLevel:QRCode.CorrectLevel.M}); }catch(e){}
}
function renderSend(net,addr){
var bal=balanceSats(), rate=(FEES&&FEES.halfHourFee)||1;
var feeOpts=FEES?[["fastestFee","High"],["halfHourFee","Medium"],["economyFee","Low"]].filter(function(o){return FEES[o[0]];})
.map(function(o){return '<option value="'+FEES[o[0]]+'"'+(o[0]==="halfHourFee"?" selected":"")+'>'+o[1]+' · '+FEES[o[0]]+' sat/vB</option>';}).join("")
: '<option value="1" selected>1 sat/vB</option>';
document.getElementById("panel").innerHTML =
'<div class="card form">'+
(net.real?'<div class="danger"><b>Mainnet — real bitcoin.</b> This is experimental software. Double-check the recipient and amount. You will be asked to type a confirmation.</div>':'')+
'<div class="maxrow"><label for="rcpt">Recipient</label></div>'+
'<input type="text" id="rcpt" placeholder="address ('+esc(net.prefix)+'1…), npub, or 64-hex pubkey" autocomplete="off">'+
'<div class="maxrow"><label for="amt">Amount (sats)</label>'+(bal!=null?'<a id="max">balance '+bal.toLocaleString()+' · max</a>':'')+'</div>'+
'<input type="number" id="amt" min="1" step="1" placeholder="0">'+
'<label for="fee">Fee rate</label><select id="fee">'+feeOpts+'</select>'+
'<label for="opr">Message (OP_RETURN, optional)</label>'+
'<input type="text" id="opr" maxlength="80" placeholder="up to 80 bytes, stored on-chain" autocomplete="off">'+
'<div class="row" style="justify-content:flex-start;margin-top:.9rem"><button id="review">Review transaction</button><button id="refresh" class="ghost">↻ Balance</button></div>'+
'<div id="rev"></div>'+
'<div class="note">Signs locally with your key (raw-key taproot, BIP340). Your key never leaves this page; only the signed transaction is broadcast.</div>'+
'</div>';
var maxBtn=document.getElementById("max"); if(maxBtn)maxBtn.onclick=function(){ document.getElementById("amt").value=Math.max(0,(bal||0)); };
document.getElementById("refresh").onclick=function(){ BUILT=null; refresh(); };
document.getElementById("review").onclick=doReview;
}
function doReview(){
var net=NETWORKS[NET], revEl=document.getElementById("rev");
revEl.innerHTML='<div class="muted note">Building & signing…</div>';
var rcpt=document.getElementById("rcpt").value;
var amt=parseInt(document.getElementById("amt").value,10);
var rate=parseFloat(document.getElementById("fee").value)||1;
var oprTxt=(document.getElementById("opr").value||"").trim();
try{
if(!(amt>0)) throw new Error("Enter an amount in sats.");
if(!UTXOS||!UTXOS.length) throw new Error("No spendable coins at this address yet.");
var oprHex=""; if(oprTxt){ var b=new TextEncoder().encode(oprTxt); if(b.length>80)throw new Error("Message exceeds 80 bytes."); oprHex=u8ToHex(b); }
}catch(e){ revEl.innerHTML='<div class="err">'+esc(e.message)+'</div>'; return; }
buildAndSign(rcpt,amt,rate,oprHex).then(function(built){
BUILT=built;
var s=built.sel;
var rows=''+
'<div><b>To</b><span class="v">'+esc(rcpt)+'</span></div>'+
'<div><b>Amount</b><span class="v">'+amt.toLocaleString()+' sats</span></div>'+
(oprTxt?'<div><b>Message</b><span class="v">'+esc(oprTxt)+'</span></div>':'')+
'<div><b>Fee</b><span class="v">'+s.fee.toLocaleString()+' sats · ~'+s.vsize+' vB</span></div>'+
(s.change>0?'<div><b>Change</b><span class="v">'+s.change.toLocaleString()+' sats</span></div>':'')+
'<div><b>Inputs</b><span class="v">'+s.ins.length+' · '+s.total.toLocaleString()+' sats</span></div>';
var confirmUi = net.real
? '<div class="danger">Type <b>SEND</b> to broadcast to <b>Bitcoin mainnet</b>.</div><input type="text" id="confirm" placeholder="SEND" autocomplete="off">'
: '';
revEl.innerHTML='<div class="summary">'+rows+'</div>'+
'<div class="rawhex" title="signed raw transaction">'+esc(built.hex)+'</div>'+
confirmUi+
'<div class="row" style="justify-content:flex-start;margin-top:.5rem"><button id="bcast">Broadcast</button><button id="cancel" class="ghost">Cancel</button></div>'+
'<div id="result"></div>';
document.getElementById("cancel").onclick=function(){ BUILT=null; revEl.innerHTML=''; };
document.getElementById("bcast").onclick=doBroadcast;
}).catch(function(e){ revEl.innerHTML='<div class="err">'+esc(e.message||String(e))+'</div>'; });
}
function doBroadcast(){
var net=NETWORKS[NET], resEl=document.getElementById("result"), btn=document.getElementById("bcast");
if(net.real){ var c=document.getElementById("confirm"); if(!c||c.value.trim()!=="SEND"){ resEl.innerHTML='<div class="err">Type SEND to confirm a mainnet broadcast.</div>'; return; } }
if(!BUILT){ resEl.innerHTML='<div class="err">Nothing to broadcast — review again.</div>'; return; }
btn.disabled=true; resEl.innerHTML='<div class="muted note">Broadcasting…</div>';
broadcast(BUILT.hex,net).then(function(txid){
if(!/^[0-9a-f]{64}$/i.test(txid)) throw new Error(txid);
resEl.innerHTML='<div class="ok">✓ Sent. txid: <a href="'+esc(net.exp)+'/tx/'+esc(txid)+'" target="_blank" rel="noopener">'+esc(txid)+'</a></div>';
toast("Transaction broadcast"); BUILT=null; UTXOS=null; setTimeout(refresh,1500);
}).catch(function(e){ btn.disabled=false; resEl.innerHTML='<div class="err">Broadcast failed: '+esc(e.message||String(e))+'</div>'; });
}
// refresh balance/utxos + fees for the current chain, then re-render the live bits
function refresh(){
var net=NETWORKS[NET]; if(!me()||!XONLY||!net.mempool) return;
var addr=taprootAddress(XONLY,net.prefix);
fetchUTXOs(addr,net).then(function(u){ UTXOS=u||[];
if(TAB==="receive"){ var el=document.getElementById("bal"), sub=document.getElementById("balsub"); var b=balanceSats();
if(el)el.innerHTML=(b==null?"—":esc(fmtCoin(b,net.coin).replace(" "+net.coin,'')))+'<span class="u"> '+esc(net.coin)+'</span>';
if(sub&&b!=null)sub.textContent=b.toLocaleString()+" sats · "+net.name;
} else { var mx=document.getElementById("max"); if(mx){ var bb=balanceSats(); mx.textContent="balance "+(bb||0).toLocaleString()+" · max"; } }
});
if(net.send&&!FEES) fetchFees(net).then(function(f){ FEES=f; if(TAB==="send")render(); });
}
function start(){
if(!me()){ render(); return; }
XONLY=null; UTXOS=null; BUILT=null; render();
identityXOnly().then(function(x){ XONLY=x; if(!x){ app.innerHTML='<div class="card muted">Could not derive a key from your identity.</div>'; return; } render(); refresh(); });
}
document.addEventListener("xlogin", start);
document.addEventListener("xlogout", function(){ XONLY=null; UTXOS=null; BUILT=null; render(); });
start();
</script>
</body>
</html>