Skip to content

Commit 87c6042

Browse files
Merge pull request #7558 from BitGo/BTC-2732.parseTxFixes
feat(abstract-utxo): improve transaction parsing and handling
2 parents b77ee99 + e0058a0 commit 87c6042

File tree

55 files changed

+558
-438
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+558
-438
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,12 +561,13 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
561561

562562
toCanonicalTransactionRecipient(output: { valueString: string; address?: string }): {
563563
amount: bigint;
564-
address?: string;
564+
address: string;
565565
} {
566566
const amount = BigInt(output.valueString);
567567
assertValidTransactionRecipient({ amount, address: output.address });
568-
if (!output.address) {
569-
return { amount };
568+
assert(output.address, 'address is required');
569+
if (isScriptRecipient(output.address)) {
570+
return { amount, address: output.address };
570571
}
571572
return { amount, address: this.canonicalAddress(output.address) };
572573
}

modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts

Lines changed: 73 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'assert';
22

33
import _ from 'lodash';
4-
import { Triple, VerificationOptions, Wallet } from '@bitgo/sdk-core';
4+
import { ITransactionRecipient, Triple, VerificationOptions, Wallet } from '@bitgo/sdk-core';
55
import * as utxolib from '@bitgo/utxo-lib';
66

77
import type { AbstractUtxoCoin, ParseTransactionOptions } from '../../abstractUtxoCoin';
@@ -17,12 +17,74 @@ export type ComparableOutputWithExternal<TValue> = ComparableOutput<TValue> & {
1717
external: boolean | undefined;
1818
};
1919

20+
async function parseRbfTransaction<TNumber extends bigint | number>(
21+
coin: AbstractUtxoCoin,
22+
params: ParseTransactionOptions<TNumber>
23+
): Promise<ParsedTransaction<TNumber>> {
24+
const { txParams, wallet } = params;
25+
26+
assert(txParams.rbfTxIds);
27+
assert(txParams.rbfTxIds.length === 1);
28+
29+
const txToBeReplaced = await wallet.getTransaction({ txHash: txParams.rbfTxIds[0], includeRbf: true });
30+
const recipients = txToBeReplaced.outputs.flatMap(
31+
(output: { valueString: string; address?: string; wallet?: string }) => {
32+
// For self-sends, the walletId will be the same as the wallet's id
33+
if (output.wallet === wallet.id()) {
34+
return [];
35+
}
36+
return [coin.toCanonicalTransactionRecipient(output)];
37+
}
38+
);
39+
40+
// Recurse into parseTransaction with the derived recipients and without rbfTxIds
41+
return parseTransaction(coin, {
42+
...params,
43+
txParams: {
44+
...txParams,
45+
recipients,
46+
rbfTxIds: undefined,
47+
},
48+
});
49+
}
50+
51+
function toExpectedOutputs(
52+
coin: AbstractUtxoCoin,
53+
txParams: {
54+
recipients?: ITransactionRecipient[];
55+
allowExternalChangeAddress?: boolean;
56+
changeAddress?: string;
57+
}
58+
): Output[] {
59+
// verify that each recipient from txParams has their own output
60+
const expectedOutputs = (txParams.recipients ?? []).flatMap((output) => {
61+
if (output.address === undefined) {
62+
if (output.amount.toString() !== '0') {
63+
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${output}`);
64+
}
65+
return [output];
66+
}
67+
return [{ ...output, address: coin.canonicalAddress(output.address) }];
68+
});
69+
if (txParams.allowExternalChangeAddress && txParams.changeAddress) {
70+
// when an external change address is explicitly specified, count all outputs going towards that
71+
// address in the expected outputs (regardless of the output amount)
72+
expectedOutputs.push({ address: coin.canonicalAddress(txParams.changeAddress), amount: 'max' });
73+
}
74+
return expectedOutputs;
75+
}
76+
2077
export async function parseTransaction<TNumber extends bigint | number>(
2178
coin: AbstractUtxoCoin,
2279
params: ParseTransactionOptions<TNumber>
2380
): Promise<ParsedTransaction<TNumber>> {
2481
const { txParams, txPrebuild, wallet, verification = {}, reqId } = params;
2582

83+
// Branch off early for RBF transactions
84+
if (txParams.rbfTxIds) {
85+
return parseRbfTransaction(coin, params);
86+
}
87+
2688
if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {
2789
throw new Error('verification.disableNetworking must be a boolean');
2890
}
@@ -47,56 +109,7 @@ export async function parseTransaction<TNumber extends bigint | number>(
47109
throw new Error('missing required txPrebuild property txHex');
48110
}
49111

50-
// obtain all outputs
51-
const explanation: TransactionExplanation = await coin.explainTransaction<TNumber>({
52-
txHex: txPrebuild.txHex,
53-
txInfo: txPrebuild.txInfo,
54-
pubs: keychainArray.map((k) => k.pub) as Triple<string>,
55-
});
56-
57-
const allOutputs = [...explanation.outputs, ...explanation.changeOutputs];
58-
59-
let expectedOutputs;
60-
if (txParams.rbfTxIds) {
61-
assert(txParams.rbfTxIds.length === 1);
62-
63-
const txToBeReplaced = await wallet.getTransaction({ txHash: txParams.rbfTxIds[0], includeRbf: true });
64-
expectedOutputs = txToBeReplaced.outputs.flatMap(
65-
(output: { valueString: string; address?: string; wallet?: string }) => {
66-
// For self-sends, the walletId will be the same as the wallet's id
67-
if (output.wallet === wallet.id()) {
68-
return [];
69-
}
70-
return [coin.toCanonicalTransactionRecipient(output)];
71-
}
72-
);
73-
} else {
74-
// verify that each recipient from txParams has their own output
75-
expectedOutputs = (txParams.recipients ?? []).flatMap((output) => {
76-
if (output.address === undefined) {
77-
if (output.amount.toString() !== '0') {
78-
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${output}`);
79-
}
80-
return [output];
81-
}
82-
return [{ ...output, address: coin.canonicalAddress(output.address) }];
83-
});
84-
if (txParams.allowExternalChangeAddress && txParams.changeAddress) {
85-
// when an external change address is explicitly specified, count all outputs going towards that
86-
// address in the expected outputs (regardless of the output amount)
87-
expectedOutputs.push(
88-
...allOutputs.flatMap((output) => {
89-
if (
90-
output.address === undefined ||
91-
output.address !== coin.canonicalAddress(txParams.changeAddress as string)
92-
) {
93-
return [];
94-
}
95-
return [{ ...output, address: coin.canonicalAddress(output.address) }];
96-
})
97-
);
98-
}
99-
}
112+
const expectedOutputs = toExpectedOutputs(coin, txParams);
100113

