theseus/bundled-addons/bchwallet/panel.js
Local Dev 821cc8e808 feat(theseus/bchwallet): send — plan, approval overlay, sign, broadcast
Send tab: recipient (cashaddr or legacy, testnet rejected), amount with
BCH/sat toggle and Max, 1-5 sat/B fee slider, live fee/total preview via
planSend. Sending goes plan -> approval-modal (To/Amount/Fee/Total) ->
ECDSA DER + SIGHASH_ALL|FORKID -> blockchain.transaction.broadcast, then
shows the txid with an explorer link. Confirmed coins are spent before
unconfirmed; dust change folds into the fee. Verified end to end against a
fake Fulcrum: broadcast tx re-parsed, sighash recomputed, signature checks.
2026-09-06 02:49:09 +02:00

229 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Wallet panel. All state comes from the add-on's activate() context via
// window.silentmode.invoke / on; this file only renders and collects input.
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
let state = null;
let tab = "receive";
// 8 decimals, trailing zeros trimmed, never fewer than two: 0.00, 0.001, 1.23456789
function fmtBch(sats) {
let s = (Number(sats || 0) / 1e8).toFixed(8).replace(/0+$/, "");
if (s.endsWith(".")) s += "00"; else if (/\.\d$/.test(s)) s += "0";
return s;
}
const fmtSats = (sats) => Number(sats || 0).toLocaleString("en-US");
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const hostOf = (url) => { try { return new URL(url).host; } catch { return url; } };
const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {});
// ---- tabs ------------------------------------------------------------------
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
function showTab(name) {
tab = name;
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
if (name === "settings") fillSettings();
}
// ---- render ----------------------------------------------------------------
function render() {
if (!state) return;
const ready = state.phase === "ready";
const gate = $("gate");
$("tabs").hidden = !ready;
gate.hidden = ready;
if (!ready) {
const copy = {
locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings Passwords. The wallet keys derive from the vault seed, so there is nothing separate to unlock."],
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings Passwords Set up. Use a recovery phrase there and this wallet can be recreated from it on any machine."],
error: ["⚠", "The wallet could not start.", state.error || ""],
}[state.phase] || ["…", "Starting…", ""];
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>`;
}
// header
const dot = $("dot");
dot.className = "dot " + (state.server ? (state.scanning ? "busy" : "on") : "");
$("netlbl").textContent = state.server ? hostOf(state.server) + (state.scanning ? " · syncing" : "") : (ready ? "connecting…" : "mainnet");
if (ready) {
const total = (state.balance?.confirmed || 0) + (state.balance?.unconfirmed || 0);
$("balBch").textContent = fmtBch(total);
const parts = [fmtSats(total) + " sat"];
if (state.balance?.unconfirmed) parts.push(fmtBch(state.balance.unconfirmed) + " unconfirmed");
if (state.height) parts.push("block " + fmtSats(state.height));
$("balSub").textContent = parts.join(" · ");
} else { $("balBch").textContent = "—"; $("balSub").textContent = "mainnet"; }
if (!ready) return;
// receive
if (state.address && $("addr").textContent !== state.address) {
$("addr").textContent = state.address;
drawQr("bitcoincash:" + state.address.replace(/^bitcoincash:/, ""));
}
$("addrMeta").textContent = `· #${state.addressIndex} · ${state.addressPath || ""}`;
// history
renderHistory();
if (state.error) { $("balSub").textContent = state.error; }
}
function drawQr(text) {
const cv = $("qr");
const g = cv.getContext("2d");
let q;
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
const px = (q.size + 2) * scale;
cv.width = cv.height = px;
cv.style.width = cv.style.height = px + "px";
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
g.fillStyle = "#000";
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
}
function renderHistory() {
const list = state.history || [];
const el = $("txlist");
if (!list.length) { el.innerHTML = `<div class="empty">${state.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; }
el.innerHTML = list.map((t) => {
const inc = t.delta >= 0;
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
const what = inc ? "Received" : ("Sent" + (t.to ? " to " + esc(t.to.replace(/^bitcoincash:/, "").slice(0, 12)) + "…" : ""));
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : "unconfirmed";
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div>
<div class="what">${what}</div>
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : ""}${fmtBch(Math.abs(t.delta))}</div>
<div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSats(t.fee) + " sat" : ""}</div>
<div class="conf ${t.confirmations > 0 ? "" : "pending"}">${conf}</div>
</div>`;
}).join("");
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(state.explorerTx + row.dataset.txid)));
}
// ---- receive actions -------------------------------------------------------
$("copyAddr").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(state.address); flash($("copyAddr"), "Copied"); } catch {}
});
$("nextAddr").addEventListener("click", async () => {
try { state = await S.invoke("nextAddress"); render(); } catch (e) { flash($("nextAddr"), "Failed"); }
});
$("viewAddr").addEventListener("click", () => openUrl(state.explorerAddr + state.address));
function flash(btn, text) {
const old = btn.textContent; btn.textContent = text;
setTimeout(() => { btn.textContent = old; }, 1200);
}
// ---- send ------------------------------------------------------------------
let unit = "bch";
let sendMax = false;
let planTimer = null;
let lastPlan = null;
function amountSats() {
const raw = $("sendAmt").value.trim().replace(/,/g, "");
if (!raw) return 0;
if (unit === "sat") return Math.round(Number(raw));
const [w, f = ""] = raw.split(".");
return Number(w || 0) * 1e8 + Math.round(Number("0." + (f || "0")) * 1e8);
}
function setUnit(u) {
if (u === unit) return;
const s = amountSats();
unit = u;
$("unitBch").classList.toggle("on", u === "bch"); $("unitSat").classList.toggle("on", u === "sat");
$("sendAmt").placeholder = u === "bch" ? "0.00" : "0";
if (s) $("sendAmt").value = u === "bch" ? fmtBch(s) : String(s);
}
$("unitBch").addEventListener("click", () => setUnit("bch"));
$("unitSat").addEventListener("click", () => setUnit("sat"));
$("sendMax").addEventListener("click", () => {
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); }));
function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); }
async function updatePlan() {
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
if (!to || (!sendMax && !amountSats())) return;
try {
const p = await S.invoke("planSend", { to, amount: amountSats(), feeRate: Number($("feeRate").value), sendMax });
lastPlan = p;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBch(p.recipients[0].value) + " BCH";
$("sumFee").textContent = fmtSats(p.fee) + " sat";
$("sumTotal").textContent = fmtBch(p.total) + " BCH";
if (sendMax) $("sendAmt").value = unit === "bch" ? fmtBch(p.recipients[0].value) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
if (!lastPlan) return;
const msg = $("sendMsg"); msg.hidden = true;
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
const r = await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountSats(), feeRate: Number($("feeRate").value), sendMax });
msg.className = "msg ok";
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(state.explorerTx + r.txid));
msg.hidden = false;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
lastPlan = null;
} catch (e) {
const t = cleanErr(e);
if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; }
$("sendBtn").disabled = !lastPlan;
} finally { $("sendBtn").textContent = "Send"; }
});
// ---- settings --------------------------------------------------------------
let settingsFilled = false;
function fillSettings() {
if (!state) return;
if (!settingsFilled) {
$("setPath").value = state.accountPath || "";
$("setServers").value = (state.servers || []).join("\n");
settingsFilled = true;
}
$("serverHint").textContent = (state.customServers ? "Custom list." : "Bundled defaults.") + (state.server ? " Connected to " + hostOf(state.server) + "." : " Not connected.");
$("purpose").textContent = "silentmode/addons/bchwallet/mainnet/0";
}
$("applySettings").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean);
state = await S.invoke("setSettings", { accountPath: $("setPath").value, servers: state.customServers || servers.join() !== (state.servers || []).join() ? servers : undefined });
settingsFilled = false; fillSettings(); render();
flash($("applySettings"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("resetServers").addEventListener("click", async () => {
try { state = await S.invoke("setSettings", { servers: [] }); settingsFilled = false; fillSettings(); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
$("showXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", {}); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); }
});
$("showXprv").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); }
});
function recoveryHtml(r) {
let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`;
if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`;
return h;
}
const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, "");
// Wipe a revealed key when the user leaves the Settings tab.
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => { if (b.dataset.tab !== "settings") $("recovery").innerHTML = ""; }));
// ---- boot ------------------------------------------------------------------
S.on("state", (s) => { state = s; render(); });
(async () => {
try { state = await S.invoke("state"); render(); }
catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
})();