theseus/bundled-addons/bchwallet/panel.js
Local Dev 40391798e0 feat(theseus/bchwallet): per-site payment allowance for remembered sends
The dapp send approval gains an "Afterwards" dropdown: ask every time, or
allow up to 0.001 / 0.01 / 0.1 BCH more without asking. The allowance is
stored as permissions[origin].sendTx {capSats, usedSats}; sends within the
remainder go through silently and draw it down, a larger request re-prompts
(showing what is left) and the choice made there replaces the allowance.
No unlimited option. Settings > Connected sites shows the remaining budget
and Revoke clears it. Message signing still asks every time.

Host: approvalModal accepts `select` {id, label, options}; a chosen value
comes back as "+<id>=<value>" and is validated against the offered options.
2026-09-06 12:43:17 +02:00

246 lines
13 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";
renderSites();
}
async function renderSites() {
let perms = {};
try { perms = await S.invoke("permissions"); } catch {}
const origins = Object.keys(perms).filter((o) => perms[o] && (perms[o].readAddress || perms[o].sendTx));
const el = $("sites");
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
el.innerHTML = origins.map((o) => {
const p = perms[o]; const what = [];
if (p.readAddress) what.push("address");
if (p.sendTx) what.push(`payments: ${fmtBch(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)))} of ${fmtBch(p.sendTx.capSats)} BCH left`);
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
}).join("");
el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => {
try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {}
}));
}
$("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>`; }
})();