101114
// get the keychains from the custom change wallet if needed
102115
let customChange: CustomChangeOptions | undefined;
@@ -126,6 +139,15 @@ export async function parseTransaction<TNumber extends bigint | number>(
126139
}
127140
}
128141

142+
// obtain all outputs
143+
const explanation: TransactionExplanation = await coin.explainTransaction<TNumber>({
144+
txHex: txPrebuild.txHex,
145+
txInfo: txPrebuild.txInfo,
146+
pubs: keychainArray.map((k) => k.pub) as Triple<string>,
147+
});
148+
149+
const allOutputs = [...explanation.outputs, ...explanation.changeOutputs];
150+
129151
/**
130152
* Loop through all the outputs and classify each of them as either internal spends
131153
* or external spends by setting the "external" property to true or false on the output object.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export function assertValidTransactionRecipient(output: { amount: bigint | numbe
4747
// We will verify that the amount is zero, and if it isnt then we will throw an error.
4848
if (!output.address || isScriptRecipient(output.address)) {
4949
if (output.amount.toString() !== '0') {
50-
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${JSON.stringify(output)}`);
50+
throw new Error(
51+
`Only zero amounts allowed for non-encodeable scriptPubkeys: amount: ${output.amount}, address: ${output.address}`
52+
);
5153
}
5254
}
5355
}

modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,7 @@ import { fixedScriptWallet, Triple } from '@bitgo/wasm-utxo';
77
import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction';
88
import { explainPsbt, explainPsbtWasm } from '../../../../src/transaction/fixedScript';
99

10-
function hasWasmUtxoSupport(network: utxolib.Network): boolean {
11-
return ![
12-
utxolib.networks.bitcoincash,
13-
utxolib.networks.bitcoingold,
14-
utxolib.networks.ecash,
15-
utxolib.networks.zcash,
16-
].includes(utxolib.getMainnet(network));
17-
}
10+
import { hasWasmUtxoSupport } from './util';
1811

1912
function describeTransactionWith(acidTest: testutil.AcidTest) {
2013
describe(`${acidTest.name}`, function () {
@@ -32,7 +25,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) {
3225
// note: `outputs` means external outputs here
3326
assert.strictEqual(refExplanation.outputs.length, 3);
3427
assert.strictEqual(refExplanation.changeOutputs.length, acidTest.outputs.length - 3);
35-
assert.strictEqual(refExplanation.outputAmount, '2700');
28+
assert.strictEqual(refExplanation.outputAmount, '1800');
3629
assert.strictEqual(refExplanation.changeOutputs.length, acidTest.outputs.length - 3);
3730
refExplanation.changeOutputs.forEach((change) => {
3831
assert.strictEqual(change.amount, '900');

0 commit comments

Comments
 (0)