// 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); }, };