wallet-inject.js runs in the isolated world of https://*.x pages and exposes window.bitcoincash { isTheseus, version, network, getAddress, signAndSend, signMessage }. Every call is routed page -> addon-page-msg -> activate() handler -> approval overlay showing the requesting origin: - getAddress: approval with an "always allow" checkbox; grants persist in api.storage.permissions and are listed/revocable under Settings. - signAndSend / signMessage: approval on every call, never remembered. signMessage returns a BIP-137 recoverable signature (verified offline). - one pending approval per origin; page-facing errors never echo balance. Host fix: the inject IPC assigned event.returnValue twice, so pages always got an empty script list.
241 lines
13 KiB
JavaScript
241 lines
13 KiB
JavaScript
// 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 = `<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);
|
||
const el = $("sites");
|
||
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
|
||
el.innerHTML = origins.map((o) => `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div class="mono">${esc(o)}</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>`; }
|
||
})();
|