// Imported BCH wallet — single-address, key material lives in Theseus's // wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's // public shape (snapshot, refresh, plan, signAndBroadcast, signMessage, // dispose) but does NOT go through vault.derive + HKDF: derivation is // direct from the seed+path or WIF that the user imported. // // 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's // single scripthash UTXO set; signAndBroadcast() pulls the signer material // from api.vault.imports.signer(importId), decodes the WIF or derives the // mnemonic/path into a 32-byte priv key, and signs every input in RAM. // The private key never lands in adapter state — signAndBroadcast fetches // it fresh per broadcast and drops it before returning. module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx, HDKey, secp256k1, base58check, vaultImports, }) { const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); const scripthashOf = (script) => toHex(sha256(script).slice().reverse()); const hash160 = (b) => ripemd160(sha256(b)); const IMPORTED_BCH_NETWORKS = { mainnet: { id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80, explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", explorerAddr: "https://blockchair.com/bitcoin-cash/address/", defaultServers: [ "wss://bch.imaginary.cash:50004", "wss://cashnode.bch.ninja:50004", "wss://electroncash.dk:50004", "wss://fulcrum.jettscythe.xyz:50004", ], }, chipnet: { id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef, explorerTx: "https://chipnet.imaginary.cash/tx/", explorerAddr: "https://chipnet.imaginary.cash/address/", defaultServers: [ "wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004", ], faucet: "https://tbch.googol.cash/", }, }; function h160OfCashaddr(addr) { const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, ""); const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean); if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`); return hash; } // Decode a WIF-encoded private key. Accepts both mainnet (0x80) and // testnet (0xef) version bytes and both compressed and uncompressed // forms; returns { priv (32 bytes), compressed (bool) }. function decodeWif(wif, versionByte) { const bytes = base58check.decodeCheck(String(wif).trim()); if (!(bytes[0] === versionByte || bytes[0] === 0x80 || bytes[0] === 0xef)) { throw new Error(`unexpected WIF version 0x${bytes[0].toString(16)}`); } const compressed = bytes.length === 34 && bytes[33] === 0x01; const priv = bytes.slice(1, 33); if (priv.length !== 32) throw new Error("WIF payload is not 32 bytes"); return { priv, compressed }; } // Derive a P2PKH signer (32-byte priv + 33-byte compressed pubkey) from // whatever vault.imports.signer returned. Two shapes today: // { kind: "seed", seed: hex, path: "m/…" } — BIP32 derivation // { kind: "wif", wif: base58check } — direct decode // Anything else (or a missing signer) throws with a clear message so // the panel can surface it rather than the broadcast returning garbage. function signerToKey(signerBlob, net) { if (!signerBlob) throw new Error("no signer material for this wallet"); if (signerBlob.kind === "seed") { const seed = signerBlob.seed; if (!/^[0-9a-f]+$/i.test(seed)) throw new Error("seed material must be hex"); const seedBytes = Uint8Array.from(seed.match(/../g).map((x) => parseInt(x, 16))); const node = HDKey.fromMasterSeed(seedBytes).derive(signerBlob.path || "m"); return { priv: node.privateKey, pub: node.publicKey }; } if (signerBlob.kind === "wif") { const { priv } = decodeWif(signerBlob.wif, net.wifVersion); const pub = secp256k1.getPublicKey(priv, true); return { priv, pub }; } throw new Error(`unknown signer kind: ${signerBlob.kind}`); } class ImportedBchWallet { constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers, importId, } = {}) { const net = IMPORTED_BCH_NETWORKS[network]; if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`); if (!address) throw new Error("chain-bch-imported: cashaddr required"); this.walletId = walletId; // importId is the vault-side id used to fetch the signer at // sign-time. Optional here so a mount without spend capability // still works (read-only surface unaffected). this._importId = importId || null; this.chain = "bch"; this.network = net.id; this._net = net; this.log = log; this.onChange = onChange; this._address = address; this._h160 = h160OfCashaddr(address); this._script = p2pkhScript(this._h160); this._scripthash = scripthashOf(this._script); this._scriptHex = toHex(this._script); this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); this._client = new electrum.Client(this._servers); this._client.onServer = () => this._emit(); this._state = { balance: { confirmed: 0, unconfirmed: 0 }, history: [], utxos: [], height: 0, scanning: false, error: null, }; } setServers(list) { this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); this._client.setServers(this._servers); } schedulePoll(ms) { clearTimeout(this._pollTimer); this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms); } _emit() { try { this.onChange(); } catch {} } snapshot() { return { chain: "bch", network: this._net.id, ticker: "BCH", decimals: 8, address: this._address, addressIndex: 0, addressPath: null, balance: this._state.balance, height: this._state.height, history: this._state.history, scanning: this._state.scanning, error: this._state.error, server: this._client.url || null, servers: this._servers, imported: true, // 0.6.36+: imported wallets can spend when the vault signer is // reachable (i.e. Theseus is unlocked). canSpend reflects that so // the panel can enable the Send tab without probing. canSpend: !!this._importId, explorerTx: this._net.explorerTx, explorerAddr: this._net.explorerAddr, faucet: this._net.faucet, }; } async refresh(full) { this._state.scanning = true; this._emit(); try { const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]); this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; // Always pull UTXOs so spend / send-max work off fresh state. const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]); this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({ txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0), })); if (full) { const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]); this._state.history = (hist || []).slice(-50).map((h) => ({ txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, })); } this._state.error = null; } catch (e) { this._state.error = e?.message || String(e); } finally { this._state.scanning = false; this._emit(); } } nextAddress() { return { address: this._address, index: 0 }; } current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex, }; } // 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's // own UTXO set. Signing happens in signAndBroadcast, which fetches the // key material from Theseus's vault at broadcast time — nothing key- // bearing lives in the plan itself, so a plan can round-trip through // the approval overlay without leaking secrets. plan(spec) { if (!this._state.utxos.length) throw new Error("wallet has no unspent outputs to spend from"); 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 }]; const outs = targets.map((t) => { const a = cashaddr.parseAny(t.to, sha256, this._net.prefix); const script = a.type === 0 ? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac]) : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]); return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; }); if (spec?.memo) outs.push({ value: 0, script: tx.memoScript(String(spec.memo)), data: true, memo: String(spec.memo) }); const rate = Math.min(10, Math.max(1, Number(spec?.feeRate) || 1)); // Imported wallets have exactly one address, so change goes back to // itself — no need to derive a fresh change entry from an HD tree. const changeScript = this._script; const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax }); const nonData = sel.outputs.filter((o) => !o.data); const total = sel.outputs.reduce((a, o) => a + o.value, 0); return { ...sel, feeRate: rate, recipients: nonData .filter((_, i) => outs[i] && !outs[i].data) .map((o, i) => ({ to: outs[i].to, value: o.value })), memo: spec?.memo || null, total, }; } async signAndBroadcast(plan) { if (!this._importId) throw new Error("this wallet has no signer registered"); if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable"); const signerBlob = await vaultImports.signer(this._importId); let key; try { key = signerToKey(signerBlob, this._net); // Belt-and-braces: the signer must match the wallet's own address. // Catches vault-side corruption and any accidental cross-mount. const derivedH160 = hash160(key.pub); const same = derivedH160.length === this._h160.length && derivedH160.every((b, i) => b === this._h160[i]); if (!same) throw new Error("signer material does not match this wallet's address"); const inputs = plan.inputs.map((u) => ({ ...u, script: this._script })); const t = { inputs, outputs: plan.outputs }; const signed = tx.sign(t, (inp, _i, digest) => ({ sig: secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "der" }), publicKey: key.pub, })); const txid = await this._client.call("blockchain.transaction.broadcast", [signed.hex]); if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); this.log("broadcast", txid); setTimeout(() => this.refresh(false).catch(() => {}), 1500); return { txid, hex: signed.hex, fee: plan.fee }; } finally { // Wipe the private material before returning. Not perfect (JS can // still relocate the underlying buffer during GC) but it minimises // the window in which the raw key sits in this frame. if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} } } } // BIP-137 message signing from the imported key. Same MAGIC / double- // sha256 payload as chain-bch.js so the resulting sig verifies through // Electron Cash and every other BCH tool. async signMessage(message) { if (!this._importId) throw new Error("this wallet has no signer registered"); if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable"); const signerBlob = await vaultImports.signer(this._importId); let key; try { key = signerToKey(signerBlob, this._net); const enc = new TextEncoder(); const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; const MAGIC = "Bitcoin Signed Message:\n"; const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); const digest = sha256(sha256(payload)); const sig = secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "recovered" }); const out = new Uint8Array(65); out[0] = 27 + sig[0] + 4; out.set(sig.subarray(1), 1); return { address: this._address, signature: Buffer.from(out).toString("base64") }; } finally { if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} } } } recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; } dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} } } return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; };