diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index d06d0032f3..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. @@ -801,20 +827,20 @@ 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'); 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) ); 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 = {