Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion modules/sdk-api/src/v1/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,46 @@ import { common, getAddressP2PKH, getNetwork, sanitizeLegacyPath } from '@bitgo/
import { verifyAddress } from './verifyAddress';
import { tryPromise } from '../util';

type Triple<T> = [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<utxolib.BIP32Interface>;

// 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<string>;

return new utxolib.bitgo.RootWalletKeys(bip32Keys, derivationPrefixes);
}

interface BaseOutput {
amount: number;
travelInfo?: any;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<bigint> = {
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']);
Expand Down
63 changes: 61 additions & 2 deletions modules/sdk-api/src/v1/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -894,6 +913,34 @@ 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)
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);
};

Expand All @@ -913,8 +960,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);
}
Expand Down Expand Up @@ -1602,6 +1656,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,
Expand All @@ -1618,6 +1673,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
Expand Down Expand Up @@ -1672,6 +1728,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;
Expand Down Expand Up @@ -1704,6 +1761,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,
Expand All @@ -1713,6 +1771,7 @@ Wallet.prototype.createAndSignTransaction = function (params, callback) {
travelInfos,
estimatedSize,
unspents,
psbtAttempt: transaction.psbtAttempt, // Propagate PSBT attempt info for error reporting
});
}
.call(this)
Expand Down
Loading