From 51c25600c1bb0597f85021dcf7a866790f376bf5 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 14:36:31 +0100 Subject: [PATCH 1/8] feat(abstract-utxo): add tx format unit tests Add unit tests for transaction format selection logic in abstract-utxo, testing various wallet configurations and their default formats. Verify PSBT defaults for testnet coins, Bitcoin mainnet, and special wallet types like distributedCustody. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/test/unit/txFormat.ts | 150 ++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 modules/abstract-utxo/test/unit/txFormat.ts diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts new file mode 100644 index 0000000000..7c056c3229 --- /dev/null +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -0,0 +1,150 @@ +import * as assert from 'assert'; + +import * as utxolib from '@bitgo/utxo-lib'; +import { ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; + +import { AbstractUtxoCoin } from '../../src'; + +import { utxoCoins, defaultBitGo } from './util'; + +type WalletOptions = { + type?: 'hot' | 'cold' | 'custodial' | 'custodialPaired' | 'trading'; + subType?: string; + multisigType?: string; + walletFlags?: Array<{ name: string; value: string }>; +}; + +/** + * Helper function to create a mock wallet for testing + */ +export function createMockWallet(coin: AbstractUtxoCoin, options: WalletOptions = {}): Wallet { + const walletData = { + id: '5b34252f1bf349930e34020a', + coin: coin.getChain(), + type: options.type || 'hot', + ...(options.subType && { subType: options.subType }), + ...(options.multisigType && { multisigType: options.multisigType }), + ...(options.walletFlags && { walletFlags: options.walletFlags }), + }; + return new Wallet(defaultBitGo, coin, walletData); +} + +/** + * Helper function to get the txFormat from a coin's getExtraPrebuildParams method + */ +export async function getTxFormat( + coin: AbstractUtxoCoin, + buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet } +): Promise<'legacy' | 'psbt' | undefined> { + const result = await coin.getExtraPrebuildParams(buildParams); + return result.txFormat; +} + +/** + * Helper function to run a txFormat test with named arguments + */ +function runTest(params: { + description: string; + walletOptions: WalletOptions; + expectedTxFormat: 'legacy' | 'psbt' | undefined | ((coin: AbstractUtxoCoin) => 'legacy' | 'psbt' | undefined); + coinFilter?: (coin: AbstractUtxoCoin) => boolean; + requestedTxFormat?: 'legacy' | 'psbt'; + extraParams?: Partial; +}): void { + it(params.description, async function () { + for (const coin of utxoCoins) { + // Skip coins that don't match the filter + if (params.coinFilter && !params.coinFilter(coin)) { + continue; + } + + const wallet = createMockWallet(coin, params.walletOptions); + const buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet } = { + wallet, + ...(params.requestedTxFormat && { txFormat: params.requestedTxFormat }), + ...params.extraParams, + }; + const txFormat = await getTxFormat(coin, buildParams); + + const expectedTxFormat = + typeof params.expectedTxFormat === 'function' ? params.expectedTxFormat(coin) : params.expectedTxFormat; + + assert.strictEqual( + txFormat, + expectedTxFormat, + `${params.description} - ${coin.getChain()}: expected ${expectedTxFormat}, got ${txFormat}` + ); + } + }); +} + +describe('txFormat', function () { + describe('getExtraPrebuildParams', function () { + // Testnet hot wallets default to PSBT (except ZCash) + runTest({ + description: 'should default to psbt for testnet hot wallets (except zcash)', + walletOptions: { type: 'hot' }, + expectedTxFormat: (coin) => { + const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; + // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) + return isZcash ? undefined : 'psbt'; + }, + coinFilter: (coin) => utxolib.isTestnet(coin.network), + }); + + // Mainnet Bitcoin hot wallets default to PSBT + runTest({ + description: 'should default to psbt for mainnet bitcoin hot wallets', + walletOptions: { type: 'hot' }, + expectedTxFormat: 'psbt', + coinFilter: (coin) => + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) === utxolib.networks.bitcoin, + }); + + // Mainnet non-Bitcoin hot wallets do NOT default to PSBT + runTest({ + description: 'should not default to psbt for mainnet non-bitcoin hot wallets', + walletOptions: { type: 'hot' }, + expectedTxFormat: undefined, + coinFilter: (coin) => + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.bitcoin, + }); + + // Cold wallets do NOT default to PSBT + runTest({ + description: 'should not default to psbt for cold wallets', + walletOptions: { type: 'cold' }, + expectedTxFormat: undefined, + }); + + // DistributedCustody wallets default to PSBT + runTest({ + description: 'should default to psbt for distributedCustody wallets', + walletOptions: { type: 'cold', multisigType: 'tss', subType: 'distributedCustody' }, + expectedTxFormat: 'psbt', + }); + + // Wallets with musigKp flag default to PSBT + runTest({ + description: 'should default to psbt for wallets with musigKp flag', + walletOptions: { type: 'cold', walletFlags: [{ name: 'musigKp', value: 'true' }] }, + expectedTxFormat: 'psbt', + }); + + // Explicitly specified legacy format is respected + runTest({ + description: 'should respect explicitly specified legacy txFormat', + walletOptions: { type: 'hot' }, + expectedTxFormat: 'legacy', + requestedTxFormat: 'legacy', + }); + + // Explicitly specified psbt format is respected + runTest({ + description: 'should respect explicitly specified psbt txFormat', + walletOptions: { type: 'cold' }, + expectedTxFormat: 'psbt', + requestedTxFormat: 'psbt', + }); + }); +}); From d5abc10eaa7e18fc60735d1b864b804e1f9f599c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 14:39:02 +0100 Subject: [PATCH 2/8] feat(abstract-utxo): refactor tx format selection into dedicated function Extract format selection logic to a new method getDefaultTxFormat to make the code more modular and easier to test. This simplifies getExtraPrebuildParams and improves test coverage by allowing direct testing of format selection. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 57 +++++++++++-------- modules/abstract-utxo/test/unit/txFormat.ts | 40 ++++++------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 200cee24ac..350eaed898 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -84,6 +84,8 @@ import { isDescriptorWalletData } from './descriptor/descriptorWallet'; import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; +export type TxFormat = 'legacy' | 'psbt'; + type UtxoCustomSigningFunction = { (params: { coin: IBaseCoin; @@ -957,37 +959,44 @@ export abstract class AbstractUtxoCoin extends BaseCoin { }; } - private shouldDefaultToPsbtTxFormat(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { - const walletFlagMusigKp = buildParams.wallet.flag('musigKp') === 'true'; - const isHotWallet = buildParams.wallet.type() === 'hot'; - - // if not txFormat is already specified figure out if we should default to psbt format - return ( - buildParams.txFormat === undefined && - (buildParams.wallet.subType() === 'distributedCustody' || - // default to testnet for all utxo coins except zcash - (isTestnet(this.network) && - // FIXME(BTC-1322): fix zcash PSBT support - getMainnet(this.network) !== utxolib.networks.zcash && - isHotWallet) || - // if mainnet, only default to psbt for btc hot wallets - (isMainnet(this.network) && getMainnet(this.network) === utxolib.networks.bitcoin && isHotWallet) || - // default to psbt if it has the wallet flag - walletFlagMusigKp) - ); + /** + * Determines the default transaction format based on wallet properties and network + * @param wallet - The wallet to check + * @param requestedFormat - Optional explicitly requested format + * @returns The transaction format to use, or undefined if no default applies + */ + getDefaultTxFormat(wallet: Wallet, requestedFormat?: TxFormat): TxFormat | undefined { + // If format is explicitly requested, use it + if (requestedFormat !== undefined) { + return requestedFormat; + } + + const walletFlagMusigKp = wallet.flag('musigKp') === 'true'; + const isHotWallet = wallet.type() === 'hot'; + + // Determine if we should default to psbt format + const shouldDefaultToPsbt = + wallet.subType() === 'distributedCustody' || + // default to testnet for all utxo coins except zcash + (isTestnet(this.network) && + // FIXME(BTC-1322): fix zcash PSBT support + getMainnet(this.network) !== utxolib.networks.zcash && + isHotWallet) || + // if mainnet, only default to psbt for btc hot wallets + (isMainnet(this.network) && getMainnet(this.network) === utxolib.networks.bitcoin && isHotWallet) || + // default to psbt if it has the wallet flag + walletFlagMusigKp; + + return shouldDefaultToPsbt ? 'psbt' : undefined; } async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }): Promise<{ - txFormat?: 'legacy' | 'psbt'; + txFormat?: TxFormat; changeAddressType?: ScriptType2Of3[] | ScriptType2Of3; }> { - let txFormat = buildParams.txFormat as 'legacy' | 'psbt' | undefined; + const txFormat = this.getDefaultTxFormat(buildParams.wallet, buildParams.txFormat as TxFormat | undefined); let changeAddressType = buildParams.changeAddressType as ScriptType2Of3[] | ScriptType2Of3 | undefined; - if (this.shouldDefaultToPsbtTxFormat(buildParams)) { - txFormat = 'psbt'; - } - // if the addressType is not specified, we need to default to p2trMusig2 for testnet hot wallets for staged rollout of p2trMusig2 if ( buildParams.addressType === undefined && // addressType is deprecated and replaced by `changeAddress` diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index 7c056c3229..86f2d151c4 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -1,16 +1,15 @@ import * as assert from 'assert'; import * as utxolib from '@bitgo/utxo-lib'; -import { ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; +import { Wallet } from '@bitgo/sdk-core'; -import { AbstractUtxoCoin } from '../../src'; +import { AbstractUtxoCoin, TxFormat } from '../../src'; import { utxoCoins, defaultBitGo } from './util'; type WalletOptions = { type?: 'hot' | 'cold' | 'custodial' | 'custodialPaired' | 'trading'; subType?: string; - multisigType?: string; walletFlags?: Array<{ name: string; value: string }>; }; @@ -23,21 +22,16 @@ export function createMockWallet(coin: AbstractUtxoCoin, options: WalletOptions coin: coin.getChain(), type: options.type || 'hot', ...(options.subType && { subType: options.subType }), - ...(options.multisigType && { multisigType: options.multisigType }), ...(options.walletFlags && { walletFlags: options.walletFlags }), }; return new Wallet(defaultBitGo, coin, walletData); } /** - * Helper function to get the txFormat from a coin's getExtraPrebuildParams method + * Helper function to get the txFormat from a coin's getDefaultTxFormat method */ -export async function getTxFormat( - coin: AbstractUtxoCoin, - buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet } -): Promise<'legacy' | 'psbt' | undefined> { - const result = await coin.getExtraPrebuildParams(buildParams); - return result.txFormat; +export function getTxFormat(coin: AbstractUtxoCoin, wallet: Wallet, requestedFormat?: TxFormat): TxFormat | undefined { + return coin.getDefaultTxFormat(wallet, requestedFormat); } /** @@ -46,12 +40,11 @@ export async function getTxFormat( function runTest(params: { description: string; walletOptions: WalletOptions; - expectedTxFormat: 'legacy' | 'psbt' | undefined | ((coin: AbstractUtxoCoin) => 'legacy' | 'psbt' | undefined); + expectedTxFormat: TxFormat | undefined | ((coin: AbstractUtxoCoin) => TxFormat | undefined); coinFilter?: (coin: AbstractUtxoCoin) => boolean; - requestedTxFormat?: 'legacy' | 'psbt'; - extraParams?: Partial; + requestedTxFormat?: TxFormat; }): void { - it(params.description, async function () { + it(params.description, function () { for (const coin of utxoCoins) { // Skip coins that don't match the filter if (params.coinFilter && !params.coinFilter(coin)) { @@ -59,12 +52,7 @@ function runTest(params: { } const wallet = createMockWallet(coin, params.walletOptions); - const buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet } = { - wallet, - ...(params.requestedTxFormat && { txFormat: params.requestedTxFormat }), - ...params.extraParams, - }; - const txFormat = await getTxFormat(coin, buildParams); + const txFormat = getTxFormat(coin, wallet, params.requestedTxFormat); const expectedTxFormat = typeof params.expectedTxFormat === 'function' ? params.expectedTxFormat(coin) : params.expectedTxFormat; @@ -79,7 +67,7 @@ function runTest(params: { } describe('txFormat', function () { - describe('getExtraPrebuildParams', function () { + describe('getDefaultTxFormat', function () { // Testnet hot wallets default to PSBT (except ZCash) runTest({ description: 'should default to psbt for testnet hot wallets (except zcash)', @@ -120,8 +108,12 @@ describe('txFormat', function () { // DistributedCustody wallets default to PSBT runTest({ description: 'should default to psbt for distributedCustody wallets', - walletOptions: { type: 'cold', multisigType: 'tss', subType: 'distributedCustody' }, - expectedTxFormat: 'psbt', + walletOptions: { type: 'cold', subType: 'distributedCustody' }, + expectedTxFormat: (coin) => { + const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; + // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) + return isZcash ? undefined : 'psbt'; + }, }); // Wallets with musigKp flag default to PSBT From a5d16121e8300587d58ee1b9c18cc318c14e3d45 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 14:46:20 +0100 Subject: [PATCH 3/8] feat(abstract-utxo): exclude Zcash from PSBT default tx format Prevents ZCash transactions from defaulting to PSBT format due to compatibility issues. Refactored the condition logic to handle the ZCash exception early in the decision flow instead of in multiple places. Issue: BTC-2732, BTC-1322 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 9 +++++---- modules/abstract-utxo/test/unit/txFormat.ts | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 350eaed898..830054af14 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -971,6 +971,10 @@ export abstract class AbstractUtxoCoin extends BaseCoin { return requestedFormat; } + if (getMainnet(this.network) === utxolib.networks.zcash) { + return undefined; + } + const walletFlagMusigKp = wallet.flag('musigKp') === 'true'; const isHotWallet = wallet.type() === 'hot'; @@ -978,10 +982,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { const shouldDefaultToPsbt = wallet.subType() === 'distributedCustody' || // default to testnet for all utxo coins except zcash - (isTestnet(this.network) && - // FIXME(BTC-1322): fix zcash PSBT support - getMainnet(this.network) !== utxolib.networks.zcash && - isHotWallet) || + (isTestnet(this.network) && isHotWallet) || // if mainnet, only default to psbt for btc hot wallets (isMainnet(this.network) && getMainnet(this.network) === utxolib.networks.bitcoin && isHotWallet) || // default to psbt if it has the wallet flag diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index 86f2d151c4..89801f1e09 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -120,7 +120,11 @@ describe('txFormat', function () { runTest({ description: 'should default to psbt for wallets with musigKp flag', walletOptions: { type: 'cold', walletFlags: [{ name: 'musigKp', value: 'true' }] }, - expectedTxFormat: 'psbt', + expectedTxFormat: (coin) => { + const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; + // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) + return isZcash ? undefined : 'psbt'; + }, }); // Explicitly specified legacy format is respected From 45745c9569c793bd81423ab38b5e9fba773fe959 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 14:58:05 +0100 Subject: [PATCH 4/8] feat(abstract-utxo): default PSBT for all testnet coins minus zcash Force PSBT format for all testnet coins regardless of wallet type. Previously, only hot wallets defaulted to PSBT in testnet. Now all testnet wallets will use PSBT by default. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 6 ++++-- modules/abstract-utxo/test/unit/txFormat.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 830054af14..1740a7514e 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -975,14 +975,16 @@ export abstract class AbstractUtxoCoin extends BaseCoin { return undefined; } + if (isTestnet(this.network)) { + return 'psbt'; + } + const walletFlagMusigKp = wallet.flag('musigKp') === 'true'; const isHotWallet = wallet.type() === 'hot'; // Determine if we should default to psbt format const shouldDefaultToPsbt = wallet.subType() === 'distributedCustody' || - // default to testnet for all utxo coins except zcash - (isTestnet(this.network) && isHotWallet) || // if mainnet, only default to psbt for btc hot wallets (isMainnet(this.network) && getMainnet(this.network) === utxolib.networks.bitcoin && isHotWallet) || // default to psbt if it has the wallet flag diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index 89801f1e09..ac6738bbf7 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -98,11 +98,16 @@ describe('txFormat', function () { utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.bitcoin, }); - // Cold wallets do NOT default to PSBT + // Cold wallets default to PSBT runTest({ - description: 'should not default to psbt for cold wallets', + description: 'should default to psbt for testnet cold wallets as well', walletOptions: { type: 'cold' }, - expectedTxFormat: undefined, + coinFilter: (coin) => utxolib.isTestnet(coin.network), + expectedTxFormat: (coin) => { + const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; + // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) + return isZcash ? undefined : 'psbt'; + }, }); // DistributedCustody wallets default to PSBT From 076170e7424e2dcdeb839f0b7b7526b8f9b85606 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 15:06:41 +0100 Subject: [PATCH 5/8] feat(abstract-utxo): test over all wallet types Issue: BTC-2732 --- modules/abstract-utxo/test/unit/txFormat.ts | 163 +++++++++++--------- 1 file changed, 94 insertions(+), 69 deletions(-) diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index ac6738bbf7..0b7314b74b 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -7,12 +7,30 @@ import { AbstractUtxoCoin, TxFormat } from '../../src'; import { utxoCoins, defaultBitGo } from './util'; +type WalletType = 'hot' | 'cold' | 'custodial' | 'custodialPaired' | 'trading'; +type WalletSubType = 'distributedCustody'; +type WalletFlag = { name: string; value: string }; + type WalletOptions = { - type?: 'hot' | 'cold' | 'custodial' | 'custodialPaired' | 'trading'; - subType?: string; - walletFlags?: Array<{ name: string; value: string }>; + type?: WalletType; + subType?: WalletSubType; + walletFlags?: WalletFlag[]; }; +/** + * Enumerates common wallet configurations for testing + */ +export function getWalletConfigurations(): Array<{ name: string; options: WalletOptions }> { + return [ + { name: 'hot wallet', options: { type: 'hot' } }, + { name: 'cold wallet', options: { type: 'cold' } }, + { name: 'custodial wallet', options: { type: 'custodial' } }, + { name: 'distributedCustody wallet', options: { type: 'cold', subType: 'distributedCustody' } }, + { name: 'musigKp wallet', options: { type: 'cold', walletFlags: [{ name: 'musigKp', value: 'true' }] } }, + { name: 'hot musigKp wallet', options: { type: 'hot', walletFlags: [{ name: 'musigKp', value: 'true' }] } }, + ]; +} + /** * Helper function to create a mock wallet for testing */ @@ -35,115 +53,122 @@ export function getTxFormat(coin: AbstractUtxoCoin, wallet: Wallet, requestedFor } /** - * Helper function to run a txFormat test with named arguments + * Helper function to run a txFormat test with named arguments. + * By default, iterates over all wallet configurations and all coins. */ function runTest(params: { description: string; - walletOptions: WalletOptions; - expectedTxFormat: TxFormat | undefined | ((coin: AbstractUtxoCoin) => TxFormat | undefined); + expectedTxFormat: + | TxFormat + | undefined + | ((coin: AbstractUtxoCoin, walletConfig: WalletOptions) => TxFormat | undefined); coinFilter?: (coin: AbstractUtxoCoin) => boolean; + walletFilter?: (walletConfig: { name: string; options: WalletOptions }) => boolean; requestedTxFormat?: TxFormat; }): void { it(params.description, function () { - for (const coin of utxoCoins) { - // Skip coins that don't match the filter - if (params.coinFilter && !params.coinFilter(coin)) { + const walletConfigs = getWalletConfigurations(); + + for (const walletConfig of walletConfigs) { + // Skip wallet configurations that don't match the filter + if (params.walletFilter && !params.walletFilter(walletConfig)) { continue; } - const wallet = createMockWallet(coin, params.walletOptions); - const txFormat = getTxFormat(coin, wallet, params.requestedTxFormat); - - const expectedTxFormat = - typeof params.expectedTxFormat === 'function' ? params.expectedTxFormat(coin) : params.expectedTxFormat; - - assert.strictEqual( - txFormat, - expectedTxFormat, - `${params.description} - ${coin.getChain()}: expected ${expectedTxFormat}, got ${txFormat}` - ); + for (const coin of utxoCoins) { + // Skip coins that don't match the filter + if (params.coinFilter && !params.coinFilter(coin)) { + continue; + } + + const wallet = createMockWallet(coin, walletConfig.options); + const txFormat = getTxFormat(coin, wallet, params.requestedTxFormat); + + const expectedTxFormat = + typeof params.expectedTxFormat === 'function' + ? params.expectedTxFormat(coin, walletConfig.options) + : params.expectedTxFormat; + + assert.strictEqual( + txFormat, + expectedTxFormat, + `${params.description} - ${ + walletConfig.name + } - ${coin.getChain()}: expected ${expectedTxFormat}, got ${txFormat}` + ); + } } }); } describe('txFormat', function () { describe('getDefaultTxFormat', function () { - // Testnet hot wallets default to PSBT (except ZCash) + // ZCash never defaults to PSBT runTest({ - description: 'should default to psbt for testnet hot wallets (except zcash)', - walletOptions: { type: 'hot' }, - expectedTxFormat: (coin) => { - const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; - // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) - return isZcash ? undefined : 'psbt'; - }, - coinFilter: (coin) => utxolib.isTestnet(coin.network), + description: 'should never return psbt for zcash', + coinFilter: (coin) => utxolib.getMainnet(coin.network) === utxolib.networks.zcash, + expectedTxFormat: undefined, }); - // Mainnet Bitcoin hot wallets default to PSBT + // All non-ZCash testnet wallets default to PSBT runTest({ - description: 'should default to psbt for mainnet bitcoin hot wallets', - walletOptions: { type: 'hot' }, - expectedTxFormat: 'psbt', + description: 'should always return psbt for testnet (non-zcash)', coinFilter: (coin) => - utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) === utxolib.networks.bitcoin, + utxolib.isTestnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + expectedTxFormat: 'psbt', }); - // Mainnet non-Bitcoin hot wallets do NOT default to PSBT + // DistributedCustody wallets default to PSBT (mainnet only, testnet already covered) runTest({ - description: 'should not default to psbt for mainnet non-bitcoin hot wallets', - walletOptions: { type: 'hot' }, - expectedTxFormat: undefined, + description: 'should return psbt for distributedCustody wallets on mainnet', coinFilter: (coin) => - utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.bitcoin, + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + walletFilter: (w) => w.options.subType === 'distributedCustody', + expectedTxFormat: 'psbt', }); - // Cold wallets default to PSBT + // MuSig2 wallets default to PSBT (mainnet only, testnet already covered) runTest({ - description: 'should default to psbt for testnet cold wallets as well', - walletOptions: { type: 'cold' }, - coinFilter: (coin) => utxolib.isTestnet(coin.network), - expectedTxFormat: (coin) => { - const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; - // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) - return isZcash ? undefined : 'psbt'; - }, + description: 'should return psbt for wallets with musigKp flag on mainnet', + coinFilter: (coin) => + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + walletFilter: (w) => Boolean(w.options.walletFlags?.some((f) => f.name === 'musigKp' && f.value === 'true')), + expectedTxFormat: 'psbt', }); - // DistributedCustody wallets default to PSBT + // Mainnet Bitcoin hot wallets default to PSBT runTest({ - description: 'should default to psbt for distributedCustody wallets', - walletOptions: { type: 'cold', subType: 'distributedCustody' }, - expectedTxFormat: (coin) => { - const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; - // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) - return isZcash ? undefined : 'psbt'; - }, + description: 'should return psbt for mainnet bitcoin hot wallets', + coinFilter: (coin) => + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) === utxolib.networks.bitcoin, + walletFilter: (w) => w.options.type === 'hot', + expectedTxFormat: 'psbt', }); - // Wallets with musigKp flag default to PSBT + // Other mainnet wallets do NOT default to PSBT runTest({ - description: 'should default to psbt for wallets with musigKp flag', - walletOptions: { type: 'cold', walletFlags: [{ name: 'musigKp', value: 'true' }] }, - expectedTxFormat: (coin) => { - const isZcash = utxolib.getMainnet(coin.network) === utxolib.networks.zcash; - // ZCash is excluded from PSBT default due to PSBT support issues (BTC-1322) - return isZcash ? undefined : 'psbt'; + description: 'should return undefined for other mainnet wallets', + coinFilter: (coin) => + utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + walletFilter: (w) => { + const isHotBitcoin = w.options.type === 'hot'; // This will be bitcoin hot wallets + const isDistributedCustody = w.options.subType === 'distributedCustody'; + const hasMusigKpFlag = Boolean(w.options.walletFlags?.some((f) => f.name === 'musigKp' && f.value === 'true')); + // Only test "other" wallets - exclude the special cases + return !isHotBitcoin && !isDistributedCustody && !hasMusigKpFlag; }, + expectedTxFormat: undefined, }); - // Explicitly specified legacy format is respected + // Test explicitly requested formats runTest({ - description: 'should respect explicitly specified legacy txFormat', - walletOptions: { type: 'hot' }, + description: 'should respect explicitly requested legacy format', expectedTxFormat: 'legacy', requestedTxFormat: 'legacy', }); - // Explicitly specified psbt format is respected runTest({ - description: 'should respect explicitly specified psbt txFormat', - walletOptions: { type: 'cold' }, + description: 'should respect explicitly requested psbt format', expectedTxFormat: 'psbt', requestedTxFormat: 'psbt', }); From 6681c749be3e2ef9b8ed34f0ff76977c72619639 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 15:26:35 +0100 Subject: [PATCH 6/8] feat(abstract-utxo): default testnet transactions to PSBT format Remove special case for ZCash - all testnets now default to PSBT format. Simplify conditional logic by removing unnecessary ZCash exclusions from unit tests and defaulting logic. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 4 ---- modules/abstract-utxo/test/unit/txFormat.ts | 23 +++++-------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 1740a7514e..85f7193e63 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -971,10 +971,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin { return requestedFormat; } - if (getMainnet(this.network) === utxolib.networks.zcash) { - return undefined; - } - if (isTestnet(this.network)) { return 'psbt'; } diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index 0b7314b74b..007436d608 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -103,26 +103,17 @@ function runTest(params: { describe('txFormat', function () { describe('getDefaultTxFormat', function () { - // ZCash never defaults to PSBT + // All testnet wallets default to PSBT runTest({ - description: 'should never return psbt for zcash', - coinFilter: (coin) => utxolib.getMainnet(coin.network) === utxolib.networks.zcash, - expectedTxFormat: undefined, - }); - - // All non-ZCash testnet wallets default to PSBT - runTest({ - description: 'should always return psbt for testnet (non-zcash)', - coinFilter: (coin) => - utxolib.isTestnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + description: 'should always return psbt for testnet', + coinFilter: (coin) => utxolib.isTestnet(coin.network), expectedTxFormat: 'psbt', }); // DistributedCustody wallets default to PSBT (mainnet only, testnet already covered) runTest({ description: 'should return psbt for distributedCustody wallets on mainnet', - coinFilter: (coin) => - utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + coinFilter: (coin) => utxolib.isMainnet(coin.network), walletFilter: (w) => w.options.subType === 'distributedCustody', expectedTxFormat: 'psbt', }); @@ -130,8 +121,7 @@ describe('txFormat', function () { // MuSig2 wallets default to PSBT (mainnet only, testnet already covered) runTest({ description: 'should return psbt for wallets with musigKp flag on mainnet', - coinFilter: (coin) => - utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + coinFilter: (coin) => utxolib.isMainnet(coin.network), walletFilter: (w) => Boolean(w.options.walletFlags?.some((f) => f.name === 'musigKp' && f.value === 'true')), expectedTxFormat: 'psbt', }); @@ -148,8 +138,7 @@ describe('txFormat', function () { // Other mainnet wallets do NOT default to PSBT runTest({ description: 'should return undefined for other mainnet wallets', - coinFilter: (coin) => - utxolib.isMainnet(coin.network) && utxolib.getMainnet(coin.network) !== utxolib.networks.zcash, + coinFilter: (coin) => utxolib.isMainnet(coin.network), walletFilter: (w) => { const isHotBitcoin = w.options.type === 'hot'; // This will be bitcoin hot wallets const isDistributedCustody = w.options.subType === 'distributedCustody'; From 4c2321318906d03bf8721cc544bf6ea2dbaf3f1b Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 15:50:05 +0100 Subject: [PATCH 7/8] feat(abstract-utxo): use defaultTxFormat in tests instead of hardcoded chain list Replace hardcoded list of non-PSBT coin types with a direct call to getDefaultTxFormat method, making tests more maintainable and accurate. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/test/unit/prebuildAndSign.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/abstract-utxo/test/unit/prebuildAndSign.ts b/modules/abstract-utxo/test/unit/prebuildAndSign.ts index ab021a6f15..6fe553c03f 100644 --- a/modules/abstract-utxo/test/unit/prebuildAndSign.ts +++ b/modules/abstract-utxo/test/unit/prebuildAndSign.ts @@ -214,7 +214,8 @@ function run(coin: AbstractUtxoCoin, inputScripts: ScriptType[], txFormat: TxFor [true, false].forEach((useWebauthn) => { it(`should succeed with ${useWebauthn ? 'webauthn encryptedPrv' : 'encryptedPrv'}`, async function () { - const txCoins = ['tzec', 'zec', 'ltc', 'bcha', 'doge', 'dash', 'btg', 'bch']; + // Check if this wallet/coin combination defaults to psbt + const defaultTxFormat = coin.getDefaultTxFormat(wallet); const nocks = createNocks({ bgUrl, wallet, @@ -223,7 +224,7 @@ function run(coin: AbstractUtxoCoin, inputScripts: ScriptType[], txFormat: TxFor recipient, addressInfo, nockOutputAddresses: txFormat !== 'psbt', - txFormat: !txCoins.includes(coin.getChain()) ? 'psbt' : undefined, + txFormat: defaultTxFormat, }); // call prebuild and sign, nocks should be consumed @@ -261,7 +262,8 @@ function run(coin: AbstractUtxoCoin, inputScripts: ScriptType[], txFormat: TxFor it(`should be able to build, sign, & verify a replacement transaction with selfSend: ${selfSend}`, async function () { const rbfTxIds = ['tx-to-be-replaced'], feeMultiplier = 1.5; - const txCoins = ['tzec', 'zec', 'ltc', 'bcha', 'doge', 'dash', 'btg', 'bch']; + // Check if this wallet/coin combination defaults to psbt + const defaultTxFormat = coin.getDefaultTxFormat(wallet); const nocks = createNocks({ bgUrl, wallet, @@ -273,7 +275,7 @@ function run(coin: AbstractUtxoCoin, inputScripts: ScriptType[], txFormat: TxFor feeMultiplier, selfSend, nockOutputAddresses: txFormat !== 'psbt', - txFormat: !txCoins.includes(coin.getChain()) ? 'psbt' : undefined, + txFormat: defaultTxFormat, }); // call prebuild and sign, nocks should be consumed From 7f4c3b28fa57e877c1144ebbc500e772674f874a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Nov 2025 16:28:47 +0100 Subject: [PATCH 8/8] feat(abstract-utxo): default to psbt-lite for testnet Change the default transaction format on testnets from 'psbt' to 'psbt-lite', which uses a more efficient serialization that omits full prevTx data. Add documentation for transaction format options. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 15 +++++++++++++-- modules/abstract-utxo/test/unit/txFormat.ts | 12 +++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 85f7193e63..058079f62d 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -84,7 +84,18 @@ import { isDescriptorWalletData } from './descriptor/descriptorWallet'; import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; -export type TxFormat = 'legacy' | 'psbt'; +export type TxFormat = + // This is a legacy transaction format based around the bitcoinjs-lib serialization of unsigned transactions + // does not include prevOut data and is a bit painful to work with + // going to be deprecated in favor of psbt + // @deprecated + | 'legacy' + // This is the standard psbt format, including the full prevTx data for legacy transactions. + // This will remain supported but is not the default, since the data sizes can become prohibitively large. + | 'psbt' + // This is a nonstandard psbt version where legacy inputs are serialized as if they were segwit inputs. + // While this prevents us to fully verify the transaction fee, we have other checks in place to ensure the fee is within bounds. + | 'psbt-lite'; type UtxoCustomSigningFunction = { (params: { @@ -972,7 +983,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { } if (isTestnet(this.network)) { - return 'psbt'; + return 'psbt-lite'; } const walletFlagMusigKp = wallet.flag('musigKp') === 'true'; diff --git a/modules/abstract-utxo/test/unit/txFormat.ts b/modules/abstract-utxo/test/unit/txFormat.ts index 007436d608..0911d0e93a 100644 --- a/modules/abstract-utxo/test/unit/txFormat.ts +++ b/modules/abstract-utxo/test/unit/txFormat.ts @@ -103,11 +103,11 @@ function runTest(params: { describe('txFormat', function () { describe('getDefaultTxFormat', function () { - // All testnet wallets default to PSBT + // All testnet wallets default to PSBT-lite runTest({ - description: 'should always return psbt for testnet', + description: 'should always return psbt-lite for testnet', coinFilter: (coin) => utxolib.isTestnet(coin.network), - expectedTxFormat: 'psbt', + expectedTxFormat: 'psbt-lite', }); // DistributedCustody wallets default to PSBT (mainnet only, testnet already covered) @@ -161,5 +161,11 @@ describe('txFormat', function () { expectedTxFormat: 'psbt', requestedTxFormat: 'psbt', }); + + runTest({ + description: 'should respect explicitly requested psbt-lite format', + expectedTxFormat: 'psbt-lite', + requestedTxFormat: 'psbt-lite', + }); }); });