Skip to content

Commit b38cd7c

Browse files
Merge pull request #8161 from BitGo/BTC-2768.disable-legacy-txformat-for-wp-request
feat(abstract-utxo): disable legacy tx format for wallet-platform requests
2 parents e0f7741 + 2c9f8d2 commit b38cd7c

File tree

5 files changed

+179
-29
lines changed

5 files changed

+179
-29
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ import {
7979
getFullNameFromCoinName,
8080
getMainnetCoinName,
8181
getNetworkFromCoinName,
82-
isTestnetCoin,
8382
isUtxoCoinNameMainnet,
8483
UtxoCoinName,
8584
UtxoCoinNameMainnet,
@@ -333,6 +332,11 @@ type UtxoBaseSignTransactionOptions<TNumber extends number | bigint = number> =
333332
* transaction (nonWitnessUtxo)
334333
*/
335334
allowNonSegwitSigningWithoutPrevTx?: boolean;
335+
/**
336+
* When true, the signed transaction will be converted from PSBT to legacy format before returning.
337+
* Set automatically by presignTransaction() when the caller explicitly requested txFormat: 'legacy'.
338+
*/
339+
returnLegacyFormat?: boolean;
336340
wallet?: UtxoWallet;
337341
};
338342

@@ -991,24 +995,20 @@ export abstract class AbstractUtxoCoin
991995
* @param requestedFormat - Optional explicitly requested format
992996
* @returns The transaction format to use, or undefined if no default applies
993997
*/
994-
getDefaultTxFormat(wallet: Wallet, requestedFormat?: TxFormat): TxFormat | undefined {
995-
// If format is explicitly requested, use it
996-
if (requestedFormat !== undefined) {
997-
if (isTestnetCoin(this.name) && requestedFormat === 'legacy') {
998-
throw new ErrorDeprecatedTxFormat(requestedFormat);
999-
}
1000-
1001-
return requestedFormat;
998+
getDefaultTxFormat(wallet: Wallet, requestedFormat?: TxFormat): TxFormat {
999+
if (requestedFormat === 'legacy') {
1000+
return 'psbt-lite';
10021001
}
1003-
1004-
return 'psbt-lite';
1002+
return requestedFormat ?? 'psbt-lite';
10051003
}
10061004

10071005
async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }): Promise<{
10081006
txFormat?: TxFormat;
10091007
changeAddressType?: ScriptType2Of3[] | ScriptType2Of3;
1008+
allowedInputScriptTypes?: ScriptType2Of3[];
10101009
}> {
1011-
const txFormat = this.getDefaultTxFormat(buildParams.wallet, buildParams.txFormat as TxFormat | undefined);
1010+
const requestedFormat = buildParams.txFormat as TxFormat | undefined;
1011+
const txFormat = this.getDefaultTxFormat(buildParams.wallet, requestedFormat);
10121012
let changeAddressType = buildParams.changeAddressType as ScriptType2Of3[] | ScriptType2Of3 | undefined;
10131013

10141014
// if the addressType is not specified, we need to default to p2trMusig2 for testnet hot wallets for staged rollout of p2trMusig2
@@ -1021,9 +1021,26 @@ export abstract class AbstractUtxoCoin
10211021
changeAddressType = ['p2trMusig2', 'p2wsh', 'p2shP2wsh', 'p2sh', 'p2tr'];
10221022
}
10231023

1024+
// getHalfSignedLegacyFormat() only supports p2ms-based types (p2sh, p2shP2wsh, p2wsh).
1025+
// Filter change outputs and restrict input selection to these types.
1026+
const legacyCompatibleTypes: ScriptType2Of3[] = ['p2sh', 'p2shP2wsh', 'p2wsh'];
1027+
let allowedInputScriptTypes: ScriptType2Of3[] | undefined;
1028+
1029+
if (requestedFormat === 'legacy') {
1030+
allowedInputScriptTypes = legacyCompatibleTypes;
1031+
if (Array.isArray(changeAddressType)) {
1032+
changeAddressType = changeAddressType.filter((t): t is ScriptType2Of3 =>
1033+
legacyCompatibleTypes.includes(t as ScriptType2Of3)
1034+
);
1035+
} else if (changeAddressType !== undefined && !legacyCompatibleTypes.includes(changeAddressType)) {
1036+
changeAddressType = legacyCompatibleTypes;
1037+
}
1038+
}
1039+
10241040
return {
10251041
txFormat,
10261042
changeAddressType,
1043+
allowedInputScriptTypes,
10271044
};
10281045
}
10291046

