// 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); const el = $("sites"); if (!origins.length) { el.innerHTML = `
None yet.
`; return; } el.innerHTML = origins.map((o) => `
${esc(o)}
`).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))}
`; } })();