diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index aa3fe64..13257c8 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -173,14 +173,16 @@ const COINS = { supportsPageInject: false, // BIP44/49/84/86 address families — the picker lives in the DGB // settings block. Default is BIP84 (dgb1q…), which matches modern - // DGB Core, DigiByte-Go, and the SilentCode web-wallet. + // DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType` + // per chain feeds the path builder below. addressFamilies: [ - { id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)", defaultAccountPath: "m/84'/20'/0'" }, - { id: "bip86", purpose: 86, label: "Taproot (dgb1p…)", defaultAccountPath: "m/86'/20'/0'" }, - { id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)", defaultAccountPath: "m/49'/20'/0'" }, - { id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)", defaultAccountPath: "m/44'/20'/0'" }, + { id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)" }, + { id: "bip86", purpose: 86, label: "Taproot (dgb1p…)" }, + { id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)" }, + { id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)" }, ], - defaultAccountPath: "m/84'/20'/0'", + coinType: 20, + defaultPurpose: 84, networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, @@ -240,10 +242,17 @@ const COINS = { logo: "btc", supportsMessageSign: true, supportsPageInject: false, - // BIP84 by default. Family selector can be exposed later, same shape - // as DGB's — the adapter already reads accountPath, so a fresh entry - // with a different purpose (m/44'/0'/0' etc.) just works. - defaultAccountPath: "m/84'/0'/0'", + // BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and + // tb1q… / tb1p… / 2… / m/n… on testnet3. Coin type shifts per + // network (0 for mainnet, 1 for testnet — the BIP44 convention). + addressFamilies: [ + { id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" }, + { id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" }, + { id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" }, + { id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" }, + ], + coinType: { mainnet: 0, testnet: 1 }, + defaultPurpose: 84, networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, @@ -257,6 +266,30 @@ const COINS = { }, }; function chainKey(chain, network) { return `${chain}:${network}`; } +// Coin type per (chain, network). A number literal on the COINS entry +// (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1} +// for BTC). Returns null when the chain doesn't declare a family picker. +function coinTypeFor(c, network) { + if (!c || c.coinType == null) return null; + return typeof c.coinType === "number" ? c.coinType : (c.coinType[network] ?? null); +} +// Full derivation paths per family for a given (chain, network) — expands +// the family list on the fly so each picker knows exactly which path a +// pick would produce. +function addressFamiliesFor(c, network) { + if (!c || !c.addressFamilies) return null; + const ct = coinTypeFor(c, network); + if (ct == null) return c.addressFamilies; + return c.addressFamilies.map((f) => ({ + ...f, + defaultAccountPath: `m/${f.purpose}'/${ct}'/0'`, + })); +} +function defaultAccountPathFor(c, network) { + const ct = coinTypeFor(c, network); + if (ct == null || c.defaultPurpose == null) return null; + return `m/${c.defaultPurpose}'/${ct}'/0'`; +} function chainMeta(chain, network) { const c = COINS[chain]; const n = c && c.networks[network]; if (!c || !n) return null; @@ -266,8 +299,8 @@ function chainMeta(chain, network) { color: c.color, logo: c.logo, coinLabel: c.label, networkLabel: n.label, testnet: !!n.testnet, purposePrefix: n.purposePrefix, startIndex: n.startIndex, supportsMessageSign: !!c.supportsMessageSign, supportsPageInject: !!c.supportsPageInject, - addressFamilies: c.addressFamilies || null, - defaultAccountPath: c.defaultAccountPath || null, + addressFamilies: addressFamiliesFor(c, network), + defaultAccountPath: defaultAccountPathFor(c, network), }; } function coinsForPanel() { @@ -684,7 +717,11 @@ function registerPanelMessages(api) { if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/84'/0'/0'"); const list = walletEntries(); const idx = list.findIndex((w) => w.id === id); - const dflt = entry.chain === "bch" ? "m/44'/145'/0'" : (entry.chain === "btc" ? "m/84'/0'/0'" : "m/84'/20'/0'"); + // Per-network default: BTC/DGB come from the registry helper, BCH keeps + // its historical m/44'/145'/0'. + const dflt = entry.chain === "bch" + ? "m/44'/145'/0'" + : (defaultAccountPathFor(COINS[entry.chain], entry.network) || "m/84'/0'/0'"); list[idx] = { ...list[idx], accountPath: v || dflt }; writeWallets(api, list); const rt = ctx.runtimes.get(id); diff --git a/bundled-addons/bchwallet/lib/chain-btc.js b/bundled-addons/bchwallet/lib/chain-btc.js index 60b6ded..db0532f 100644 --- a/bundled-addons/bchwallet/lib/chain-btc.js +++ b/bundled-addons/bchwallet/lib/chain-btc.js @@ -47,6 +47,10 @@ module.exports = function makeBtcAdapter({ 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. function bjsNetworkFor(id) { @@ -66,9 +70,45 @@ module.exports = function makeBtcAdapter({ }; } + // 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" }; + } + 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 needs the tap-tweaked signer; + // ECPair from the current bitcoinjs stack doesn't tweak natively, so + // send is deferred — receive still works. + const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); // x-only + const p = payments.p2tr({ internalPubkey, network }); + return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: null }; + } + // 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); @@ -78,19 +118,22 @@ module.exports = function makeBtcAdapter({ 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 pubkey = Buffer.from(node.publicKey); - const p2wpkh = payments.p2wpkh({ pubkey, network: this._network }); - const script = Buffer.from(p2wpkh.output); + const pay = paymentFor(this._purpose, node, this._network); e = { branch, index, path: this._accountPath + "/" + branch + "/" + index, - publicKey: pubkey, script, scriptHex: script.toString("hex"), - scripthash: scripthashOf(script), - address: p2wpkh.address, + 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); @@ -109,11 +152,18 @@ module.exports = function makeBtcAdapter({ } } - // Fee vsize model — same P2WPKH numbers as DGB (identical script shapes). + // 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 P2WPKH_INPUT_VB = 68; - const P2WPKH_OUTPUT_VB = 31; - const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb); + 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, { @@ -312,15 +362,24 @@ module.exports = function makeBtcAdapter({ const dest = String(to || ""); try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); } catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); } + // Family-aware refusal: this rev signs BIP84 + BIP49 natively. BIP44 + // needs previous-tx fetches (nonWitnessUtxo) and BIP86 needs Taproot + // key-path tweaking — both are on the roadmap; error early so the + // user isn't surprised at broadcast time. + const cur = this.current(); + if (!cur.sendKind) { + throw new Error(`Sending from ${cur.family.toUpperCase()} addresses isn't wired up yet in this Aegis rev. Receiving works; to spend, switch this wallet's address family to BIP84 (Native SegWit) in Settings and sweep funds there.`); + } 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(chosen.length, 1, rate); + 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, + _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, @@ -331,13 +390,13 @@ module.exports = function makeBtcAdapter({ let sum = 0; const chosen = []; for (const u of spendable) { chosen.push(u); sum += u.value; - const withChange = feeVb(chosen.length, 2, rate); + 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, + _change: change, _changeVal: changeVal > 546 ? changeVal : 0, _kind: kind, recipients: [{ to: dest, value }], fee, feeRate: rate, change: changeVal > 546 ? changeVal : 0, @@ -351,10 +410,14 @@ module.exports = function makeBtcAdapter({ async signAndBroadcast(plan) { const psbt = new Psbt({ network: this._bjsNet }); for (const u of plan._chosen) { - psbt.addInput({ + const inp = { hash: u.txid, index: u.vout, witnessUtxo: { script: u.entry.script, value: u.value }, - }); + }; + // BIP49: the redeem script (inner P2WPKH output) is required so + // bitcoinjs-lib can finalize the P2SH-wrapped SegWit witness. + if (u.entry.family === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript; + psbt.addInput(inp); } const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; if (!plan._sendMax && plan._changeVal > 0) { diff --git a/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index 9b801c9..2d2c023 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -274,6 +274,31 @@ + +