Skip to content

Commit cc49225

Browse files
authored
Merge pull request #7792 from BitGo/BTC-2894-v1.psbt
Btc 2894 v1.psbt
2 parents 20af49e + 1c1bde1 commit cc49225

File tree

3 files changed

+439
-3
lines changed

3 files changed

+439
-3
lines changed

modules/sdk-api/src/v1/transactionBuilder.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,46 @@ import { common, getAddressP2PKH, getNetwork, sanitizeLegacyPath } from '@bitgo/
2121
import { verifyAddress } from './verifyAddress';
2222
import { tryPromise } from '../util';
2323

24+
type Triple<T> = [T, T, T];
25+
26+
interface V1Keychain {
27+
xpub: string;
28+
path?: string;
29+
walletSubPath?: string;
30+
}
31+
32+
/**
33+
* Parse chainPath like "/0/13" into { chain: 0, index: 13 }
34+
*/
35+
function parseChainPath(chainPath: string): { chain: number; index: number } {
36+
const parts = chainPath.split('/').filter((p) => p.length > 0);
37+
if (parts.length !== 2) {
38+
throw new Error(`Invalid chainPath: ${chainPath}`);
39+
}
40+
return { chain: parseInt(parts[0], 10), index: parseInt(parts[1], 10) };
41+
}
42+
43+
/**
44+
* Create RootWalletKeys from v1 wallet keychains.
45+
* v1 keychains have a structure like { xpub, path: 'm', walletSubPath: '/0/0' }
46+
*/
47+
function createRootWalletKeysFromV1Keychains(keychains: V1Keychain[]): utxolib.bitgo.RootWalletKeys {
48+
if (keychains.length !== 3) {
49+
throw new Error('Expected 3 keychains for v1 wallet');
50+
}
51+
52+
const bip32Keys = keychains.map((k) => bip32.fromBase58(k.xpub)) as Triple<utxolib.BIP32Interface>;
53+
54+
// v1 wallets typically have walletSubPath like '/0/0' which we convert to derivation prefixes like '0/0'
55+
const derivationPrefixes = keychains.map((k) => {
56+
const walletSubPath = k.walletSubPath || '/0/0';
57+
// Remove leading slash if present
58+
return walletSubPath.startsWith('/') ? walletSubPath.slice(1) : walletSubPath;
59+
}) as Triple<string>;
60+
61+
return new utxolib.bitgo.RootWalletKeys(bip32Keys, derivationPrefixes);
62+
}
63+
2464
interface BaseOutput {
2565
amount: number;
2666
travelInfo?: any;
@@ -235,6 +275,9 @@ exports.createTransaction = function (params) {
235275

236276
let changeOutputs: Output[] = [];
237277

278+
// All outputs for the transaction (recipients, OP_RETURNs, change, fees)
279+
let outputs: Output[] = [];
280+
238281
let containsUncompressedPublicKeys = false;
239282

240283
// The transaction.
@@ -603,7 +646,8 @@ exports.createTransaction = function (params) {
603646
throw new Error('transaction too large: estimated size ' + minerFeeInfo.size + ' bytes');
604647
}
605648

606-
const outputs: Output[] = [];
649+
// Reset outputs array (use outer scope variable)
650+
outputs = [];
607651

608652
recipients.forEach(function (recipient) {
609653
let script;
@@ -739,8 +783,75 @@ exports.createTransaction = function (params) {
739783
});
740784
};
741785

786+
// Build PSBT with all signing metadata embedded
787+
const buildPsbt = function (): utxolib.bitgo.UtxoPsbt {
788+
const psbt = utxolib.bitgo.createPsbtForNetwork({ network });
789+
790+
// Need wallet keychains for PSBT metadata
791+
const walletKeychains = params.wallet.keychains;
792+
if (!walletKeychains || walletKeychains.length !== 3) {
793+
throw new Error('Wallet keychains required for PSBT format');
794+
}
795+
796+
const rootWalletKeys = createRootWalletKeysFromV1Keychains(walletKeychains);
797+
utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys);
798+
799+
// Add multisig inputs with PSBT metadata
800+
for (const unspent of unspents) {
801+
const { chain, index } = parseChainPath(unspent.chainPath) as { chain: utxolib.bitgo.ChainCode; index: number };
802+
803+
const walletUnspent: utxolib.bitgo.WalletUnspent<bigint> = {
804+
id: `${unspent.tx_hash}:${unspent.tx_output_n}`,
805+
address: unspent.address,
806+
chain,
807+
index,
808+
value: BigInt(unspent.value),
809+
};
810+
811+
utxolib.bitgo.addWalletUnspentToPsbt(psbt, walletUnspent, rootWalletKeys, 'user', 'backup', {
812+
skipNonWitnessUtxo: true,
813+
});
814+
}
815+
816+
// Fee single key inputs are not supported with PSBT yet - throw to trigger fallback to legacy
817+
if (feeSingleKeyUnspentsUsed.length > 0) {
818+
throw new Error('PSBT does not support feeSingleKey inputs - use legacy transaction format');
819+
}
820+
821+
// Add outputs (recipients, change, fees, OP_RETURNs) - already calculated in outputs array
822+
for (const output of outputs) {
823+
psbt.addOutput({
824+
script: (output as ScriptOutput).script,
825+
value: BigInt(output.amount),
826+
});
827+
}
828+
829+
return psbt;
830+
};
831+
742832
// Serialize the transaction, returning what is needed to sign it
743833
const serialize = function () {
834+
// Build and return PSBT format when usePsbt is explicitly true
835+
// PSBT hex is returned in transactionHex field for backward compatibility
836+
// Use utxolib.bitgo.isPsbt() to detect if transactionHex contains PSBT or legacy tx
837+
if (params.usePsbt === true) {
838+
const psbt = buildPsbt();
839+
return {
840+
transactionHex: psbt.toHex(),
841+
fee: fee,
842+
changeAddresses: changeOutputs.map(function (co) {
843+
return _.pick(co, ['address', 'path', 'amount']);
844+
}),
845+
walletId: params.wallet.id(),
846+
feeRate: feeRate,
847+
instant: params.instant,
848+
bitgoFee: bitgoFeeInfo,
849+
estimatedSize: minerFeeInfo.size,
850+
travelInfos: travelInfos,
851+
};
852+
}
853+
854+
// Legacy format: return transactionHex with separate unspents array
744855
// only need to return the unspents that were used and just the chainPath, redeemScript, and instant flag
745856
const pickedUnspents: any = _.map(unspents, function (unspent) {
746857
return _.pick(unspent, ['chainPath', 'redeemScript', 'instant', 'witnessScript', 'script', 'value']);

modules/sdk-api/src/v1/wallet.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ import { tryPromise } from '../util';
3131
const TransactionBuilder = require('./transactionBuilder');
3232
const PendingApproval = require('./pendingapproval');
3333

34+
// PSBT rollout: 0% on mainnet, 100% on testnet
35+
const V1_PSBT_ROLLOUT_PERCENT = 0;
36+
37+
function shouldUsePsbt(bitgo: any, explicitUsePsbt?: boolean): boolean {
38+
// Explicit setting always wins
39+
if (explicitUsePsbt !== undefined) {
40+
return explicitUsePsbt;
41+
}
42+
43+
// Testnet: always PSBT
44+
const network = common.Environments[bitgo.getEnv()]?.network;
45+
if (network !== 'bitcoin') {
46+
return true;
47+
}
48+
49+
// Mainnet: 10% rollout
50+
return Math.random() * 100 < V1_PSBT_ROLLOUT_PERCENT;
51+
}
52+
3453
const { getExternalChainCode, getInternalChainCode, isChainCode, scriptTypeForChain } = utxolib.bitgo;
3554
const request = require('superagent');
3655

@@ -894,6 +913,33 @@ Wallet.prototype.createTransaction = function (params, callback) {
894913
params.validate = params.validate !== undefined ? params.validate : this.bitgo.getValidate();
895914
params.wallet = this;
896915

916+
// Apply PSBT rollout logic (respects explicit usePsbt if set)
917+
const wantsPsbt = shouldUsePsbt(this.bitgo, params.usePsbt);
918+
919+
if (wantsPsbt) {
920+
// Try PSBT first, fall back to legacy on failure
921+
return TransactionBuilder.createTransaction({ ...params, usePsbt: true })
922+
.then((result: any) => {
923+
result.psbtAttempt = { success: true };
924+
return result;
925+
})
926+
.catch((psbtError: Error) => {
927+
// PSBT failed - fall back to legacy and capture error for backend reporting
928+
console.warn('PSBT transaction failed, falling back to legacy');
929+
return TransactionBuilder.createTransaction({ ...params, usePsbt: false }).then((result: any) => {
930+
result.psbtAttempt = {
931+
success: false,
932+
stack: psbtError.stack?.split('\n').slice(0, 5).join('\n'), // First 5 lines only
933+
};
934+
return result;
935+
});
936+
})
937+
.then(callback)
938+
.catch(callback);
939+
}
940+
941+
// Legacy path
942+
params.usePsbt = false;
897943
return TransactionBuilder.createTransaction(params).then(callback).catch(callback);
898944
};
899945

@@ -913,8 +959,15 @@ Wallet.prototype.createTransaction = function (params, callback) {
913959
Wallet.prototype.signTransaction = function (params, callback) {
914960
params = _.extend({}, params);
915961

916-
if (params.psbt) {
917-
return tryPromise(() => signPsbtRequest(params))
962+
// Route to PSBT signing if params.psbt exists OR if transactionHex contains a PSBT
963+
// Use utxolib.bitgo.isPsbt() to auto-detect PSBT format in transactionHex
964+
if (params.psbt || (params.transactionHex && utxolib.bitgo.isPsbt(params.transactionHex))) {
965+
const psbtHex = params.psbt || params.transactionHex;
966+
return tryPromise(() => signPsbtRequest({ psbt: psbtHex, keychain: params.keychain }))
967+
.then(function (result) {
968+
// Return result with transactionHex containing the signed PSBT for consistency
969+
return { tx: result.psbt, transactionHex: result.psbt };
970+
})
918971
.then(callback)
919972
.catch(callback);
920973
}
@@ -1602,6 +1655,7 @@ Wallet.prototype.accelerateTransaction = function accelerateTransaction(params,
16021655
const changeAddress = await this.createAddress({ chain: changeChain });
16031656

16041657
// create the child tx and broadcast
1658+
// Use legacy format - PSBT rollout applies to user-facing createTransaction only
16051659
// @ts-expect-error - no implicit this
16061660
const tx = await this.createAndSignTransaction({
16071661
unspents: unspentsToUse,
@@ -1618,6 +1672,7 @@ Wallet.prototype.accelerateTransaction = function accelerateTransaction(params,
16181672
},
16191673
xprv: params.xprv,
16201674
walletPassphrase: params.walletPassphrase,
1675+
usePsbt: false,
16211676
});
16221677

16231678
// child fee rate must be in sat/kB, so we need to convert
@@ -1672,6 +1727,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) {
16721727
}
16731728

16741729
// @ts-expect-error - no implicit this
1730+
// Build transaction (legacy format by default, PSBT when usePsbt: true)
16751731
const transaction = (await this.createTransaction(params)) as any;
16761732
const fee = transaction.fee;
16771733
const feeRate = transaction.feeRate;
@@ -1704,6 +1760,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) {
17041760

17051761
transaction.feeSingleKeyWIF = params.feeSingleKeyWIF;
17061762
// @ts-expect-error - no implicit this
1763+
// signTransaction auto-detects PSBT vs legacy from transactionHex
17071764
const result = await this.signTransaction(transaction);
17081765
return _.extend(result, {
17091766
fee,
@@ -1713,6 +1770,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) {
17131770
travelInfos,
17141771
estimatedSize,
17151772
unspents,
1773+
psbtAttempt: transaction.psbtAttempt, // Propagate PSBT attempt info for error reporting
17161774
});
17171775
}
17181776
.call(this)

0 commit comments

Comments
 (0)