From 7022e7269052b50056c7821a8db49eea4604552e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 21 May 2026 11:32:44 +0200 Subject: [PATCH 1/3] test(abstract-utxo): reproduce BitGoPsbt rejection in descriptor sign Refs: T1-3400 --- .../descriptor/signTransactionGuard.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionGuard.ts diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionGuard.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionGuard.ts new file mode 100644 index 0000000000..281a173144 --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionGuard.ts @@ -0,0 +1,56 @@ +import 'mocha'; +import assert from 'assert'; + +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import { signTransaction } from '../../../../src/transaction/signTransaction'; +import type { UtxoWallet } from '../../../../src/wallet'; +import { defaultBitGo, getUtxoCoin } from '../../util/utxoCoins'; +import { getDefaultWalletKeys } from '../../util/keychains'; + +const { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } = testutils.descriptor; + +// Regression test for T1-3400. The descriptor sign guard previously +// only accepted utxo-lib PSBTs (UtxoLibPsbt) and Uint8Array, throwing +// `descriptor wallets require PSBT format transactions` when the +// decoded prebuild was a wasm-utxo BitGoPsbt — the default after +// defaultSdkBackend flipped to 'wasm-utxo' for all coins. +describe('signTransaction: descriptor wallet with BitGoPsbt prebuild (T1-3400)', function () { + it('does not reject BitGoPsbt with the descriptor PSBT guard', async function () { + const coin = getUtxoCoin('btc'); + const descriptorMap = getDescriptorMap('Wsh2Of3'); + const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + const psbtHex = Buffer.from(psbt.serialize()).toString('hex'); + + const wallet = { + coinSpecific: () => ({ + descriptors: [...descriptorMap.entries()].map(([name, descriptor]) => ({ + name, + value: descriptor.toString(), + })), + }), + keyIds: () => ['k0', 'k1', 'k2'], + } as unknown as UtxoWallet; + + const userPrv = getDefaultWalletKeys().user.toBase58(); + + let caught: Error | undefined; + try { + // decodeWith: 'wasm-utxo' forces decodeTransactionFromPrebuild to + // return fixedScriptWallet.BitGoPsbt (the path that used to trip + // the guard). + await signTransaction(coin, defaultBitGo, { + txPrebuild: { txHex: psbtHex, decodeWith: 'wasm-utxo' }, + prv: userPrv, + wallet, + } as any); + } catch (e) { + caught = e as Error; + } + + assert.ok( + !caught || !/descriptor wallets require PSBT format transactions/.test(caught.message), + `descriptor sign guard incorrectly rejected BitGoPsbt: ${caught?.message}` + ); + }); +}); From 43218b916c17410f3d0fd1e576bed4600749adb7 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 21 May 2026 11:25:17 +0200 Subject: [PATCH 2/3] refactor(abstract-utxo): decode descriptor PSBTs directly to wasm Psbt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptor flows were going through a wasteful byte round-trip: prebuild bytes -> fixedScriptWallet.BitGoPsbt -> bytes -> descriptorWallet.Psbt decodeTransactionFromPrebuild + decodeWith='wasm-utxo' returns the fixed-script BitGoPsbt, which descriptor signing/parsing/verifying/ explaining then had to convert via toWasmPsbt before reaching the descriptor APIs. Beyond the wasted work, it produced T1-3400 — a guard that only accepted utxo-lib PSBTs rejected the BitGoPsbt the default backend produced. Add a decodeDescriptorPsbt helper (transaction/decode.ts) that parses prebuild bytes straight into the wasm-utxo descriptor Psbt. Use it in all four descriptor sites: - signTransaction.ts descriptor branch - descriptor/parse.ts - descriptor/verifyTransaction.ts - abstractUtxoCoin.explainTransaction (+ explainTx descriptor branch) This makes the BitGoPsbt path unreachable for descriptor wallets, so the T1-3400 'descriptor wallets require PSBT format transactions' guard relaxation is no longer needed. Closes: T1-3400 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 14 ++++++++-- .../abstract-utxo/src/transaction/decode.ts | 26 ++++++++++++++++- .../src/transaction/descriptor/parse.ts | 9 ++---- .../descriptor/verifyTransaction.ts | 6 ++-- .../src/transaction/explainTransaction.ts | 28 ++++++++----------- .../src/transaction/signTransaction.ts | 25 ++++++++--------- 6 files changed, 65 insertions(+), 43 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 41bd688757..735223ae0c 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -86,7 +86,12 @@ import { } from './names'; import { assertFixedScriptWalletAddress } from './address/fixedScript'; import { isSdkBackend, ParsedTransaction, SdkBackend } from './transaction/types'; -import { decodePsbtWith, encodeTransaction, stringToBufferTryFormats } from './transaction/decode'; +import { + decodeDescriptorPsbt, + decodePsbtWith, + encodeTransaction, + stringToBufferTryFormats, +} from './transaction/decode'; import { fetchKeychains, toBip32Triple, UtxoKeychain } from './keychains'; import { verifyKeySignature, verifyUserPublicKey } from './verifyKey'; import { getPolicyForEnv } from './descriptor/validatePolicy'; @@ -963,7 +968,12 @@ export abstract class AbstractUtxoCoin params.pubs = toBip32Triple(keychains).map((k) => k.neutered().toBase58()) as Triple; } } - return explainTx(this.decodeTransactionFromPrebuild(params), params, this.name); + if (wallet && isDescriptorWallet(wallet)) { + // Descriptor wallets decode prebuild bytes straight into the wasm-utxo + // descriptor Psbt, skipping the fixedScriptWallet.BitGoPsbt intermediate. + return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.name); + } + return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.name); } /** diff --git a/modules/abstract-utxo/src/transaction/decode.ts b/modules/abstract-utxo/src/transaction/decode.ts index d985736f48..77d461f6ac 100644 --- a/modules/abstract-utxo/src/transaction/decode.ts +++ b/modules/abstract-utxo/src/transaction/decode.ts @@ -1,5 +1,5 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, hasPsbtMagic, Psbt as WasmPsbt, utxolibCompat } from '@bitgo/wasm-utxo'; import { getNetworkFromCoinName, UtxoCoinName } from '../names'; @@ -66,6 +66,30 @@ export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName): BitGo return decodePsbtWith(psbt, coinName, 'wasm-utxo'); } +export type PrebuildLike = { + txHex?: string; + txBase64?: string; + txHexPsbt?: string; +}; + +/** + * Decode a prebuild's PSBT bytes directly into a wasm-utxo descriptor `Psbt`. + * + * Skips the `fixedScriptWallet.BitGoPsbt` intermediate that `decodeTransactionFromPrebuild` + * + `toWasmPsbt` would otherwise round-trip through for descriptor flows. + */ +export function decodeDescriptorPsbt(prebuild: PrebuildLike): WasmPsbt { + const s = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64; + if (!s) { + throw new Error('missing required txHex or txBase64 property'); + } + const bytes = stringToBufferTryFormats(s, ['hex', 'base64']); + if (!hasPsbtMagic(bytes)) { + throw new Error('descriptor wallets require PSBT format transactions'); + } + return WasmPsbt.deserialize(bytes); +} + export function encodeTransaction( transaction: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt ): Buffer { diff --git a/modules/abstract-utxo/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index ce3911e57f..f0a1d6eb20 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -10,6 +10,7 @@ import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../r import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference'; import { UtxoCoinName } from '../../names'; import { sumValues, toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil'; +import { decodeDescriptorPsbt } from '../decode'; type ParsedOutput = Omit & { script: Buffer }; @@ -128,13 +129,7 @@ export function parse( if (!recipients) { throw new Error('recipients is required'); } - const psbt = coin.decodeTransactionFromPrebuild(params.txPrebuild); - let wasmPsbt: Psbt; - try { - wasmPsbt = toWasmPsbt(psbt as Psbt | UtxoLibPsbt | Uint8Array); - } catch (e) { - throw new Error(`expected psbt to be a wasm-utxo or utxo-lib PSBT: ${e instanceof Error ? e.message : e}`); - } + const wasmPsbt = decodeDescriptorPsbt(params.txPrebuild); const walletKeys = toBip32Triple(keychains); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); return { diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 699f915400..645dcea578 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -4,7 +4,8 @@ import type { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; import { UtxoCoinName } from '../../names'; -import { toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil'; +import { UtxoLibPsbt } from '../../wasmUtil'; +import { decodeDescriptorPsbt } from '../decode'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -76,10 +77,9 @@ export async function verifyTransaction( params: VerifyTransactionOptions, descriptorMap: descriptorWallet.DescriptorMap ): Promise { - const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); let psbt: Psbt; try { - psbt = toWasmPsbt(tx as Psbt | UtxoLibPsbt | Uint8Array); + psbt = decodeDescriptorPsbt(params.txPrebuild); } catch (e) { const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( coin as unknown as IBaseCoin, diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 8546431f25..45c90da5e9 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -1,5 +1,5 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, Psbt as WasmPsbt } from '@bitgo/wasm-utxo'; import { isTriple, IWallet, Triple } from '@bitgo/sdk-core'; import { getDescriptorMapFromWallet, isDescriptorWallet } from '../descriptor'; @@ -7,7 +7,6 @@ import { toBip32Triple } from '../keychains'; import { getPolicyForEnv } from '../descriptor/validatePolicy'; import { UtxoCoinName } from '../names'; import type { Unspent } from '../unspent'; -import { toWasmPsbt } from '../wasmUtil'; import { getReplayProtectionPubkeys } from './fixedScript/replayProtection'; import type { @@ -23,7 +22,7 @@ import * as descriptor from './descriptor'; * change amounts, and transaction outputs. */ export function explainTx( - tx: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt, + tx: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt | WasmPsbt, params: { wallet?: IWallet; pubs?: string[]; @@ -34,20 +33,15 @@ export function explainTx( coinName: UtxoCoinName ): TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt | TransactionExplanationWasm { if (params.wallet && isDescriptorWallet(params.wallet)) { - if (tx instanceof utxolib.bitgo.UtxoPsbt) { - if (!params.pubs || !isTriple(params.pubs)) { - throw new Error('pub triple is required for descriptor wallets'); - } - const walletKeys = toBip32Triple(params.pubs); - const descriptors = getDescriptorMapFromWallet( - params.wallet, - walletKeys, - getPolicyForEnv(params.wallet.bitgo.env) - ); - return descriptor.explainPsbt(toWasmPsbt(tx), descriptors, coinName); + if (!(tx instanceof WasmPsbt)) { + throw new Error('descriptor wallets require PSBT format transactions'); } - - throw new Error('legacy transactions are not supported for descriptor wallets'); + if (!params.pubs || !isTriple(params.pubs)) { + throw new Error('pub triple is required for descriptor wallets'); + } + const walletKeys = toBip32Triple(params.pubs); + const descriptors = getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); + return descriptor.explainPsbt(tx, descriptors, coinName); } if (tx instanceof utxolib.bitgo.UtxoPsbt) { return fixedScript.explainPsbt(tx, { ...params, customChangePubs: params.customChangeXpubs }, coinName); @@ -71,6 +65,8 @@ export function explainTx( }, customChangeWalletXpubs: params.customChangeXpubs, }); + } else if (tx instanceof WasmPsbt) { + throw new Error('descriptor Psbt is only supported for descriptor wallets'); } else { return fixedScript.explainLegacyTx(tx, params, coinName); } diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts index 75c76f4443..ace4ce0dac 100644 --- a/modules/abstract-utxo/src/transaction/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -6,11 +6,11 @@ import buildDebug from 'debug'; import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin'; import { getDescriptorMapFromWallet, getPolicyForEnv, isDescriptorWallet } from '../descriptor'; import { fetchKeychains, toBip32Triple } from '../keychains'; -import { isUtxoLibPsbt, toWasmPsbt } from '../wasmUtil'; +import { isUtxoLibPsbt } from '../wasmUtil'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; -import { decodePsbtWith, encodeTransaction } from './decode'; +import { decodeDescriptorPsbt, decodePsbtWith, encodeTransaction } from './decode'; const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction'); @@ -43,14 +43,6 @@ export async function signTransaction( throw new Error('missing txPrebuild parameter'); } - let tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); - - // When returnLegacyFormat is set, ensure we use wasm-utxo's BitGoPsbt so - // getHalfSignedLegacyFormat() is available after signing. - if (params.returnLegacyFormat && isUtxoLibPsbt(tx)) { - tx = decodePsbtWith(tx.toBuffer(), coin.name, 'wasm-utxo'); - } - const signerKeychain = getSignerKeychain(params.prv); const { wallet } = params; @@ -59,17 +51,22 @@ export async function signTransaction( if (!signerKeychain) { throw new Error('missing signer'); } - if (!isUtxoLibPsbt(tx) && !(tx instanceof Uint8Array)) { - throw new Error('descriptor wallets require PSBT format transactions'); - } + const psbt = decodeDescriptorPsbt(params.txPrebuild); const walletKeys = toBip32Triple(await fetchKeychains(coin, wallet)); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(bitgo.env)); - const psbt = toWasmPsbt(tx); descriptor.signPsbt(psbt, descriptorMap, signerKeychain, { onUnknownInput: 'throw', }); return { txHex: Buffer.from(psbt.serialize()).toString('hex') }; } else { + let tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); + + // When returnLegacyFormat is set, ensure we use wasm-utxo's BitGoPsbt so + // getHalfSignedLegacyFormat() is available after signing. + if (params.returnLegacyFormat && isUtxoLibPsbt(tx)) { + tx = decodePsbtWith(tx.toBuffer(), coin.name, 'wasm-utxo'); + } + const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.name, { walletId: params.txPrebuild.walletId, txInfo: params.txPrebuild.txInfo, From 6766023a9ffe6f01ad66113f523a334d997ffa0e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 21 May 2026 12:02:38 +0200 Subject: [PATCH 3/3] test(abstract-utxo): add E2E descriptor signTransaction test No test in the monorepo exercised the top-level signTransaction against a descriptor wallet, leaving the decode-and-route logic in the SDK entry point unprotected. T1-3400 lived in that gap for the full lifetime of the wasm-utxo backend default. Add an end-to-end test that drives a real PSBT through coin.signTransaction with decodeWith='wasm-utxo', mocks the keychain fetch via nock, and asserts the returned txHex deserializes as a signed PSBT with valid user-key signatures on each input. Refs: T1-3401 --- .../descriptor/signTransactionE2E.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionE2E.ts diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionE2E.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionE2E.ts new file mode 100644 index 0000000000..e205d9c513 --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/signTransactionE2E.ts @@ -0,0 +1,75 @@ +import 'mocha'; +import assert from 'assert'; + +import { Psbt } from '@bitgo/wasm-utxo'; +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import type { UtxoWallet } from '../../../../src/wallet'; +import { getUtxoCoin } from '../../util/utxoCoins'; +import { nockBitGo } from '../../util/nockBitGo'; + +const { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } = testutils.descriptor; +const { getKeyTriple } = testutils; + +// End-to-end coverage for descriptor wallet signing through the +// top-level coin.signTransaction entry point (T1-3401). Locks in the +// wasm-utxo decode path that T1-3400 broke. +describe('signTransaction E2E: descriptor wallet (wasm-utxo backend)', function () { + it('produces a signed PSBT with valid user signatures on every input', async function () { + const coin = getUtxoCoin('btc'); + // mockPsbtDefaultWithDescriptorTemplate uses getDefaultXPubs('a') — + // i.e. getKeyTriple('a') — so we sign with the same triple. + const keychain = getKeyTriple('a'); + const userKey = keychain[0]; + const descriptorMap = getDescriptorMap('Wsh2Of3', keychain); + + const unsignedPsbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + const psbtHex = Buffer.from(unsignedPsbt.serialize()).toString('hex'); + + const keyIds = ['kU', 'kB', 'kG']; + const wallet = { + coinSpecific: () => ({ + descriptors: [...descriptorMap.entries()].map(([name, descriptor]) => ({ + name, + value: descriptor.toString(), + })), + }), + keyIds: () => keyIds, + } as unknown as UtxoWallet; + + // Mock the keychain fetch — fetchKeychains pulls each key by id. + keyIds.forEach((id, i) => { + nockBitGo().get(`/api/v2/${coin.getChain()}/key/${id}`).reply(200, { pub: keychain[i].neutered().toBase58() }); + }); + + // decodeWith: 'wasm-utxo' is explicit to lock in the BitGoPsbt + // decode path that T1-3400 broke; this is also the production + // default after 1702a08009. + const result = await coin.signTransaction({ + txPrebuild: { txHex: psbtHex, decodeWith: 'wasm-utxo' }, + prv: userKey.toBase58(), + wallet, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + assert.ok('txHex' in result, 'expected signTransaction to return { txHex }'); + const signedPsbt = Psbt.deserialize(Buffer.from(result.txHex, 'hex')); + + const inputs = signedPsbt.getInputs(); + assert.ok(inputs.length > 0, 'expected at least one input'); + inputs.forEach((_input, vin) => { + assert.ok(signedPsbt.hasPartialSignatures(vin), `input ${vin} has no partial signatures`); + const sigs = signedPsbt.getPartialSignatures(vin); + assert.ok(sigs.length > 0, `input ${vin} returned empty partial signatures`); + // Pubkeys in partial sigs are the descriptor-derived child keys, not + // the user master pubkey; assert that each sig validates at its claimed + // pubkey, which is the strongest "signing actually worked" check. + for (const sig of sigs) { + assert.ok( + signedPsbt.validateSignatureAtInput(vin, sig.pubkey), + `input ${vin} has an invalid signature for pubkey ${Buffer.from(sig.pubkey).toString('hex')}` + ); + } + }); + }); +});