2026-09-06 02:33:27 +02:00
|
|
|
// 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
|
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 02:33:27 +02:00
|
|
|
module.exports = {
|
|
|
|
|
activate(api) {
|
|
|
|
|
api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" });
|
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
|
|
|
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);
|
2026-09-06 02:33:27 +02:00
|
|
|
},
|
|
|
|
|
};
|