From e65fabbc21d7ec6d0d3ef33bd59b8d681a4f39c4 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 19 Nov 2025 16:39:39 +0100 Subject: [PATCH 1/2] feat(abstract-utxo): support RootWalletKeys in fixedScript address generation Support passing RootWalletKeys directly to generateAddressWithChainAndIndex for more efficient key derivation without recreating HD nodes for each address. Also allows passing custom derivation prefixes which can be set inside RootWalletKeys (defaults to /0/0/). Issue: BTC-2668 Co-authored-by: llm-git --- .../abstract-utxo/src/address/fixedScript.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/modules/abstract-utxo/src/address/fixedScript.ts b/modules/abstract-utxo/src/address/fixedScript.ts index 0f927ebc23..3cb9dcfb00 100644 --- a/modules/abstract-utxo/src/address/fixedScript.ts +++ b/modules/abstract-utxo/src/address/fixedScript.ts @@ -9,7 +9,8 @@ import { P2trUnsupportedError, P2wshUnsupportedError, UnsupportedAddressTypeError, - sanitizeLegacyPath, + isTriple, + Triple, } from '@bitgo/sdk-core'; import * as utxolib from '@bitgo/utxo-lib'; import { bitgo } from '@bitgo/utxo-lib'; @@ -52,7 +53,7 @@ function supportsAddressType(network: utxolib.Network, addressType: ScriptType2O export function generateAddressWithChainAndIndex( network: utxolib.Network, - keychains: { pub: string }[], + keychains: bitgo.RootWalletKeys | Triple, chain: bitgo.ChainCode, index: number, format: CreateAddressFormat | undefined @@ -61,24 +62,19 @@ export function generateAddressWithChainAndIndex( // Convert CreateAddressFormat to AddressFormat for wasm-utxo // 'base58' -> 'default', 'cashaddr' -> 'cashaddr' const wasmFormat = format === 'base58' ? 'default' : format; - return wasmUtxo.fixedScriptWallet.address( - keychains.map((k) => k.pub) as [string, string, string], - chain, - index, - network, - wasmFormat - ); + return wasmUtxo.fixedScriptWallet.address(keychains, chain, index, network, wasmFormat); + } + + if (!(keychains instanceof bitgo.RootWalletKeys)) { + const hdNodes = keychains.map((pub) => bip32.fromBase58(pub)); + keychains = new bitgo.RootWalletKeys(hdNodes as Triple); } - const path = '0/0/' + chain + '/' + index; - const hdNodes = keychains.map(({ pub }) => bip32.fromBase58(pub)); - const derivedKeys = hdNodes.map((hdNode) => hdNode.derivePath(sanitizeLegacyPath(path)).publicKey); const addressType = bitgo.scriptTypeForChain(chain); + const derivedKeys = keychains.deriveForChainAndIndex(chain, index).publicKeys; const { scriptPubKey: outputScript } = utxolib.bitgo.outputScripts.createOutputScript2of3(derivedKeys, addressType); - const address = utxolib.address.fromOutputScript(outputScript, network); - return canonicalAddress(network, address, format); } @@ -143,7 +139,17 @@ export function generateAddress(network: utxolib.Network, params: GenerateFixedS } } - return generateAddressWithChainAndIndex(network, keychains, derivationChain, derivationIndex, params.format); + if (!isTriple(keychains)) { + throw new Error('keychains must be a triple'); + } + + return generateAddressWithChainAndIndex( + network, + keychains.map((k) => k.pub) as Triple, + derivationChain, + derivationIndex, + params.format + ); } type Keychain = { From e4a6b3819853ca2fe188652cf71a732cb41cb521 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 19 Nov 2025 16:53:39 +0100 Subject: [PATCH 2/2] feat(abstract-utxo): optimize backup key recovery with address generation Creates a more efficient address generation function that directly creates addresses from wallet keys rather than using intermediate MultiSigAddress objects. This simplifies the code flow and removes the now-unused `createMultiSigAddress` method. Issue: BG-61926 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 21 ---------- .../src/recovery/backupKeyRecovery.ts | 41 ++++++++++++------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 57f5de5e52..5739b47c17 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -814,27 +814,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin { return explainTx(this.decodeTransactionFromPrebuild(params), params, this.network); } - /** - * Create a multisig address of a given type from a list of keychains and a signing threshold - * @param addressType - * @param signatureThreshold - * @param keys - */ - createMultiSigAddress(addressType: ScriptType2Of3, signatureThreshold: number, keys: Buffer[]): MultiSigAddress { - const { - scriptPubKey: outputScript, - redeemScript, - witnessScript, - } = utxolib.bitgo.outputScripts.createOutputScript2of3(keys, addressType); - - return { - outputScript, - redeemScript, - witnessScript, - address: utxolib.address.fromOutputScript(outputScript, this.network), - }; - } - /** * @deprecated - use {@see backupKeyRecovery} * Builds a funds recovery transaction without BitGo diff --git a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts index 0676158a7f..daaa2d0add 100644 --- a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts @@ -15,8 +15,9 @@ import { } from '@bitgo/sdk-core'; import { getMainnet, networks } from '@bitgo/utxo-lib'; -import { AbstractUtxoCoin, MultiSigAddress } from '../abstractUtxoCoin'; +import { AbstractUtxoCoin } from '../abstractUtxoCoin'; import { signAndVerifyPsbt } from '../sign'; +import { generateAddressWithChainAndIndex } from '../address'; import { forCoin, RecoveryProvider } from './RecoveryProvider'; import { MempoolApi } from './mempoolApi'; @@ -125,12 +126,27 @@ export interface RecoverParams { feeRate?: number; } -function getFormattedAddress(coin: AbstractUtxoCoin, address: MultiSigAddress) { - // Blockchair uses cashaddr format when querying the API for address information. Convert legacy addresses to cashaddr - // before querying the API. - return coin.getChain() === 'bch' || coin.getChain() === 'bcha' - ? coin.canonicalAddress(address.address, 'cashaddr').split(':')[1] - : address.address; +/** + * Generate an address and format it for API queries + * @param coin - The coin instance + * @param network - The network to use + * @param walletKeys - The wallet keys + * @param chain - The chain code + * @param addrIndex - The address index + * @returns The formatted address (with cashaddr prefix stripped for BCH/BCHA) + */ +function getFormattedAddress( + coin: AbstractUtxoCoin, + network: utxolib.Network, + walletKeys: RootWalletKeys, + chain: ChainCode, + addrIndex: number +): string { + const format = coin.getChain() === 'bch' || coin.getChain() === 'bcha' ? 'cashaddr' : undefined; + const address = generateAddressWithChainAndIndex(network, walletKeys, chain, addrIndex, format); + + // Blockchair uses cashaddr format when querying the API for address information. Strip the prefix for BCH/BCHA. + return format === 'cashaddr' ? address.split(':')[1] : address; } async function queryBlockchainUnspentsPath( @@ -157,10 +173,7 @@ async function queryBlockchainUnspentsPath( } async function gatherUnspents(addrIndex: number) { - const walletKeysForUnspent = walletKeys.deriveForChainAndIndex(chain, addrIndex); - const address = coin.createMultiSigAddress(scriptType, 2, walletKeysForUnspent.publicKeys); - - const formattedAddress = getFormattedAddress(coin, address); + const formattedAddress = getFormattedAddress(coin, coin.network, walletKeys, chain, addrIndex); const addrInfo = await recoveryProvider.getAddressInfo(formattedAddress); // we use txCount here because it implies usage - having tx'es means the addr was generated and used if (addrInfo.txCount === 0) { @@ -169,7 +182,7 @@ async function queryBlockchainUnspentsPath( numSequentialAddressesWithoutTxs = 0; if (addrInfo.balance > 0) { - console.log(`Found an address with balance: ${address.address} with balance ${addrInfo.balance}`); + console.log(`Found an address with balance: ${formattedAddress} with balance ${addrInfo.balance}`); const addressUnspents = await recoveryProvider.getUnspentsForAddresses([formattedAddress]); const processedUnspents = await Promise.all( addressUnspents.map(async (u): Promise> => { @@ -375,9 +388,9 @@ export async function backupKeyRecovery( const recoveryAmount = totalInputAmount - approximateFee - krsFee; if (recoveryAmount < BigInt(0)) { - throw new Error(`this wallet\'s balance is too low to pay the fees specified by the KRS provider. + throw new Error(`this wallet\'s balance is too low to pay the fees specified by the KRS provider. Existing balance on wallet: ${totalInputAmount.toString()}. Estimated network fee for the recovery transaction - : ${approximateFee.toString()}, KRS fee to pay: ${krsFee.toString()}. After deducting fees, your total + : ${approximateFee.toString()}, KRS fee to pay: ${krsFee.toString()}. After deducting fees, your total recoverable balance is ${recoveryAmount.toString()}`); }