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.
303 lines
14 KiB
JavaScript
303 lines
14 KiB
JavaScript
// Bitcoin Cash Wallet — bundled Theseus add-on. activate() runs in the main
|
|
// process; all key material lives here, in memory, and is re-derived from the
|
|
// password vault on every launch. Nothing secret is ever written to disk or
|
|
// logged — storage holds settings, the receive cursor, dapp permissions and a
|
|
// cache of public transactions only.
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
|
|
const NETWORK = "mainnet";
|
|
const PREFIX = "bitcoincash";
|
|
const DEFAULT_ACCOUNT_PATH = "m/44'/145'/0'";
|
|
const PURPOSE = "bchwallet/mainnet/0";
|
|
const EXPLORER_TX = "https://blockchair.com/bitcoin-cash/transaction/";
|
|
const EXPLORER_ADDR = "https://blockchair.com/bitcoin-cash/address/";
|
|
|
|
let ctx = null; // { api, keys, wallet, client, phase, error }
|
|
|
|
function defaultServers(api) {
|
|
try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); }
|
|
catch { return []; }
|
|
}
|
|
function serverList(api) {
|
|
const custom = api.storage.get("servers", null);
|
|
return Array.isArray(custom) && custom.length ? custom : defaultServers(api);
|
|
}
|
|
function accountPath(api) {
|
|
const p = String(api.storage.get("accountPath", "") || "").trim();
|
|
return /^m(\/\d+'?)+$/.test(p) ? p : DEFAULT_ACCOUNT_PATH;
|
|
}
|
|
|
|
async function deps(api) {
|
|
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
|
|
const { sha256 } = await api.import("@noble/hashes/sha2.js");
|
|
const { ripemd160 } = await api.import("@noble/hashes/legacy.js");
|
|
const { HDKey } = await api.import("@scure/bip32");
|
|
const WebSocket = api.require("ws");
|
|
const cashaddr = require("./lib/cashaddr.js");
|
|
const keysLib = require("./lib/keys.js")({ HDKey, secp256k1, sha256, ripemd160, cashaddr });
|
|
const tx = require("./lib/tx.js")({ sha256 });
|
|
const electrum = require("./lib/electrum.js")({ WebSocket, log: (...a) => api.log("electrum", ...a) });
|
|
return { sha256, cashaddr, keysLib, tx, electrum };
|
|
}
|
|
|
|
function snapshot() {
|
|
const c = ctx;
|
|
const base = {
|
|
network: NETWORK, phase: c.phase, error: c.error,
|
|
server: c.client ? c.client.url : null,
|
|
accountPath: accountPath(c.api),
|
|
servers: serverList(c.api),
|
|
customServers: Array.isArray(c.api.storage.get("servers", null)),
|
|
explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR,
|
|
};
|
|
if (c.wallet) Object.assign(base, c.wallet.snapshot(), { xpub: c.keys.xpub });
|
|
return base;
|
|
}
|
|
function emitState() { try { ctx.api.emit("state", snapshot()); } catch {} }
|
|
function setPhase(phase, error = null) { ctx.phase = phase; ctx.error = error; emitState(); }
|
|
|
|
// Build (or rebuild, after a settings change) the key tree + wallet from the
|
|
// vault-derived root. The root itself is kept only for re-derivation when the
|
|
// account path changes; it is a Uint8Array in this closure and nowhere else.
|
|
function buildWallet() {
|
|
const c = ctx;
|
|
if (c.wallet) { c.wallet.dispose(); c.wallet = null; }
|
|
if (c.keys) { c.keys.wipe(); c.keys = null; }
|
|
if (c.client) { c.client.clearSubscriptions(); c.client.setServers(serverList(c.api)); }
|
|
else c.client = new c.d.electrum.Client(serverList(c.api));
|
|
c.client.onServer = () => emitState();
|
|
c.keys = new c.d.keysLib.WalletKeys(c.root, accountPath(c.api), PREFIX);
|
|
c.wallet = require("./lib/wallet.js")({
|
|
client: c.client, keys: c.keys, tx: c.d.tx, cashaddr: c.d.cashaddr, sha256: c.d.sha256,
|
|
storage: c.api.storage, log: (...a) => c.api.log("wallet", ...a), onChange: emitState,
|
|
});
|
|
c.api.log("wallet ready, receive address", c.keys.entry(0, 0).address);
|
|
setPhase("ready");
|
|
c.wallet.refresh(true);
|
|
}
|
|
|
|
async function deriveAndStart() {
|
|
const c = ctx;
|
|
setPhase("locked");
|
|
try {
|
|
c.root = await c.api.vault.derive(PURPOSE);
|
|
} catch (e) {
|
|
const msg = e?.message || String(e);
|
|
setPhase(/not set up/i.test(msg) ? "nosetup" : "error", msg);
|
|
c.api.log("vault derive failed:", msg);
|
|
return;
|
|
}
|
|
if (!ctx || ctx !== c) return; // deactivated while waiting for unlock
|
|
try { buildWallet(); }
|
|
catch (e) { setPhase("error", e?.message || String(e)); c.api.log("wallet build failed:", e?.message); }
|
|
}
|
|
|
|
const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
|
|
function planFrom(p) {
|
|
const c = requireReady();
|
|
const spec = p || {};
|
|
const targets = Array.isArray(spec.outputs) && spec.outputs.length
|
|
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
|
|
: [{ to: spec.to, value: spec.amount ?? spec.value }];
|
|
return c.wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
|
|
}
|
|
function describePlan(plan) {
|
|
const sent = plan.recipients.reduce((a, r) => a + r.value, 0);
|
|
return { recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate, inputs: plan.inputs.length, change: plan.change, total: sent + plan.fee };
|
|
}
|
|
|
|
function requireReady() {
|
|
if (!ctx || ctx.phase !== "ready" || !ctx.wallet) throw new Error("wallet is not ready (vault locked?)");
|
|
return ctx;
|
|
}
|
|
function fromPanel(ctxMsg) { if (!ctxMsg || ctxMsg.from !== "panel") throw new Error("panel-only message"); }
|
|
|
|
function registerPanelMessages(api) {
|
|
api.onMessage("state", (_p, m) => { fromPanel(m); return snapshot(); });
|
|
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const c = requireReady(); await c.wallet.refresh(true); return snapshot(); });
|
|
api.onMessage("nextAddress", (_p, m) => { fromPanel(m); const c = requireReady(); c.wallet.nextUnusedAddress(); return snapshot(); });
|
|
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
|
|
api.onMessage("setSettings", (p, m) => {
|
|
fromPanel(m);
|
|
const patch = p || {};
|
|
if ("accountPath" in patch) {
|
|
const v = String(patch.accountPath || "").trim();
|
|
if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/44'/145'/0'");
|
|
api.storage.set("accountPath", v || DEFAULT_ACCOUNT_PATH);
|
|
}
|
|
if ("servers" in patch) {
|
|
const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`);
|
|
api.storage.set("servers", list.length ? list : null);
|
|
}
|
|
if (ctx && ctx.root) buildWallet();
|
|
return snapshot();
|
|
});
|
|
// Send preview — no side effects, used for the live fee/total summary.
|
|
api.onMessage("planSend", (p, m) => { fromPanel(m); return describePlan(planFrom(p)); });
|
|
// Send for real: plan -> approval overlay -> sign -> broadcast.
|
|
api.onMessage("send", async (p, m) => {
|
|
fromPanel(m);
|
|
const c = requireReady();
|
|
const plan = planFrom(p);
|
|
const d = describePlan(plan);
|
|
const pick = await api.approvalModal({
|
|
title: "Send Bitcoin Cash?",
|
|
origin: "Theseus wallet panel",
|
|
rows: [
|
|
{ label: "To", value: d.recipients[0].to, mono: true },
|
|
{ label: "Amount", value: fmtBch(d.recipients[0].value) + " BCH", strong: true },
|
|
{ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` },
|
|
{ label: "Total", value: fmtBch(d.total) + " BCH" },
|
|
],
|
|
actions: [{ id: "send", label: "Send", primary: true }],
|
|
});
|
|
if (pick !== "send") throw new Error("cancelled");
|
|
return c.wallet.signAndBroadcast(plan);
|
|
});
|
|
// Recovery info: xpub always; the account xprv only after an explicit
|
|
// confirmation in the approval overlay. Import the xprv into any BIP32
|
|
// wallet (branch 0 receive / 1 change) to move funds without Theseus.
|
|
api.onMessage("recovery", async (p, m) => {
|
|
fromPanel(m);
|
|
const c = requireReady();
|
|
const out = { accountPath: accountPath(api), xpub: c.keys.xpub, purpose: PURPOSE };
|
|
if (p && p.reveal) {
|
|
const pick = await api.approvalModal({
|
|
title: "Reveal the account private key?",
|
|
origin: "Theseus wallet panel",
|
|
body: "Anyone holding this key can spend every coin in this wallet. It stays on screen until you close the Settings tab.",
|
|
actions: [{ id: "reveal", label: "Reveal", danger: true }],
|
|
});
|
|
if (pick === "reveal") out.xprv = c.keys.xprv;
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
// ---- dapp bridge (window.bitcoincash) ---------------------------------------
|
|
// Permissions live in storage as { [origin]: { readAddress: true } }. Only
|
|
// readAddress can be remembered; signing and sending ask every time.
|
|
const pendingByOrigin = new Set();
|
|
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
|
|
function fromPage(m) {
|
|
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
|
|
return m.origin;
|
|
}
|
|
// One approval in flight per origin — a page can't stack modals.
|
|
async function withOriginLock(origin, fn) {
|
|
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
|
|
pendingByOrigin.add(origin);
|
|
try { return await fn(); } finally { pendingByOrigin.delete(origin); }
|
|
}
|
|
const MAGIC = "Bitcoin Signed Message:\n";
|
|
function messageDigest(sha256, message) {
|
|
const enc = new TextEncoder();
|
|
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); };
|
|
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
|
|
return sha256(sha256(payload));
|
|
}
|
|
|
|
function registerPageMessages(api) {
|
|
api.onMessage("getAddress", async (_p, m) => {
|
|
const origin = fromPage(m);
|
|
const c = requireReady();
|
|
const perms = permissions(api);
|
|
if (perms[origin] && perms[origin].readAddress) return c.wallet.current().address;
|
|
return withOriginLock(origin, async () => {
|
|
const pick = await api.approvalModal({
|
|
title: "Share your Bitcoin Cash address?",
|
|
origin,
|
|
body: "The site will see your current receiving address and can look up its balance and history on the public chain.",
|
|
rows: [{ label: "Address", value: c.wallet.current().address, mono: true }],
|
|
actions: [{ id: "allow", label: "Share", primary: true }],
|
|
checkbox: { id: "always", label: "Always allow this site to see my address" },
|
|
});
|
|
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
|
if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); }
|
|
return c.wallet.current().address;
|
|
});
|
|
});
|
|
api.onMessage("signAndSend", async (p, m) => {
|
|
const origin = fromPage(m);
|
|
const c = requireReady();
|
|
return withOriginLock(origin, async () => {
|
|
let plan;
|
|
// Never echo the shortfall to a page — it would let a site probe the
|
|
// balance by bisecting amounts.
|
|
try { plan = planFrom(p); }
|
|
catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); }
|
|
const d = describePlan(plan);
|
|
if (d.recipients.length > 8) throw new Error("too many outputs");
|
|
const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true }));
|
|
rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true });
|
|
rows.push({ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` });
|
|
rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" });
|
|
const pick = await api.approvalModal({
|
|
title: "Send Bitcoin Cash?",
|
|
origin,
|
|
body: "This site is asking your wallet to pay. Check the address and amount.",
|
|
rows,
|
|
actions: [{ id: "send", label: "Send", primary: true }],
|
|
});
|
|
if (pick !== "send") throw new Error("user rejected");
|
|
const r = await c.wallet.signAndBroadcast(plan);
|
|
return { txid: r.txid };
|
|
});
|
|
});
|
|
api.onMessage("signMessage", async (p, m) => {
|
|
const origin = fromPage(m);
|
|
const c = requireReady();
|
|
const message = String(p && p.message != null ? p.message : "");
|
|
if (message.length > 4096) throw new Error("message too long");
|
|
return withOriginLock(origin, async () => {
|
|
const entry = c.wallet.current();
|
|
const pick = await api.approvalModal({
|
|
title: "Sign a message?",
|
|
origin,
|
|
body: "Signing proves you control the address below. It moves no coins.",
|
|
rows: [{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Address", value: entry.address, mono: true }],
|
|
actions: [{ id: "sign", label: "Sign", primary: true }],
|
|
});
|
|
if (pick !== "sign") throw new Error("user rejected");
|
|
const sig = c.keys.signRecoverable(entry, messageDigest(c.d.sha256, message));
|
|
return { address: entry.address, signature: Buffer.from(sig).toString("base64") };
|
|
});
|
|
});
|
|
// Panel-side management of remembered sites.
|
|
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
|
|
api.onMessage("revoke", (p, m) => {
|
|
fromPanel(m);
|
|
const perms = permissions(api);
|
|
delete perms[String(p && p.origin || "")];
|
|
api.storage.set("permissions", perms);
|
|
return perms;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
activate(api) {
|
|
api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" });
|
|
const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null };
|
|
registerPanelMessages(api);
|
|
registerPageMessages(api);
|
|
deps(api).then((d) => {
|
|
if (ctx !== c) return;
|
|
c.d = d;
|
|
return deriveAndStart();
|
|
}).catch((e) => {
|
|
if (ctx !== c) return;
|
|
setPhase("error", e?.message || String(e));
|
|
api.log("startup failed:", e?.message);
|
|
});
|
|
},
|
|
deactivate() {
|
|
const c = ctx; ctx = null;
|
|
if (!c) return;
|
|
try { c.wallet && c.wallet.dispose(); } catch {}
|
|
try { c.keys && c.keys.wipe(); } catch {}
|
|
try { c.client && c.client.disconnect(); } catch {}
|
|
if (c.root) c.root.fill(0);
|
|
},
|
|
};
|