// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 = `
${copy[0]}
${esc(copy[1])}
${esc(copy[2])}
`;
}
// 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 = `${state.scanning ? "Syncing…" : "No transactions yet."}
`; 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 `
${inc ? "↓" : "↑"}
${what}
${inc ? "+" : "−"}${fmtBch(Math.abs(t.delta))}
${esc(when)}${t.fee != null ? " · fee " + fmtSats(t.fee) + " sat" : ""}
${conf}
`;
}).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. ${esc(r.txid.slice(0, 16))}… `;
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 = `None yet.
`; 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 `${esc(o)}
${esc(what.join(" · "))}
Revoke `;
}).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 = `Account path
${esc(r.accountPath)}
Account xpub
${esc(r.xpub)}
`;
if (r.xprv) h += `Account private key (xprv)
${esc(r.xprv)}
`;
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 = `⚠
${esc(cleanErr(e))}
`; }
})();