@@ -1035,6 +1052,9 @@ export abstract class AbstractUtxoCoin
10351052
if (params.walletData && isUtxoWalletData(params.walletData) && isDescriptorWalletData(params.walletData)) {
10361053
return params;
10371054
}
1055+
1056+
const returnLegacyFormat = (params as Record<string, unknown>).txFormat === 'legacy';
1057+
10381058
// In the case that we have a 'psbt-lite' transaction format, we want to indicate in signing to not fail
10391059
const txHex = (params.txHex ?? params.txPrebuild?.txHex) as string;
10401060
if (
@@ -1043,9 +1063,9 @@ export abstract class AbstractUtxoCoin
10431063
utxolib.bitgo.isPsbtLite(utxolib.bitgo.createPsbtFromHex(txHex, this.network)) &&
10441064
params.allowNonSegwitSigningWithoutPrevTx === undefined
10451065
) {
1046-
return { ...params, allowNonSegwitSigningWithoutPrevTx: true };
1066+
return { ...params, allowNonSegwitSigningWithoutPrevTx: true, returnLegacyFormat };
10471067
}
1048-
return params;
1068+
return { ...params, returnLegacyFormat };
10491069
}
10501070

10511071
async supplementGenerateWallet(

modules/abstract-utxo/src/transaction/signTransaction.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import _ from 'lodash';
22
import { BitGoBase } from '@bitgo/sdk-core';
3-
import { BIP32 } from '@bitgo/wasm-utxo';
3+
import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo';
44
import buildDebug from 'debug';
55

66
import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin';
@@ -10,7 +10,7 @@ import { isUtxoLibPsbt, toWasmPsbt } from '../wasmUtil';
1010

1111
import * as fixedScript from './fixedScript';
1212
import * as descriptor from './descriptor';
13-
import { encodeTransaction } from './decode';
13+
import { decodePsbtWith, encodeTransaction } from './decode';
1414

1515
const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction');
1616

@@ -43,7 +43,13 @@ export async function signTransaction<TNumber extends number | bigint>(
4343
throw new Error('missing txPrebuild parameter');
4444
}
4545

46-
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
46+
let tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
47+
48+
// When returnLegacyFormat is set, ensure we use wasm-utxo's BitGoPsbt so
49+
// getHalfSignedLegacyFormat() is available after signing.
50+
if (params.returnLegacyFormat && isUtxoLibPsbt(tx)) {
51+
tx = decodePsbtWith(tx.toBuffer(), coin.name, 'wasm-utxo');
52+
}
4753

4854
const signerKeychain = getSignerKeychain(params.prv);
4955

@@ -73,6 +79,12 @@ export async function signTransaction<TNumber extends number | bigint>(
7379
pubs: params.pubs,
7480
cosignerPub: params.cosignerPub,
7581
});
82+
83+
// Convert half-signed PSBT to legacy format when the caller explicitly requested txFormat: 'legacy'
84+
if (params.returnLegacyFormat && signedTx instanceof fixedScriptWallet.BitGoPsbt) {
85+
return { txHex: Buffer.from(signedTx.getHalfSignedLegacyFormat()).toString('hex') };
86+
}
87+
7688
const buffer = Buffer.isBuffer(signedTx) ? signedTx : encodeTransaction(signedTx);
7789
return { txHex: buffer.toString('hex') };
7890
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as assert from 'assert';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { hasPsbtMagic } from '@bitgo/wasm-utxo';
5+
import nock = require('nock');
6+
import { common, HalfSignedUtxoTransaction } from '@bitgo/sdk-core';
7+
import { getSeed } from '@bitgo/sdk-test';
8+
9+
import {
10+
defaultBitGo,
11+
encryptKeychain,
12+
getDefaultWalletKeys,
13+
getMinUtxoCoins,
14+
getUtxoWallet,
15+
keychainsBase58,
16+
getScriptTypes,
17+
} from './util';
18+
19+
const walletPassphrase = 'gabagool';
20+
21+
const rootWalletKeys = getDefaultWalletKeys();
22+
const keyDocumentObjects = rootWalletKeys.triple.map((bip32, keyIdx) => ({
23+
id: getSeed(keychainsBase58[keyIdx].pub).toString('hex'),
24+
pub: bip32.neutered().toBase58(),
25+
source: ['user', 'backup', 'bitgo'][keyIdx],
26+
encryptedPrv: encryptKeychain(walletPassphrase, keychainsBase58[keyIdx]),
27+
coinSpecific: {},
28+
}));
29+
30+
// Test that txFormat: 'legacy' converts the signed PSBT back to legacy (non-PSBT) format.
31+
// Uses BTC with legacy-compatible script types (no taproot).
32+
describe('prebuildAndSign-returnLegacyFormat', function () {
33+
const coin = getMinUtxoCoins().find((c) => c.getChain() === 'btc')!;
34+
const inputScripts = getScriptTypes(coin, 'legacy');
35+
const wallet = getUtxoWallet(coin, {
36+
coinSpecific: { addressVersion: 'base58' },
37+
keys: keyDocumentObjects.map((k) => k.id),
38+
id: 'walletId',
39+
});
40+
const bgUrl = common.Environments[defaultBitGo.getEnv()].uri;
41+
let prebuild: utxolib.bitgo.UtxoPsbt;
42+
let recipient: { address: string; amount: string };
43+
const fee = BigInt(10000);
44+
45+
before(function () {
46+
const outputAmount = BigInt(inputScripts.length) * BigInt(1e8) - fee;
47+
const outputScriptType: utxolib.bitgo.outputScripts.ScriptType = 'p2sh';
48+
const outputChain = utxolib.bitgo.getExternalChainCode(outputScriptType);
49+
const outputAddress = utxolib.bitgo.getWalletAddress(rootWalletKeys, outputChain, 0, coin.network);
50+
recipient = { address: outputAddress, amount: outputAmount.toString() };
51+
prebuild = utxolib.testutil.constructPsbt(
52+
inputScripts.map((s) => ({ scriptType: s, value: BigInt(1e8) })),
53+
[{ scriptType: outputScriptType, value: outputAmount }],
54+
coin.network,
55+
rootWalletKeys,
56+
'unsigned'
57+
);
58+
utxolib.bitgo.addXpubsToPsbt(prebuild, rootWalletKeys);
59+
});
60+
61+
afterEach(nock.cleanAll);
62+
63+
it('should build with PSBT internally but return legacy format to the caller', async function () {
64+
// WP receives a PSBT build request (getExtraPrebuildParams maps 'legacy' -> 'psbt-lite')
65+
const nocks: nock.Scope[] = [];
66+
nocks.push(
67+
nock(bgUrl)
68+
.post(`/api/v2/${coin.getChain()}/wallet/${wallet.id()}/tx/build`)
69+
.reply(200, { txHex: prebuild.toHex(), txInfo: {} })
70+
);
71+
nocks.push(nock(bgUrl).get(`/api/v2/${coin.getChain()}/public/block/latest`).reply(200, { height: 1000 }));
72+
keyDocumentObjects.forEach((keyDocument) => {
73+
nocks.push(nock(bgUrl).get(`/api/v2/${coin.getChain()}/key/${keyDocument.id}`).times(3).reply(200, keyDocument));
74+
});
75+
76+
// The prebuild from WP is a PSBT
77+
assert.strictEqual(hasPsbtMagic(Buffer.from(prebuild.toHex(), 'hex')), true);
78+
79+
// The caller requests txFormat: 'legacy'
80+
const res = (await wallet.prebuildAndSignTransaction({
81+
recipients: [recipient],
82+
walletPassphrase,
83+
txFormat: 'legacy',
84+
})) as HalfSignedUtxoTransaction;
85+
86+
nocks.forEach((n) => assert.ok(n.isDone()));
87+
88+
// The signed result is converted back to legacy (non-PSBT) format
89+
assert.strictEqual(hasPsbtMagic(Buffer.from(res.txHex, 'hex')), false);
90+
});
91+
});

