// Bitcoin (BTC) chain adapter — mainnet + testnet3. BIP84 native SegWit, // bitcoinjs-lib for the tx/PSBT primitives, Aegis's own ElectrumX transport // for the network side. Very close in shape to chain-dgb.js; the two could // share a "bip84-electrum" helper later, but for now a distinct file keeps // the chain-specific tuning (electrum pool, network object) visible. // // Address family: BIP84 only in this rev — bc1q… (mainnet) / tb1q… (testnet). // BIP44 (1…) and BIP49 (3…) are trivially reachable by editing accountPath // to m/44'/0'/0' or m/49'/0'/0' respectively; the PSBT layer already // supports the resulting scripts because bitcoinjs-lib does. An explicit // address-family picker like DGB's is a follow-up. const NETWORKS = { mainnet: { id: "mainnet", label: "Mainnet", hrp: "bc", coinType: 0, defaultAccountPath: "m/84'/0'/0'", explorerTx: "https://mempool.space/tx/", explorerAddr: "https://mempool.space/address/", defaultServers: [ "wss://electrum.blockstream.info:50004", "wss://bitcoin.lu.ke:50004", "wss://fulcrum.grey.pw:50004", ], faucet: null, }, testnet: { id: "testnet", label: "Testnet3", hrp: "tb", coinType: 1, defaultAccountPath: "m/84'/1'/0'", explorerTx: "https://mempool.space/testnet/tx/", explorerAddr: "https://mempool.space/testnet/address/", defaultServers: [ "wss://testnet.aranguren.org:51004", "wss://blockstream.info:993", ], faucet: "https://coinfaucet.eu/en/btc-testnet/", }, signet: { // Signet (BIP-325) shares testnet's address format and SLIP-44 coin // type (1), so bitcoinjs-lib's `networks.testnet` handles address // derivation unchanged. The chain itself is a separate, permissioned // testnet with its own genesis + signer-signed blocks; from a wallet's // point of view, the only differences are the electrum pool serving // it and the explorer URL for tx lookups. id: "signet", label: "Signet", hrp: "tb", coinType: 1, defaultAccountPath: "m/84'/1'/0'", explorerTx: "https://mempool.space/signet/tx/", explorerAddr: "https://mempool.space/signet/address/", defaultServers: [ "wss://signet.aranguren.org:51102", "wss://signet-electrumx.wakiyamap.dev:50003", ], faucet: "https://signetfaucet.com/", }, }; module.exports = function makeBtcAdapter({ bitcoinjs, bip32Factory, ecpairFactory, ecc, sha256, electrum, }) { if (!bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) { throw new Error("chain-btc: missing dep"); } const { payments, Psbt, networks: bjsNetworks } = bitcoinjs; const bip32 = bip32Factory(ecc); const ECPair = ecpairFactory(ecc); // Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend // wired to a curve implementation — @bitcoinerlab/secp256k1 provides both // ECDSA and schnorr, so initEccLib once at load makes p2tr resolve. try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {} // Map our network id → bitcoinjs-lib Network object. Signet shares // testnet's address prefixes + magic (BIP-325 defines only new consensus // rules; the p2p / address layer stays testnet-compatible). function bjsNetworkFor(id) { if (id === "mainnet") return bjsNetworks.bitcoin; if (id === "testnet" || id === "signet") return bjsNetworks.testnet; throw new Error("chain-btc: unknown network " + id); } const toHex = (b) => Buffer.from(b).toString("hex"); const scripthashOf = (scriptBuf) => Buffer.from(sha256(scriptBuf)).reverse().toString("hex"); function scopedStorage(storage, keyPrefix) { const k = (key) => keyPrefix + key; return { get: (key, fallback = null) => storage.get(k(key), fallback), set: (key, value) => storage.set(k(key), value), }; } // Address-family shape from the derivation-path purpose. Every field the // PSBT layer might need for signing an input funded by this family is // captured here so signAndBroadcast has one code path per family. function paymentFor(purpose, node, network) { const pubkey = Buffer.from(node.publicKey); if (purpose === 44) { // Legacy P2PKH. Signing needs the full previous transaction // (nonWitnessUtxo) — one extra electrum call per input at send time. const p = payments.p2pkh({ pubkey, network }); return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true }; } if (purpose === 49) { // P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh // output) alongside the witnessUtxo. const redeem = payments.p2wpkh({ pubkey, network }); const p = payments.p2sh({ redeem, network }); return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" }; } if (purpose === 86) { // BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair // (see signerFor + signAndBroadcast); the internal 32-byte x-only // pubkey is captured here so the PSBT input can carry it. const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); const p = payments.p2tr({ internalPubkey, network }); return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" }; } // Default: BIP84 native SegWit. const p = payments.p2wpkh({ pubkey, network }); return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" }; } function purposeOfPath(accountPath) { const m = /^m\/(\d+)'\//.exec(String(accountPath || "")); return m ? Number(m[1]) : 84; } class WalletKeys { constructor(root32, accountPath, bjsNetwork) { this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'"; this._purpose = purposeOfPath(this._accountPath); this._network = bjsNetwork; this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork); this._account = this._root.derivePath(this._accountPath); this._branch = [this._account.derive(0), this._account.derive(1)]; this._cache = new Map(); } get xpub() { return this._account.neutered().toBase58(); } get xprv() { return this._account.toBase58(); } get accountPath() { return this._accountPath; } get purpose() { return this._purpose; } entry(branch, index) { const k = branch + "/" + index; let e = this._cache.get(k); if (!e) { const node = this._branch[branch].derive(index); const pay = paymentFor(this._purpose, node, this._network); e = { branch, index, path: this._accountPath + "/" + branch + "/" + index, publicKey: Buffer.from(node.publicKey), family: pay.family, sendKind: pay.send, script: pay.output, scriptHex: pay.output.toString("hex"), scripthash: scripthashOf(pay.output), address: pay.address, redeemScript: pay.redeem || null, tapInternalKey: pay.internalPubkey || null, _node: node, }; this._cache.set(k, e); } return e; } signerFor(entry) { return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._network }); } wipe() { for (const e of this._cache.values()) e._node = null; this._cache.clear(); this._branch = null; this._account = null; this._root = null; } } // Fee vsize model per family. Values are rounded vsize contributions from // standard tx-size tables; the estimator is pessimistic enough to cover a // real broadcast without underpaying. const OVERHEAD_VB = 10.5; const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take const INPUT_VB = { p2pkh: 148, // (32+4)+1+107+4 legacy input "p2sh-p2wpkh": 91, // 40 base + ~205/4 witness p2wpkh: 68, // 41 base + 108/4 witness p2tr: 58, // 41 base + 66/4 witness (key-path) }; const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); class BtcWallet { constructor(root32, networkId, { walletId, storage, log = () => {}, onChange = () => {}, servers, accountPath, } = {}) { if (!walletId) throw new Error("chain-btc: walletId required"); const net = NETWORKS[networkId]; if (!net) throw new Error("chain-btc: unknown network " + networkId); this.walletId = walletId; this.chain = "btc"; this.network = net.id; this._net = net; this._bjsNet = bjsNetworkFor(net.id); this.log = log; this.onChange = onChange; this.storage = scopedStorage(storage, `wallets/${walletId}/`); this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); const wantPath = accountPath || net.defaultAccountPath; this._keys = new WalletKeys(root32, wantPath, this._bjsNet); this._root = new Uint8Array(root32); this._client = new electrum.Client(this._servers); this._client.onServer = () => this._emit(); this._state = { used: new Set(), watched: new Map(), height: 0, balance: { confirmed: 0, unconfirmed: 0 }, utxos: [], history: [], receiveIndex: 0, scanning: false, error: null, }; this._refreshTimer = null; this._subscribedHeaders = false; this._client.onNotify = (method, params) => { if (method === "blockchain.headers.subscribe") { const h = params && params[0] && params[0].height; if (h) { this._state.height = h; this._scheduleRefresh(1500); } } else if (method === "blockchain.scripthash.subscribe") { this._scheduleRefresh(800); } }; } _emit() { try { this.onChange(); } catch {} } async _historyOf(entry) { const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]); return Array.isArray(h) ? h : []; } async _scan() { const cursor = Number(this.storage.get("receiveCursor", 0)) || 0; const GAP = 20; for (const branch of [0, 1]) { let gap = 0, i = 0; const minIndex = branch === 0 ? cursor + 1 : 0; while (gap < GAP || i < minIndex + GAP) { const batch = []; for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k)); const results = await Promise.all(batch.map((e) => this._historyOf(e))); for (let k = 0; k < batch.length; k++) { const e = batch[k]; this._state.watched.set(e.scripthash, e); if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++; i++; if (gap >= GAP && i >= minIndex + GAP) break; } } } let r = cursor; while (this._state.used.has("0/" + r)) r++; this._state.receiveIndex = r; this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); } async _subscribeAll() { if (!this._subscribedHeaders) { this._subscribedHeaders = true; const tip = await this._client.subscribe("blockchain.headers.subscribe", []); if (tip && tip.height) this._state.height = tip.height; } await Promise.all([...this._state.watched.values()].map((e) => this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); } async _loadUtxos() { const lists = await Promise.all([...this._state.watched.values()].map(async (e) => { const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]); return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); })); this._state.utxos = lists.flat(); let confirmed = 0, unconfirmed = 0; for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } this._state.balance = { confirmed, unconfirmed }; } async _loadHistory() { const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index)); const merged = new Map(); const lists = await Promise.all(entries.map((e) => this._historyOf(e))); for (const list of lists) for (const h of list) { const prev = merged.get(h.tx_hash); if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); } const ordered = [...merged.values()].sort((a, b) => { const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; return hb - ha; }).slice(0, 25); const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex)); const out = []; for (const h of ordered) { let received = 0, spent = 0; try { const t = await this._client.call("blockchain.transaction.get", [h.txid, true]); for (const o of t.vout || []) { const hex = o.scriptPubKey && o.scriptPubKey.hex; if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8); } for (const i of t.vin || []) { if (!i.txid) continue; try { const p = await this._client.call("blockchain.transaction.get", [i.txid, true]); const po = p.vout && p.vout[i.vout]; const hex = po && po.scriptPubKey && po.scriptPubKey.hex; if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8); } catch {} } out.push({ txid: h.txid, height: h.height, confirmations: t.confirmations || 0, time: t.blocktime || t.time || 0, delta: received - spent, fee: null, to: null, status: (t.confirmations || 0) > 0 ? "confirmed" : "pending", kind: "transfer", }); } catch { out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" }); } } this._state.history = out; } async refresh(full = false) { if (this._state.scanning) return; this._state.scanning = true; this._state.error = null; this._emit(); try { if (full || !this._state.watched.size) await this._scan(); else { let r = Number(this.storage.get("receiveCursor", 0)) || 0; while (this._state.used.has("0/" + r)) r++; this._state.receiveIndex = r; this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); } await this._loadUtxos(); await this._loadHistory(); await this._subscribeAll(); for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index); let r = Number(this.storage.get("receiveCursor", 0)) || 0; while (this._state.used.has("0/" + r)) r++; this._state.receiveIndex = r; } catch (e) { this._state.error = e?.message || String(e); this.log("refresh failed:", this._state.error); } finally { this._state.scanning = false; this._emit(); } } _scheduleRefresh(ms = 800) { clearTimeout(this._refreshTimer); this._refreshTimer = setTimeout(() => this.refresh(false), ms); } current() { return this._keys.entry(0, this._state.receiveIndex); } nextAddress() { let r = this._state.receiveIndex + 1; while (this._state.used.has("0/" + r)) r++; this.storage.set("receiveCursor", r); this._state.receiveIndex = r; const e = this._keys.entry(0, r); this._state.watched.set(e.scripthash, e); this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}); this._emit(); return this.current(); } _changeEntry() { let i = 0; while (this._state.used.has("1/" + i)) i++; return this._keys.entry(1, i); } setServers(list) { this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); this._client.setServers(this._servers); } plan({ to, amount, feeRate = 5, sendMax = false }) { const rate = Math.min(500, Math.max(1, Number(feeRate) || 5)); const dest = String(to || ""); try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); } catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); } const cur = this.current(); if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`); const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); const change = this._changeEntry(); const kind = cur.sendKind; if (sendMax) { const chosen = spendable; const sum = chosen.reduce((a, u) => a + u.value, 0); const fee = feeVb(kind, chosen.length, 1, rate); if (sum <= fee) throw new Error("balance does not cover the fee"); return { _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind, recipients: [{ to: dest, value: sum - fee }], fee, feeRate: rate, change: 0, total: sum, }; } const value = Math.round(Number(amount) || 0); if (!(value > 0)) throw new Error("amount must be > 0"); let sum = 0; const chosen = []; for (const u of spendable) { chosen.push(u); sum += u.value; const withChange = feeVb(kind, chosen.length, 2, rate); if (sum >= value + withChange) { const changeVal = sum - value - withChange; const fee = changeVal > 546 ? withChange : sum - value; return { _chosen: chosen, _rate: rate, _to: dest, _value: value, _change: change, _changeVal: changeVal > 546 ? changeVal : 0, _kind: kind, recipients: [{ to: dest, value }], fee, feeRate: rate, change: changeVal > 546 ? changeVal : 0, total: value + fee, }; } } throw new Error("insufficient funds"); } async signAndBroadcast(plan) { // BIP44 inputs need the whole previous transaction (nonWitnessUtxo) // so PSBT can compute a legacy sighash; fetch each one in parallel // before assembling the PSBT. const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44"); const prevHex = new Map(); if (needsPrev.length) { const results = await Promise.all(needsPrev.map((u) => this._client.call("blockchain.transaction.get", [u.txid, false]) )); needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i]))); } const psbt = new Psbt({ network: this._bjsNet }); for (const u of plan._chosen) { const inp = { hash: u.txid, index: u.vout }; const fam = u.entry.family; if (fam === "bip44") { inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex"); } else { inp.witnessUtxo = { script: u.entry.script, value: u.value }; if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript; if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey; } psbt.addInput(inp); } const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; if (!plan._sendMax && plan._changeVal > 0) { outputs.push({ address: plan._change.address, value: plan._changeVal }); } for (const o of outputs) psbt.addOutput(o); for (let i = 0; i < plan._chosen.length; i++) { const entry = plan._chosen[i].entry; // Taproot key-path: bitcoinjs-lib matches the signer's publicKey // against the tweaked output key, so the signer has to be the // internal ECPair tweaked with sha256("TapTweak" || internalPubkey). // ECPair.tweak() from the ecpair package does exactly that (its // internal state becomes the tap-tweaked keypair) and its // signSchnorr is what PSBT calls for a key-path spend. if (entry.family === "bip86") { const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet }); const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey); const tweaked = raw.tweak(tweak); psbt.signInput(i, tweaked); } else { psbt.signInput(i, this._keys.signerFor(entry)); } } psbt.finalizeAllInputs(); const tx = psbt.extractTransaction(); const hex = tx.toHex(); const txid = await this._client.call("blockchain.transaction.broadcast", [hex]); if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); this.log("broadcast", txid); this._scheduleRefresh(1200); return { txid, hex, fee: plan.fee }; } // BIP-137 recoverable over sha256d("Bitcoin Signed Message:\n" || msg). signMessage(message) { 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 entry = this.current(); const signer = this._keys.signerFor(entry); const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey); const out = Buffer.alloc(65); out[0] = 27 + sig.recoveryId + 4; // +4 = compressed Buffer.from(sig.signature).copy(out, 1); return { address: entry.address, signature: out.toString("base64") }; } recovery() { return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; } snapshot() { const cur = this.current(); return { chain: "btc", network: this._net.id, ticker: "BTC", decimals: 8, address: cur.address, addressIndex: this._state.receiveIndex, addressPath: cur.path, 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, accountPath: this._keys.accountPath, xpub: this._keys.xpub, explorerTx: this._net.explorerTx, explorerAddr: this._net.explorerAddr, faucet: this._net.faucet, }; } dispose() { clearTimeout(this._refreshTimer); try { this._keys.wipe(); } catch {} try { this._client.disconnect(); } catch {} if (this._root) this._root.fill(0); } } return { BtcWallet, NETWORKS }; };