From 945a2722c53dcd9032fed6acf449911e4d3ba074 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 9 Feb 2026 11:33:03 +0100 Subject: [PATCH 1/2] feat(abstract-utxo): make pubs parameter required for custom signing function Ensure pubs parameter is always required when using custom signing functions to maintain consistency and prevent errors. Update function signature in the interface and implementation to reflect this requirement. Issue: BTC-3018 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 4 ++-- modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 2 +- modules/sdk-core/src/bitgo/wallet/wallet.ts | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index d06d0032f3..eada9bcd23 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -801,13 +801,13 @@ export abstract class AbstractUtxoCoin /** * Sign a transaction with a custom signing function. Example use case is express external signer * @param customSigningFunction custom signing function that returns a single signed transaction - * @param signTransactionParams parameters for custom signing function. Includes txPrebuild and pubs (for legacy tx only). + * @param signTransactionParams parameters for custom signing function. Includes txPrebuild and pubs * * @returns signed transaction as hex string */ async signWithCustomSigningFunction( customSigningFunction: UtxoCustomSigningFunction, - signTransactionParams: { txPrebuild: TransactionPrebuild; pubs?: string[] } + signTransactionParams: { txPrebuild: TransactionPrebuild; pubs: string[] } ): Promise { const txHex = signTransactionParams.txPrebuild.txHex; assert(txHex, 'missing txHex parameter'); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index cfb424aba3..936d4edd3d 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -613,7 +613,7 @@ export interface IBaseCoin { presignTransaction(params: PresignTransactionOptions): Promise; signWithCustomSigningFunction?( customSigningFunction: CustomSigningFunction, - signTransactionParams: { txPrebuild: TransactionPrebuild; pubs?: string[] } + signTransactionParams: { txPrebuild: TransactionPrebuild; pubs: string[] } ): Promise; newWalletObject(walletParams: any): IWallet; feeEstimate(params: FeeEstimateOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index bdbf815115..3786c0c2e1 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2194,7 +2194,11 @@ export class Wallet implements IWallet { if (_.isFunction(params.customSigningFunction)) { if (typeof this.baseCoin.signWithCustomSigningFunction === 'function') { - return this.baseCoin.signWithCustomSigningFunction(params.customSigningFunction, signTransactionParams); + assert(pubs, 'pubs are required for custom signing'); + return this.baseCoin.signWithCustomSigningFunction(params.customSigningFunction, { + ...signTransactionParams, + pubs, + }); } const keys = await this.baseCoin.keychains().getKeysForSigning({ wallet: this }); const signTransactionParamsWithSeed = { From 15b50c89a18d7d1d48eafec2587c7fe077cc5089 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 9 Feb 2026 11:33:52 +0100 Subject: [PATCH 2/2] feat(abstract-utxo): default to wasm-utxo on testnet Change the default SDK backend to wasm-utxo for testnet coins, keeping utxolib for mainnet. Also enhance PSBT transaction handling to support key path spend input detection in both backends. Issue: BTC-3018 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 32 +++++++++++++++-- .../abstract-utxo/test/unit/customSigner.ts | 7 ++-- .../test/unit/explainTransaction.ts | 36 ++++++++++++++++--- modules/abstract-utxo/test/unit/wallet.ts | 9 ++--- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index eada9bcd23..772fb0a3b1 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -57,7 +57,7 @@ import { v1Sweep, V1SweepParams, } from './recovery'; -import { isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection'; +import { getReplayProtectionPubkeys, isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection'; import { supportedCrossChainRecoveries } from './config'; import { assertValidTransactionRecipient, @@ -81,6 +81,7 @@ import { getMainnetCoinName, getNetworkFromCoinName, isTestnetCoin, + isUtxoCoinNameMainnet, UtxoCoinName, UtxoCoinNameMainnet, } from './names'; @@ -143,6 +144,28 @@ type UtxoCustomSigningFunction = { const { isChainCode, scriptTypeForChain, outputScripts } = bitgo; +/** + * Check if a decoded transaction has at least one taproot key path spend (MuSig2) input. + * Works for both utxolib UtxoPsbt and wasm-utxo BitGoPsbt. + */ +function hasKeyPathSpendInput( + tx: DecodedTransaction, + pubs: string[] | undefined, + coinName: UtxoCoinName +): boolean { + if (tx instanceof bitgo.UtxoPsbt) { + return bitgo.isTransactionWithKeyPathSpendInput(tx); + } + if (tx instanceof fixedScriptWallet.BitGoPsbt) { + assert(pubs && isTriple(pubs), 'pub triple is required to check for key path spend inputs in wasm-utxo PSBT'); + const rootWalletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(pubs); + const replayProtection = { publicKeys: getReplayProtectionPubkeys(coinName) }; + const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection); + return parsed.inputs.some((input) => input.scriptType === 'p2trMusig2KeyPath'); + } + return false; +} + /** * Convert ValidationError to TxIntentMismatchRecipientError with structured data * @@ -379,7 +402,6 @@ export abstract class AbstractUtxoCoin public altScriptHash?: number; public supportAltScriptDestination?: boolean; - public defaultSdkBackend: SdkBackend = 'utxolib'; public readonly amountType: 'number' | 'bigint'; protected constructor(bitgo: BitGoBase, amountType: 'number' | 'bigint' = 'number') { @@ -387,6 +409,10 @@ export abstract class AbstractUtxoCoin this.amountType = amountType; } + get defaultSdkBackend(): SdkBackend { + return isUtxoCoinNameMainnet(this.name) ? 'utxolib' : 'wasm-utxo'; + } + /** * @deprecated - will be removed when we drop support for utxolib * Use `name` property instead. @@ -814,7 +840,7 @@ export abstract class AbstractUtxoCoin const tx = this.decodeTransaction(txHex); - const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx); + const isTxWithKeyPathSpendInput = hasKeyPathSpendInput(tx, signTransactionParams.pubs, this.name); if (!isTxWithKeyPathSpendInput) { return await customSigningFunction({ ...signTransactionParams, coin: this }); diff --git a/modules/abstract-utxo/test/unit/customSigner.ts b/modules/abstract-utxo/test/unit/customSigner.ts index d8ad50e996..9a96469d90 100644 --- a/modules/abstract-utxo/test/unit/customSigner.ts +++ b/modules/abstract-utxo/test/unit/customSigner.ts @@ -43,13 +43,14 @@ describe('UTXO Custom Signer Function', function () { }); function nocks(txPrebuild: { txHex: string }) { + const pubs = rootWalletKey.triple.map((k) => k.neutered().toBase58()); nock(bgUrl) .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`) .reply(200, { ...txPrebuild, txInfo: {} }); nock(bgUrl).get(`/api/v2/${wallet.coin()}/public/block/latest`).reply(200, { height: 1000 }); - nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[0]}`).reply(200, { pub: 'pub' }); - nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[1]}`).reply(200, { pub: 'pub' }); - nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[2]}`).reply(200, { pub: 'pub' }); + nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[0]}`).reply(200, { pub: pubs[0] }); + nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[1]}`).reply(200, { pub: pubs[1] }); + nock(bgUrl).persist().get(`/api/v2/${wallet.coin()}/key/${wallet.keyIds()[2]}`).reply(200, { pub: pubs[2] }); return nock(bgUrl).post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`).reply(200, { ok: true }); } diff --git a/modules/abstract-utxo/test/unit/explainTransaction.ts b/modules/abstract-utxo/test/unit/explainTransaction.ts index aa5d368534..bcf1c0486a 100644 --- a/modules/abstract-utxo/test/unit/explainTransaction.ts +++ b/modules/abstract-utxo/test/unit/explainTransaction.ts @@ -1,18 +1,46 @@ import assert from 'assert'; -import { Triple } from '@bitgo/sdk-core'; +import { common, Triple, Wallet } from '@bitgo/sdk-core'; +import nock = require('nock'); import { bip322Fixtures } from './fixtures/bip322/fixtures'; import { psbtTxHex } from './fixtures/psbtHexProof'; -import { getUtxoCoin } from './util'; +import { defaultBitGo, getUtxoCoin } from './util'; + +nock.disableNetConnect(); + +const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; describe('Explain Transaction', function () { + afterEach(function () { + nock.cleanAll(); + }); + describe('Verify paygo output when explaining psbt transaction', function () { const coin = getUtxoCoin('tbtc4'); + const pubs: Triple = [ + 'xpub661MyMwAqRbcFaKvNBFdV6HY7ibXxFSbL7rDjY1cVM8s3pGPTNKfjTu8SmatNZ7AcZQehSqcEnC7vezMoprQvhqQUszLhuY4G8ruv6PGEr7', + 'xpub661MyMwAqRbcGkAVVQVHrEYQA4hfbDW9Rpn35b6sXA9TSBd5Qzjwz7F6Weje57kBVeVfimfJjXutwUDBSMz5yRwsWik9gNyxrdvSaJbjgi6', + 'xpub661MyMwAqRbcGCCL3GYNbvKs1t5k5yeKZcV5smto9T5Z17zkcgRF4X9uzDfPxMHHedwF4JcJ6kpg8M2NWHEFC5LMSv1t3nMMm1GC9PcVmq5', + ]; + + const keyIds = ['key-user', 'key-backup', 'key-bitgo']; + + function nockKeyFetch(): void { + keyIds.forEach((id, i) => { + nock(bgUrl).get(`/api/v2/${coin.getChain()}/key/${id}`).reply(200, { pub: pubs[i] }); + }); + } + it('should detect and verify paygo address proof in PSBT', async function () { - // Call explainTransaction - await coin.explainTransaction(psbtTxHex); + nockKeyFetch(); + const wallet = new Wallet(defaultBitGo, coin, { + id: 'mock-wallet-id', + coin: coin.getChain(), + keys: keyIds, + }); + await coin.explainTransaction(psbtTxHex, wallet); }); }); diff --git a/modules/abstract-utxo/test/unit/wallet.ts b/modules/abstract-utxo/test/unit/wallet.ts index 6835b108f8..451873cf4d 100644 --- a/modules/abstract-utxo/test/unit/wallet.ts +++ b/modules/abstract-utxo/test/unit/wallet.ts @@ -52,11 +52,11 @@ describe('manage unspents', function () { ); nocks.push( - ...psbts.map((psbt) => + ...psbts.map(() => nock(bgUrl) .post( `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`, - _.matches({ txHex: psbt.signAllInputsHD(rootWalletKey.user).toHex() }) + _.matches({ type: 'consolidate', bulk: true }) ) .reply(200) ) @@ -92,10 +92,7 @@ describe('manage unspents', function () { nocks.push( nock(bgUrl) - .post( - `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`, - _.matches({ txHex: psbt.signAllInputsHD(rootWalletKey.user).toHex() }) - ) + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`, _.matches({ type: 'consolidate' })) .reply(200) );