modules/abstract-utxo/test/unit/txFormat.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as assert from 'assert';
22

33
import { Wallet } from '@bitgo/sdk-core';
44

5-
import { AbstractUtxoCoin, ErrorDeprecatedTxFormat, TxFormat } from '../../src';
5+
import { AbstractUtxoCoin, TxFormat } from '../../src';
66
import { isMainnetCoin, isTestnetCoin } from '../../src/names';
77

88
import { utxoCoins, defaultBitGo } from './util';
@@ -119,9 +119,8 @@ describe('txFormat', function () {
119119

120120
// Test explicitly requested formats
121121
runTest({
122-
description: 'should respect explicitly requested legacy format on mainnet',
123-
coinFilter: (coin) => isMainnetCoin(coin.name),
124-
expectedTxFormat: 'legacy',
122+
description: 'should map explicitly requested legacy format to psbt-lite',
123+
expectedTxFormat: 'psbt-lite',
125124
requestedTxFormat: 'legacy',
126125
});
127126

@@ -136,19 +135,45 @@ describe('txFormat', function () {
136135
expectedTxFormat: 'psbt-lite',
137136
requestedTxFormat: 'psbt-lite',
138137
});
138+
});
139+
140+
describe('getExtraPrebuildParams with legacy format', function () {
141+
const legacyCompatibleTypes = ['p2sh', 'p2shP2wsh', 'p2wsh'];
139142

140-
// Test that legacy format is prohibited on testnet
141-
it('should throw ErrorDeprecatedTxFormat when legacy format is requested on testnet', function () {
143+
it('should filter changeAddressType to legacy-compatible types for hot wallets', async function () {
142144
for (const coin of utxoCoins) {
143-
if (!isTestnetCoin(coin.name)) {
144-
continue;
145+
const wallet = createMockWallet(coin, { type: 'hot' });
146+
const result = await coin.getExtraPrebuildParams({ txFormat: 'legacy', wallet } as any);
147+
assert.ok(Array.isArray(result.changeAddressType), `${coin.getChain()}: changeAddressType should be an array`);
148+
for (const t of result.changeAddressType as string[]) {
149+
assert.ok(
150+
legacyCompatibleTypes.includes(t),
151+
`${coin.getChain()}: changeAddressType contains ${t} which is not legacy-compatible`
152+
);
145153
}
154+
}
155+
});
146156

157+
it('should set allowedInputScriptTypes to legacy-compatible types', async function () {
158+
for (const coin of utxoCoins) {
159+
const wallet = createMockWallet(coin, { type: 'hot' });
160+
const result = await coin.getExtraPrebuildParams({ txFormat: 'legacy', wallet } as any);
161+
assert.deepStrictEqual(
162+
result.allowedInputScriptTypes,
163+
legacyCompatibleTypes,
164+
`${coin.getChain()}: allowedInputScriptTypes should be legacy-compatible`
165+
);
166+
}
167+
});
168+
169+
it('should not set allowedInputScriptTypes when txFormat is not legacy', async function () {
170+
for (const coin of utxoCoins) {
147171
const wallet = createMockWallet(coin, { type: 'hot' });
148-
assert.throws(
149-
() => getTxFormat(coin, wallet, 'legacy'),
150-
ErrorDeprecatedTxFormat,
151-
`Expected ErrorDeprecatedTxFormat for ${coin.getChain()}`
172+
const result = await coin.getExtraPrebuildParams({ wallet } as any);
173+
assert.strictEqual(
174+
result.allowedInputScriptTypes,
175+
undefined,
176+
`${coin.getChain()}: allowedInputScriptTypes should be undefined for default format`
152177
);
153178
}
154179
});

modules/sdk-core/src/bitgo/wallet/BuildParams.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const BuildParamsUTXO = t.partial({
3131
enforceMinConfirmsForChange: t.unknown,
3232
/* legacy or psbt */
3333
txFormat: t.unknown,
34+
/* restrict which input script types WP may select (e.g. for legacy format compatibility) */
35+
allowedInputScriptTypes: t.unknown,
3436
maxChangeOutputs: t.unknown,
3537
/* rbf */
3638
rbfTxIds: t.array(t.string),

0 commit comments

Comments
 (0)