diff --git a/bundled-addons/aegis/lib/chain-dgb.js b/bundled-addons/aegis/lib/chain-dgb.js index 85d32f4d..aa6609bf 100644 --- a/bundled-addons/aegis/lib/chain-dgb.js +++ b/bundled-addons/aegis/lib/chain-dgb.js @@ -9,9 +9,9 @@ // Optional Blockbook mode is on the roadmap; ElectrumX is the default // because it matches Aegis's transport shape and needs no per-server keys. // -// Only BIP84 (m/84'/20'/0'/0/x → dgb1q…) is exposed in this rev; the -// vendored core also supports BIP44 (D…) and BIP49 (S…) — plumb them by -// switching the purpose passed to accountNode(). Address recovery from any +// All four address families follow the account path's purpose: BIP84 +// (m/84'/20'/0'/0/x → dgb1q…, the default), BIP44 (D…), BIP49 (S…) and +// BIP86 (dgb1p…, Taproot is active on DigiByte). Address recovery from any // BIP39 tool at coin type 20 is guaranteed by bitcoinjs-lib's Network // object, so a seed exported here can be restored on iancoleman.io/bip39 // or the SilentCode Digibyte web-wallet with matching addresses. @@ -52,8 +52,35 @@ module.exports = function makeDgbAdapter({ const rev = Buffer.from(h).reverse(); return rev.toString("hex"); } - function scriptPubKeyBuf(pubkeyBuf) { - return payments.p2wpkh({ pubkey: pubkeyBuf, network: digibyte }).output; + // Taproot outputs need bitcoinjs-lib's schnorr backend; chain-btc.js does + // the same at load, but this adapter must not depend on load order. + try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {} + + // Address family from the derivation-path purpose, with everything the + // PSBT layer needs to spend an input of that family. entry() used to make + // a bech32 p2wpkh address whatever the purpose, so picking "Legacy (D…)" + // or "Taproot (dgb1p…)" in Settings showed a dgb1q address from the wrong + // key tree — one no other wallet restoring that path would ever find. + // Same shapes as chain-btc.js; BIP49 uses DGB's current "S…" prefix. + const PURPOSES = new Set([44, 49, 84, 86]); + function paymentFor(purpose, node) { + const pubkey = Buffer.from(node.publicKey); + if (purpose === 44) { + const p = payments.p2pkh({ pubkey, network: digibyte }); + return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh" }; + } + if (purpose === 49) { + const redeem = payments.p2wpkh({ pubkey, network: digibyte }); + const p = payments.p2sh({ redeem, network: digibyte }); + return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" }; + } + if (purpose === 86) { + const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); + const p = payments.p2tr({ internalPubkey, network: digibyte }); + return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" }; + } + const p = payments.p2wpkh({ pubkey, network: digibyte }); + return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" }; } // ---- keys -------------------------------------------------------------- @@ -62,7 +89,10 @@ module.exports = function makeDgbAdapter({ // input under its own key. class WalletKeys { constructor(root32, accountPath) { - const purpose = parsePurposeFromPath(accountPath) || DEFAULT_PURPOSE; + const purpose = parsePurposeFromPath(accountPath) ?? DEFAULT_PURPOSE; + if (!PURPOSES.has(purpose)) { + throw new Error(`DGB: unsupported derivation purpose ${purpose} — use 44, 49, 84 or 86 (m/84'/${DGB_COIN_TYPE}'/0' is the default)`); + } this._purpose = purpose; // bip32.fromSeed uses bitcoinjs-lib's Network object — pass DGB's so // extended keys serialize with the right BIP32 magic (0x0488B21E). @@ -81,13 +111,16 @@ module.exports = function makeDgbAdapter({ if (!e) { const node = this._branch[branch].derive(index); const pubkey = Buffer.from(node.publicKey); - const address = p2wpkhAddress(node, digibyte); - const script = Buffer.from(scriptPubKeyBuf(pubkey)); + const pay = paymentFor(this._purpose, node); + const script = pay.output; e = { branch, index, path: this._accountPath + "/" + branch + "/" + index, publicKey: pubkey, script, scriptHex: script.toString("hex"), scripthash: scripthashOf(script), - address, + address: pay.address, + family: pay.family, send: pay.send, + redeemScript: pay.redeem || null, + tapInternalKey: pay.internalPubkey || null, _node: node, }; this._cache.set(k, e); @@ -115,10 +148,11 @@ module.exports = function makeDgbAdapter({ } // ---- vsize model (fee estimation ahead of PSBT.getFee) ----------------- + // Input sizes per family, as in chain-btc.js. 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; + const INPUT_VB = { p2pkh: 148, "p2sh-p2wpkh": 91, p2wpkh: 68, p2tr: 58 }; + const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); function scopedStorage(storage, keyPrefix) { const k = (key) => keyPrefix + key; @@ -338,7 +372,7 @@ module.exports = function makeDgbAdapter({ 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(change.send, 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, @@ -352,7 +386,7 @@ module.exports = function makeDgbAdapter({ 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(change.send, chosen.length, 2, rate); if (sum >= value + withChange) { const changeVal = sum - value - withChange; const fee = changeVal > 546 ? withChange : sum - value; @@ -370,11 +404,30 @@ module.exports = function makeDgbAdapter({ } async signAndBroadcast(plan) { - const psbtInputs = plan._chosen.map((u) => ({ - txid: u.txid, - vout: u.vout, - witness: { scriptHex: u.entry.scriptHex, value: u.value }, - })); + // buildPsbt orders inputs by BIP69; put ours in that same order first + // so input i is signed with the key of the UTXO that actually sits at + // i. Signing in selection order failed on any multi-address spend. + const chosen = plan._chosen.slice().sort((a, b) => { + const cmp = a.txid.localeCompare(b.txid); + return cmp !== 0 ? cmp : a.vout - b.vout; + }); + // P2PKH (BIP44) inputs need the whole previous transaction for the + // legacy sighash; fetch each one before assembling the PSBT. + const prevHex = new Map(); + const needsPrev = chosen.filter((u) => u.entry.family === "bip44"); + 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 psbtInputs = chosen.map((u) => { + const fam = u.entry.family; + const inp = { txid: u.txid, vout: u.vout }; + if (fam === "bip44") inp.nonWitnessTxHex = prevHex.get(u.txid); + else inp.witness = { scriptHex: u.entry.scriptHex, value: u.value }; + if (fam === "bip49") inp.redeemScriptHex = u.entry.redeemScript.toString("hex"); + if (fam === "bip86") inp.tapInternalKeyHex = u.entry.tapInternalKey.toString("hex"); + return inp; + }); const psbtOutputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; if (!plan._sendMax && plan._changeVal > 0) { psbtOutputs.push({ address: plan._change.address, value: plan._changeVal }); @@ -383,9 +436,16 @@ module.exports = function makeDgbAdapter({ // Sign per-input with the exact key that funded that UTXO. signAllInputs // would work when all inputs share a key, but each derived address // has its own key, so we go per-input. - for (let i = 0; i < plan._chosen.length; i++) { - const signer = this._keys.signerFor(plan._chosen[i].entry); - psbt.signInput(i, signer); + for (let i = 0; i < chosen.length; i++) { + const entry = chosen[i].entry; + if (entry.family === "bip86") { + // Taproot key-path: the signer must be the tap-tweaked key, which + // ECPair.tweak() produces (as chain-btc.js does). + const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: digibyte }); + psbt.signInput(i, raw.tweak(bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey))); + } else { + psbt.signInput(i, this._keys.signerFor(entry)); + } } const { hex, txid } = finalizeAndExtract(psbt); const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]);