From 1617968cbbb8644d466266f0944e293198d3bbaa Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Mon, 22 Dec 2025 12:35:29 -0800 Subject: [PATCH 1/3] feat: add PSBT support for v1 transaction building Add opt-in PSBT support for legacy v1 wallet transactions. When `usePsbt: true` is passed to `createTransaction`, the function now returns a PSBT hex in the `transactionHex` field instead of a legacy unsigned transaction hex. Key changes: - Add `buildPsbt` function in transactionBuilder.ts that constructs a PSBT with embedded signing metadata (BIP32 derivation paths, redeem/witness scripts, global xpubs) - Add `createRootWalletKeysFromV1Keychains` helper to convert v1 keychain format to utxo-lib's RootWalletKeys - Update `signTransaction` in wallet.ts to auto-detect PSBT format using `utxolib.bitgo.isPsbt()` and route to appropriate signing - Add comprehensive tests verifying PSBT output matches legacy tx structure The PSBT format embeds all signing metadata directly in the transaction, eliminating the need for a separate `unspents` array. This prepares v1 wallets for gradual migration to PSBT-based signing. Default behavior remains legacy format for backward compatibility. Use `usePsbt: true` to opt-in to PSBT format. TICKET: BTC-2894 TICKET: BTC-2894 --- modules/sdk-api/src/v1/transactionBuilder.ts | 113 +++++++- modules/sdk-api/src/v1/wallet.ts | 13 +- modules/sdk-api/test/unit/v1/wallet.ts | 267 +++++++++++++++++++ 3 files changed, 390 insertions(+), 3 deletions(-) diff --git a/modules/sdk-api/src/v1/transactionBuilder.ts b/modules/sdk-api/src/v1/transactionBuilder.ts index be03a14d68..9bfca327af 100644 --- a/modules/sdk-api/src/v1/transactionBuilder.ts +++ b/modules/sdk-api/src/v1/transactionBuilder.ts @@ -21,6 +21,46 @@ import { common, getAddressP2PKH, getNetwork, sanitizeLegacyPath } from '@bitgo/ import { verifyAddress } from './verifyAddress'; import { tryPromise } from '../util'; +type Triple = [T, T, T]; + +interface V1Keychain { + xpub: string; + path?: string; + walletSubPath?: string; +} + +/** + * Parse chainPath like "/0/13" into { chain: 0, index: 13 } + */ +function parseChainPath(chainPath: string): { chain: number; index: number } { + const parts = chainPath.split('/').filter((p) => p.length > 0); + if (parts.length !== 2) { + throw new Error(`Invalid chainPath: ${chainPath}`); + } + return { chain: parseInt(parts[0], 10), index: parseInt(parts[1], 10) }; +} + +/** + * Create RootWalletKeys from v1 wallet keychains. + * v1 keychains have a structure like { xpub, path: 'm', walletSubPath: '/0/0' } + */ +function createRootWalletKeysFromV1Keychains(keychains: V1Keychain[]): utxolib.bitgo.RootWalletKeys { + if (keychains.length !== 3) { + throw new Error('Expected 3 keychains for v1 wallet'); + } + + const bip32Keys = keychains.map((k) => bip32.fromBase58(k.xpub)) as Triple; + + // v1 wallets typically have walletSubPath like '/0/0' which we convert to derivation prefixes like '0/0' + const derivationPrefixes = keychains.map((k) => { + const walletSubPath = k.walletSubPath || '/0/0'; + // Remove leading slash if present + return walletSubPath.startsWith('/') ? walletSubPath.slice(1) : walletSubPath; + }) as Triple; + + return new utxolib.bitgo.RootWalletKeys(bip32Keys, derivationPrefixes); +} + interface BaseOutput { amount: number; travelInfo?: any; @@ -235,6 +275,9 @@ exports.createTransaction = function (params) { let changeOutputs: Output[] = []; + // All outputs for the transaction (recipients, OP_RETURNs, change, fees) + let outputs: Output[] = []; + let containsUncompressedPublicKeys = false; // The transaction. @@ -603,7 +646,8 @@ exports.createTransaction = function (params) { throw new Error('transaction too large: estimated size ' + minerFeeInfo.size + ' bytes'); } - const outputs: Output[] = []; + // Reset outputs array (use outer scope variable) + outputs = []; recipients.forEach(function (recipient) { let script; @@ -739,8 +783,75 @@ exports.createTransaction = function (params) { }); }; + // Build PSBT with all signing metadata embedded + const buildPsbt = function (): utxolib.bitgo.UtxoPsbt { + const psbt = utxolib.bitgo.createPsbtForNetwork({ network }); + + // Need wallet keychains for PSBT metadata + const walletKeychains = params.wallet.keychains; + if (!walletKeychains || walletKeychains.length !== 3) { + throw new Error('Wallet keychains required for PSBT format'); + } + + const rootWalletKeys = createRootWalletKeysFromV1Keychains(walletKeychains); + utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys); + + // Add multisig inputs with PSBT metadata + for (const unspent of unspents) { + const { chain, index } = parseChainPath(unspent.chainPath) as { chain: utxolib.bitgo.ChainCode; index: number }; + + const walletUnspent: utxolib.bitgo.WalletUnspent = { + id: `${unspent.tx_hash}:${unspent.tx_output_n}`, + address: unspent.address, + chain, + index, + value: BigInt(unspent.value), + }; + + utxolib.bitgo.addWalletUnspentToPsbt(psbt, walletUnspent, rootWalletKeys, 'user', 'backup', { + skipNonWitnessUtxo: true, + }); + } + + // Fee single key inputs are not supported with PSBT yet - throw to trigger fallback to legacy + if (feeSingleKeyUnspentsUsed.length > 0) { + throw new Error('PSBT does not support feeSingleKey inputs - use legacy transaction format'); + } + + // Add outputs (recipients, change, fees, OP_RETURNs) - already calculated in outputs array + for (const output of outputs) { + psbt.addOutput({ + script: (output as ScriptOutput).script, + value: BigInt(output.amount), + }); + } + + return psbt; + }; + // Serialize the transaction, returning what is needed to sign it const serialize = function () { + // Build and return PSBT format when usePsbt is explicitly true + // PSBT hex is returned in transactionHex field for backward compatibility + // Use utxolib.bitgo.isPsbt() to detect if transactionHex contains PSBT or legacy tx + if (params.usePsbt === true) { + const psbt = buildPsbt(); + return { + transactionHex: psbt.toHex(), + fee: fee, + changeAddresses: changeOutputs.map(function (co) { + return _.pick(co, ['address', 'path', 'amount']); + }), + walletId: params.wallet.id(), + feeRate: feeRate, + instant: params.instant, + bitgoFee: bitgoFeeInfo, + estimatedSize: minerFeeInfo.size, + travelInfos: travelInfos, + }; + } + + // Legacy format: return transactionHex with separate unspents array // only need to return the unspents that were used and just the chainPath, redeemScript, and instant flag const pickedUnspents: any = _.map(unspents, function (unspent) { return _.pick(unspent, ['chainPath', 'redeemScript', 'instant', 'witnessScript', 'script', 'value']); diff --git a/modules/sdk-api/src/v1/wallet.ts b/modules/sdk-api/src/v1/wallet.ts index 5144fe2b37..8b07ba9176 100644 --- a/modules/sdk-api/src/v1/wallet.ts +++ b/modules/sdk-api/src/v1/wallet.ts @@ -913,8 +913,15 @@ Wallet.prototype.createTransaction = function (params, callback) { Wallet.prototype.signTransaction = function (params, callback) { params = _.extend({}, params); - if (params.psbt) { - return tryPromise(() => signPsbtRequest(params)) + // Route to PSBT signing if params.psbt exists OR if transactionHex contains a PSBT + // Use utxolib.bitgo.isPsbt() to auto-detect PSBT format in transactionHex + if (params.psbt || (params.transactionHex && utxolib.bitgo.isPsbt(params.transactionHex))) { + const psbtHex = params.psbt || params.transactionHex; + return tryPromise(() => signPsbtRequest({ psbt: psbtHex, keychain: params.keychain })) + .then(function (result) { + // Return result with transactionHex containing the signed PSBT for consistency + return { tx: result.psbt, transactionHex: result.psbt }; + }) .then(callback) .catch(callback); } @@ -1672,6 +1679,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) { } // @ts-expect-error - no implicit this + // Build transaction (legacy format by default, PSBT when usePsbt: true) const transaction = (await this.createTransaction(params)) as any; const fee = transaction.fee; const feeRate = transaction.feeRate; @@ -1704,6 +1712,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) { transaction.feeSingleKeyWIF = params.feeSingleKeyWIF; // @ts-expect-error - no implicit this + // signTransaction auto-detects PSBT vs legacy from transactionHex const result = await this.signTransaction(transaction); return _.extend(result, { fee, diff --git a/modules/sdk-api/test/unit/v1/wallet.ts b/modules/sdk-api/test/unit/v1/wallet.ts index 92ac1be455..84c24425b4 100644 --- a/modules/sdk-api/test/unit/v1/wallet.ts +++ b/modules/sdk-api/test/unit/v1/wallet.ts @@ -202,6 +202,7 @@ describe('Wallet Prototype Methods', function () { address: '', }, opReturns: { 'BitGo p2sh test': 1000 }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '010000000144dea5cb05425f94976e887ccba5686a9a12a3f49710b021508d3d9cd8de16b80100000000ffffffff02e803000000000000116a0f426974476f2070327368207465737422a107000000000017a914d039cb3344294a5a384a5508a006444c420cbc118700000000' @@ -227,6 +228,266 @@ describe('Wallet Prototype Methods', function () { ); }); + it('p2sh PSBT produces same unsigned tx as legacy', async function () { + const p2shAddress = fakeProdWallet.generateAddress({ path: '/0/13', segwit: false }); + const unspent: any = { + addresses: ['2NCEDmmKNNnqKvnWw7pE3RLzuFe5aHHVy1X'], + value: '0.00504422', + value_int: 504422, + txid: 'b816ded89c3d8d5021b01097f4a3129a6a68a5cb7c886e97945f4205cba5de44', + n: 1, + script_pub_key: { + asm: 'OP_HASH160 d039cb3344294a5a384a5508a006444c420cbc11 OP_EQUAL', + hex: 'a914d039cb3344294a5a384a5508a006444c420cbc1187', + }, + req_sigs: 1, + type: 'scripthash', + confirmations: 9, + id: 61330229, + }; + _.extend(unspent, p2shAddress); + unspent.value = unspent.value_int; + unspent.tx_hash = unspent.txid; + unspent.tx_output_n = unspent.n; + unspent.script = unspent.outputScript; + + const txParams = { + changeAddress: p2shAddress.address, + unspents: [unspent], + recipients: {}, + noSplitChange: true, + forceChangeAtEnd: true, + feeRate: 10000, + bitgoFee: { + amount: 0, + address: '', + }, + opReturns: { 'BitGo p2sh test': 1000 }, + }; + + // Create legacy transaction + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + const legacyTx = (await fakeProdWallet.createTransaction({ ...txParams, usePsbt: false })) as any; + + // Create PSBT transaction (explicitly) + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + const psbtTx = (await fakeProdWallet.createTransaction({ ...txParams, usePsbt: true })) as any; + + // Extract unsigned tx from PSBT and compare + const psbt = utxolib.bitgo.createPsbtFromHex(psbtTx.transactionHex, utxolib.networks.bitcoin); + const unsignedTxFromPsbt = psbt.getUnsignedTx().toHex(); + + // The unsigned transaction hex should be identical + legacyTx.transactionHex.should.equal(unsignedTxFromPsbt); + + // Fees should also match + legacyTx.fee.should.equal(psbtTx.fee); + + // Clean up nock mocks to avoid interference with other tests + nock.cleanAll(); + }); + + it('segwit PSBT produces same unsigned tx as legacy', async function () { + const segwitAddress = fakeProdWallet.generateAddress({ path: '/10/13', segwit: true }); + const unspent: any = { + addresses: ['2MxKkH8yB3S9YWmTQRbvmborYQyQnH5petP'], + value: '0.18750000', + value_int: 18750000, + txid: '7d282878a85daee5d46e043827daed57596d75d1aa6e04fd0c09a36f9130881f', + n: 0, + script_pub_key: { + asm: 'OP_HASH160 37b393fce627a0ec634eb543dda1e608e2d1c78a OP_EQUAL', + hex: 'a91437b393fce627a0ec634eb543dda1e608e2d1c78a87', + }, + req_sigs: 1, + type: 'scripthash', + confirmations: 0, + id: 61331617, + }; + _.extend(unspent, segwitAddress); + unspent.value = unspent.value_int; + unspent.tx_hash = unspent.txid; + unspent.tx_output_n = unspent.n; + unspent.script = unspent.outputScript; + + const txParams = { + changeAddress: segwitAddress.address, + unspents: [unspent], + recipients: {}, + noSplitChange: true, + forceChangeAtEnd: true, + feeRate: 10000, + bitgoFee: { + amount: 0, + address: '', + }, + opReturns: { 'BitGo segwit test': 1000 }, + }; + + // Create legacy transaction + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + const legacyTx = (await fakeProdWallet.createTransaction({ ...txParams, usePsbt: false })) as any; + + // Create PSBT transaction (explicitly) + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + const psbtTx = (await fakeProdWallet.createTransaction({ ...txParams, usePsbt: true })) as any; + + // Extract unsigned tx from PSBT and compare + const psbt = utxolib.bitgo.createPsbtFromHex(psbtTx.transactionHex, utxolib.networks.bitcoin); + const unsignedTxFromPsbt = psbt.getUnsignedTx().toHex(); + + // The unsigned transaction hex should be identical + legacyTx.transactionHex.should.equal(unsignedTxFromPsbt); + + // Fees should also match + legacyTx.fee.should.equal(psbtTx.fee); + + // Clean up nock mocks to avoid interference with other tests + nock.cleanAll(); + }); + + it('p2sh with PSBT format', async function () { + const p2shAddress = fakeProdWallet.generateAddress({ path: '/0/13', segwit: false }); + const unspent: any = { + addresses: ['2NCEDmmKNNnqKvnWw7pE3RLzuFe5aHHVy1X'], + value: '0.00504422', + value_int: 504422, + txid: 'b816ded89c3d8d5021b01097f4a3129a6a68a5cb7c886e97945f4205cba5de44', + n: 1, + script_pub_key: { + asm: 'OP_HASH160 d039cb3344294a5a384a5508a006444c420cbc11 OP_EQUAL', + hex: 'a914d039cb3344294a5a384a5508a006444c420cbc1187', + }, + req_sigs: 1, + type: 'scripthash', + confirmations: 9, + id: 61330229, + }; + _.extend(unspent, p2shAddress); + unspent.value = unspent.value_int; + unspent.tx_hash = unspent.txid; + unspent.tx_output_n = unspent.n; + unspent.script = unspent.outputScript; + + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + + const transaction = (await fakeProdWallet.createTransaction({ + changeAddress: p2shAddress.address, + unspents: [unspent], + recipients: {}, + noSplitChange: true, + forceChangeAtEnd: true, + feeRate: 10000, + bitgoFee: { + amount: 0, + address: '', + }, + opReturns: { 'BitGo p2sh test': 1000 }, + usePsbt: true, // Explicitly request PSBT format + })) as any; + + // Verify PSBT format is returned in transactionHex + should.exist(transaction.transactionHex); + should.ok(utxolib.bitgo.isPsbt(transaction.transactionHex)); + should.not.exist(transaction.unspents); + + // Parse and validate the PSBT + const psbt = utxolib.bitgo.createPsbtFromHex(transaction.transactionHex, utxolib.networks.bitcoin); + psbt.data.inputs.length.should.equal(1); + psbt.data.outputs.length.should.equal(2); // OP_RETURN + change + + // Verify input has required PSBT metadata + const input = psbt.data.inputs[0]; + should.exist(input.witnessUtxo); + should.exist(input.redeemScript); + should.exist(input.bip32Derivation); + input.bip32Derivation!.length.should.equal(3); // user, backup, bitgo + + // Verify globalXpubs are set + should.exist(psbt.data.globalMap.globalXpub); + psbt.data.globalMap.globalXpub!.length.should.equal(3); + + // Sign with user key - auto-detects PSBT in transactionHex + const signedResult = (await fakeProdWallet.signTransaction({ + transactionHex: transaction.transactionHex, + keychain: userKeypair, + })) as any; + + should.exist(signedResult.tx); + + // Parse signed PSBT and verify signature + const signedPsbt = utxolib.bitgo.createPsbtFromHex(signedResult.tx, utxolib.networks.bitcoin); + should.ok(signedPsbt.validateSignaturesOfInputHD(0, utxolib.bip32.fromBase58(userKeypair.xpub))); + }); + + it('segwit with PSBT format', async function () { + const segwitAddress = fakeProdWallet.generateAddress({ path: '/10/13', segwit: true }); + const unspent: any = { + addresses: ['2MxKkH8yB3S9YWmTQRbvmborYQyQnH5petP'], + value: '0.18750000', + value_int: 18750000, + txid: '7d282878a85daee5d46e043827daed57596d75d1aa6e04fd0c09a36f9130881f', + n: 0, + script_pub_key: { + asm: 'OP_HASH160 37b393fce627a0ec634eb543dda1e608e2d1c78a OP_EQUAL', + hex: 'a91437b393fce627a0ec634eb543dda1e608e2d1c78a87', + }, + req_sigs: 1, + type: 'scripthash', + confirmations: 0, + id: 61331617, + }; + _.extend(unspent, segwitAddress); + unspent.value = unspent.value_int; + unspent.tx_hash = unspent.txid; + unspent.tx_output_n = unspent.n; + unspent.script = unspent.outputScript; + + nock(bgUrl).post('/api/v1/billing/address').reply(200, { address: '2MswQjkvN6oWYdE7L2brJ5cAAMjPmG59oco' }); + + const transaction = (await fakeProdWallet.createTransaction({ + changeAddress: segwitAddress.address, + unspents: [unspent], + recipients: {}, + noSplitChange: true, + forceChangeAtEnd: true, + feeRate: 10000, + bitgoFee: { + amount: 0, + address: '', + }, + opReturns: { 'BitGo segwit test': 1000 }, + usePsbt: true, // Explicitly request PSBT format + })) as any; + + // Verify PSBT format is returned in transactionHex + should.exist(transaction.transactionHex); + should.ok(utxolib.bitgo.isPsbt(transaction.transactionHex)); + + // Parse and validate the PSBT + const psbt = utxolib.bitgo.createPsbtFromHex(transaction.transactionHex, utxolib.networks.bitcoin); + psbt.data.inputs.length.should.equal(1); + + // Verify input has segwit PSBT metadata + const input = psbt.data.inputs[0]; + should.exist(input.witnessUtxo); + should.exist(input.witnessScript); + should.exist(input.redeemScript); + should.exist(input.bip32Derivation); + + // Sign with user key - auto-detects PSBT in transactionHex + const signedResult = (await fakeProdWallet.signTransaction({ + transactionHex: transaction.transactionHex, + keychain: userKeypair, + })) as any; + + should.exist(signedResult.tx); + + // Parse signed PSBT and verify signature + const signedPsbt = utxolib.bitgo.createPsbtFromHex(signedResult.tx, utxolib.networks.bitcoin); + should.ok(signedPsbt.validateSignaturesOfInputHD(0, utxolib.bip32.fromBase58(userKeypair.xpub))); + }); + it('BCH p2sh', async function () { const p2shAddress = fakeProdWallet.generateAddress({ path: '/0/13', segwit: false }); const unspent: any = { @@ -264,6 +525,7 @@ describe('Wallet Prototype Methods', function () { address: '', }, opReturns: { 'BitGo p2sh test': 1000 }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '010000000144dea5cb05425f94976e887ccba5686a9a12a3f49710b021508d3d9cd8de16b80100000000ffffffff02e803000000000000116a0f426974476f2070327368207465737422a107000000000017a914d039cb3344294a5a384a5508a006444c420cbc118700000000' @@ -326,6 +588,7 @@ describe('Wallet Prototype Methods', function () { address: '', }, opReturns: { 'BitGo segwit test': 1000 }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '01000000011f8830916fa3090cfd046eaad1756d5957edda2738046ed4e5ae5da87828287d0000000000ffffffff02e803000000000000136a11426974476f2073656777697420746573740e0f1e010000000017a91437b393fce627a0ec634eb543dda1e608e2d1c78a8700000000' @@ -394,6 +657,7 @@ describe('Wallet Prototype Methods', function () { amount: 0, address: '', }, + usePsbt: false, }); scope.isDone().should.be.true(); @@ -484,6 +748,7 @@ describe('Wallet Prototype Methods', function () { address: '', }, opReturns: { 'BitGo segwit test': 1000 }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '01000000011f8830916fa3090cfd046eaad1756d5957edda2738046ed4e5ae5da87828287d0000000000ffffffff02e803000000000000136a11426974476f2073656777697420746573740e0f1e010000000017a91437b393fce627a0ec634eb543dda1e608e2d1c78a8700000000' @@ -551,6 +816,7 @@ describe('Wallet Prototype Methods', function () { amount: 81760, address: '2ND7jQR5itjGTbh3DKgbpZWSY9ungDrwcwb', }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '01000000027c75f8b4061212ec4669ef10c7a85a6bd8b677e74ecffef72df1e35b0ace54f60100000000ffffffff249f4f3b89110526e9d71f33679c5303dbf00ef43dac90b867ae2f043f9c40a40000000000ffffffff030084d71700000000206a1e426974476f206d6978656420703273682026207365677769742074657374b08ff9020000000017a9148153e7a35508088b6cf599226792c7de2dbff25287603f01000000000017a914d9f7be47975c036f94228b0bfd70701912758ba98700000000' @@ -632,6 +898,7 @@ describe('Wallet Prototype Methods', function () { amount: 81760, address: '2ND7jQR5itjGTbh3DKgbpZWSY9ungDrwcwb', }, + usePsbt: false, })) as any; transaction.transactionHex.should.equal( '01000000027c75f8b4061212ec4669ef10c7a85a6bd8b677e74ecffef72df1e35b0ace54f60100000000ffffffff249f4f3b89110526e9d71f33679c5303dbf00ef43dac90b867ae2f043f9c40a40000000000ffffffff04e09304000000000022002047044b55dab740b0c302853b27b8e3f50a79023aca367c94ee006f11bb79368f0084d71700000000206a1e426974476f206d69786564207032736820262073656777697420746573747cfaf4020000000017a9148153e7a35508088b6cf599226792c7de2dbff25287603f01000000000017a914d9f7be47975c036f94228b0bfd70701912758ba98700000000' From ad01a9227ceba834c425dd3c1e5b2e738fc780aa Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Mon, 22 Dec 2025 13:02:46 -0800 Subject: [PATCH 2/3] feat: add PSBT support with 10% rollout for v1 transactions Add PSBT transaction building capability for legacy v1 wallets with gradual rollout: - 10% of mainnet transactions use PSBT format - 100% of testnet transactions use PSBT format - Explicit usePsbt: true/false always takes precedence TICKET: BTC-2894 --- modules/sdk-api/src/v1/wallet.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/modules/sdk-api/src/v1/wallet.ts b/modules/sdk-api/src/v1/wallet.ts index 8b07ba9176..1b47890c4b 100644 --- a/modules/sdk-api/src/v1/wallet.ts +++ b/modules/sdk-api/src/v1/wallet.ts @@ -31,6 +31,25 @@ import { tryPromise } from '../util'; const TransactionBuilder = require('./transactionBuilder'); const PendingApproval = require('./pendingapproval'); +// PSBT rollout: 10% on mainnet, 100% on testnet +const V1_PSBT_ROLLOUT_PERCENT = 10; + +function shouldUsePsbt(bitgo: any, explicitUsePsbt?: boolean): boolean { + // Explicit setting always wins + if (explicitUsePsbt !== undefined) { + return explicitUsePsbt; + } + + // Testnet: always PSBT + const network = common.Environments[bitgo.getEnv()]?.network; + if (network !== 'bitcoin') { + return true; + } + + // Mainnet: 10% rollout + return Math.random() * 100 < V1_PSBT_ROLLOUT_PERCENT; +} + const { getExternalChainCode, getInternalChainCode, isChainCode, scriptTypeForChain } = utxolib.bitgo; const request = require('superagent'); @@ -894,6 +913,9 @@ Wallet.prototype.createTransaction = function (params, callback) { params.validate = params.validate !== undefined ? params.validate : this.bitgo.getValidate(); params.wallet = this; + // Apply PSBT rollout logic (respects explicit usePsbt if set) + params.usePsbt = shouldUsePsbt(this.bitgo, params.usePsbt); + return TransactionBuilder.createTransaction(params).then(callback).catch(callback); }; @@ -1609,6 +1631,7 @@ Wallet.prototype.accelerateTransaction = function accelerateTransaction(params, const changeAddress = await this.createAddress({ chain: changeChain }); // create the child tx and broadcast + // Use legacy format - PSBT rollout applies to user-facing createTransaction only // @ts-expect-error - no implicit this const tx = await this.createAndSignTransaction({ unspents: unspentsToUse, @@ -1625,6 +1648,7 @@ Wallet.prototype.accelerateTransaction = function accelerateTransaction(params, }, xprv: params.xprv, walletPassphrase: params.walletPassphrase, + usePsbt: false, }); // child fee rate must be in sat/kB, so we need to convert From 27d650f04f9ebbb6288fce602928e49206868327 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Mon, 22 Dec 2025 13:34:31 -0800 Subject: [PATCH 3/3] feat: implement PSBT fallback in wallet transactions Add fallback mechanism for PSBT transactions in the SDK. When PSBT is requested but fails, automatically retry with legacy transaction format and record the error details for reporting. Co-authored-by: llm-git Ticket: BTC-2894 TICKET: BTC-2894 --- modules/sdk-api/src/v1/wallet.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/modules/sdk-api/src/v1/wallet.ts b/modules/sdk-api/src/v1/wallet.ts index 1b47890c4b..6e6bcba4a9 100644 --- a/modules/sdk-api/src/v1/wallet.ts +++ b/modules/sdk-api/src/v1/wallet.ts @@ -914,8 +914,33 @@ Wallet.prototype.createTransaction = function (params, callback) { params.wallet = this; // Apply PSBT rollout logic (respects explicit usePsbt if set) - params.usePsbt = shouldUsePsbt(this.bitgo, params.usePsbt); + const wantsPsbt = shouldUsePsbt(this.bitgo, params.usePsbt); + + if (wantsPsbt) { + // Try PSBT first, fall back to legacy on failure + return TransactionBuilder.createTransaction({ ...params, usePsbt: true }) + .then((result: any) => { + result.psbtAttempt = { success: true }; + return result; + }) + .catch((psbtError: Error) => { + // PSBT failed - fall back to legacy and capture error for backend reporting + console.warn('PSBT transaction failed, falling back to legacy:', psbtError.message); + return TransactionBuilder.createTransaction({ ...params, usePsbt: false }).then((result: any) => { + result.psbtAttempt = { + success: false, + error: psbtError.message, + stack: psbtError.stack?.split('\n').slice(0, 5).join('\n'), // First 5 lines + }; + return result; + }); + }) + .then(callback) + .catch(callback); + } + // Legacy path + params.usePsbt = false; return TransactionBuilder.createTransaction(params).then(callback).catch(callback); }; @@ -1746,6 +1771,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) { travelInfos, estimatedSize, unspents, + psbtAttempt: transaction.psbtAttempt, // Propagate PSBT attempt info for error reporting }); } .call(this)