theseus/bundled-addons/bchwallet/index.js

304 lines
14 KiB
JavaScript
Raw Normal View History

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