theseus/bundled-addons/bchwallet/index.js

167 lines
7.1 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); }
}
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();
});
// 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;
});
}
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);
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);
},
};