Skip to content

Commit df692e8

Browse files
Merge pull request #5180 from BitGo/BTC-1450.refactor-explainTx
refactor(abstract-utxo): refactor explainTx, explainPsbt
2 parents 9186bb8 + 298591d commit df692e8

File tree

5 files changed

+177
-158
lines changed

5 files changed

+177
-158
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -500,9 +500,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
500500
if (_.isUndefined(prebuild.txHex)) {
501501
throw new Error('missing required txPrebuild property txHex');
502502
}
503-
const tx = bitgo.isPsbt(prebuild.txHex)
504-
? bitgo.createPsbtFromHex(prebuild.txHex, this.network)
505-
: this.createTransactionFromHex<TNumber>(prebuild.txHex);
503+
const tx = this.decodeTransaction(prebuild.txHex);
506504
if (_.isUndefined(prebuild.blockHeight)) {
507505
prebuild.blockHeight = (await this.getLatestBlockHeight()) as number;
508506
}
@@ -542,6 +540,29 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
542540
return utxolib.bitgo.createTransactionFromHex<TNumber>(hex, this.network, this.amountType);
543541
}
544542

543+
decodeTransaction<TNumber extends number | bigint>(
544+
input: Buffer | string
545+
): utxolib.bitgo.UtxoTransaction<TNumber> | utxolib.bitgo.UtxoPsbt {
546+
if (typeof input === 'string') {
547+
for (const format of ['hex', 'base64'] as const) {
548+
const buffer = Buffer.from(input, format);
549+
if (buffer.toString(format) === input.toLowerCase()) {
550+
return this.decodeTransaction(buffer);
551+
}
552+
}
553+
554+
throw new Error('input must be a valid hex or base64 string');
555+
}
556+
557+
if (utxolib.bitgo.isPsbt(input)) {
558+
return utxolib.bitgo.createPsbtFromBuffer(input, this.network);
559+
} else {
560+
return utxolib.bitgo.createTransactionFromBuffer(input, this.network, {
561+
amountType: this.amountType,
562+
});
563+
}
564+
}
565+
545566
toCanonicalTransactionRecipient(output: { valueString: string; address?: string }): {
546567
amount: bigint;
547568
address?: string;
@@ -1278,9 +1299,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
12781299
throw new Error('missing txPrebuild parameter');
12791300
}
12801301

1281-
let tx = bitgo.isPsbt(txPrebuild.txHex)
1282-
? bitgo.createPsbtFromHex(txPrebuild.txHex, this.network)
1283-
: this.createTransactionFromHex<TNumber>(txPrebuild.txHex);
1302+
let tx = this.decodeTransaction(params.txPrebuild.txHex);
12841303

12851304
const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx);
12861305

@@ -1413,9 +1432,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
14131432
const txHex = signTransactionParams.txPrebuild.txHex;
14141433
assert(txHex, 'missing txHex parameter');
14151434

1416-
const tx = bitgo.isPsbt(txHex)
1417-
? bitgo.createPsbtFromHex(txHex, this.network)
1418-
: this.createTransactionFromHex<TNumber>(txHex);
1435+
const tx = this.decodeTransaction(txHex);
14191436

14201437
const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx);
14211438

@@ -1503,7 +1520,12 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
15031520
if (typeof txHex !== 'string' || !txHex.match(/^([a-f0-9]{2})+$/i)) {
15041521
throw new Error('invalid transaction hex, must be a valid hex string');
15051522
}
1506-
return utxolib.bitgo.isPsbt(txHex) ? explainPsbt(params, this.network) : explainTx(params, this);
1523+
const tx = this.decodeTransaction(txHex);
1524+
if (tx instanceof bitgo.UtxoPsbt) {
1525+
return explainPsbt(tx, params, this.network);
1526+
} else {
1527+
return explainTx(tx, params, this.network);
1528+
}
15071529
}
15081530

15091531
/**

modules/abstract-utxo/src/transaction.ts renamed to modules/abstract-utxo/src/transaction/explainTransaction.ts

Lines changed: 13 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,16 @@
1+
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
12
import * as utxolib from '@bitgo/utxo-lib';
2-
import { BitGoBase, IRequestTracer, Triple } from '@bitgo/sdk-core';
3+
import { Triple } from '@bitgo/sdk-core';
4+
35
import {
4-
AbstractUtxoCoin,
56
DecoratedExplainTransactionOptions,
6-
WalletOutput,
77
ExplainTransactionOptions,
8-
TransactionExplanation,
9-
TransactionPrebuild,
108
Output,
11-
} from './abstractUtxoCoin';
12-
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
13-
14-
const ScriptRecipientPrefix = 'scriptPubKey:';
15-
16-
/**
17-
* Check if the address is a script recipient (starts with `scriptPubKey:`).
18-
* @param address
19-
*/
20-
export function isScriptRecipient(address: string): boolean {
21-
return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase());
22-
}
23-
24-
/**
25-
* An extended address is one that encodes either a regular address or a hex encoded script with the prefix `scriptPubKey:`.
26-
* This function converts the extended address format to either a script or an address.
27-
* @param extendedAddress
28-
*/
29-
export function fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } {
30-
if (isScriptRecipient(extendedAddress)) {
31-
return { script: extendedAddress.slice(ScriptRecipientPrefix.length) };
32-
}
33-
return { address: extendedAddress };
34-
}
35-
36-
/**
37-
* Convert a script or address to the extended address format.
38-
* @param script
39-
* @param network
40-
* @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address.
41-
*/
42-
export function toExtendedAddressFormat(script: Buffer, network: utxolib.Network): string {
43-
return script[0] === utxolib.opcodes.OP_RETURN
44-
? `${ScriptRecipientPrefix}${script.toString('hex')}`
45-
: utxolib.address.fromOutputScript(script, network);
46-
}
47-
48-
export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void {
49-
// In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address.
50-
// We will verify that the amount is zero, and if it isnt then we will throw an error.
51-
if (!output.address || isScriptRecipient(output.address)) {
52-
if (output.amount.toString() !== '0') {
53-
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${JSON.stringify(output)}`);
54-
}
55-
}
56-
}
57-
58-
/**
59-
* Get the inputs for a psbt from a prebuild.
60-
*/
61-
export function getPsbtTxInputs(
62-
psbtArg: string | utxolib.bitgo.UtxoPsbt,
63-
network: utxolib.Network
64-
): { address: string; value: bigint; valueString: string }[] {
65-
const psbt = psbtArg instanceof utxolib.bitgo.UtxoPsbt ? psbtArg : utxolib.bitgo.createPsbtFromHex(psbtArg, network);
66-
const txInputs = psbt.txInputs;
67-
return psbt.data.inputs.map((input, index) => {
68-
let address: string;
69-
let value: bigint;
70-
if (input.witnessUtxo) {
71-
address = utxolib.address.fromOutputScript(input.witnessUtxo.script, network);
72-
value = input.witnessUtxo.value;
73-
} else if (input.nonWitnessUtxo) {
74-
const tx = utxolib.bitgo.createTransactionFromBuffer<bigint>(input.nonWitnessUtxo, network, {
75-
amountType: 'bigint',
76-
});
77-
const txId = (Buffer.from(txInputs[index].hash).reverse() as Buffer).toString('hex');
78-
if (tx.getId() !== txId) {
79-
throw new Error('input transaction hex does not match id');
80-
}
81-
const prevTxOutputIndex = txInputs[index].index;
82-
address = utxolib.address.fromOutputScript(tx.outs[prevTxOutputIndex].script, network);
83-
value = tx.outs[prevTxOutputIndex].value;
84-
} else {
85-
throw new Error('psbt input is missing both witnessUtxo and nonWitnessUtxo');
86-
}
87-
return { address, value, valueString: value.toString() };
88-
});
89-
}
9+
TransactionExplanation,
10+
WalletOutput,
11+
} from '../abstractUtxoCoin';
9012

91-
/**
92-
* Get the inputs for a transaction from a prebuild.
93-
*/
94-
export async function getTxInputs<TNumber extends number | bigint>(params: {
95-
txPrebuild: TransactionPrebuild<TNumber>;
96-
bitgo: BitGoBase;
97-
coin: AbstractUtxoCoin;
98-
disableNetworking: boolean;
99-
reqId?: IRequestTracer;
100-
}): Promise<{ address: string; value: TNumber; valueString: string }[]> {
101-
const { txPrebuild, bitgo, coin, disableNetworking, reqId } = params;
102-
if (!txPrebuild.txHex) {
103-
throw new Error(`txPrebuild.txHex not set`);
104-
}
105-
const transaction = coin.createTransactionFromHex<TNumber>(txPrebuild.txHex);
106-
const transactionCache = {};
107-
return await Promise.all(
108-
transaction.ins.map(async (currentInput): Promise<{ address: string; value: TNumber; valueString: string }> => {
109-
const transactionId = (Buffer.from(currentInput.hash).reverse() as Buffer).toString('hex');
110-
const txHex = txPrebuild.txInfo?.txHexes?.[transactionId];
111-
if (txHex) {
112-
const localTx = coin.createTransactionFromHex<TNumber>(txHex);
113-
if (localTx.getId() !== transactionId) {
114-
throw new Error('input transaction hex does not match id');
115-
}
116-
const currentOutput = localTx.outs[currentInput.index];
117-
const address = utxolib.address.fromOutputScript(currentOutput.script, coin.network);
118-
return {
119-
address,
120-
value: currentOutput.value,
121-
valueString: currentOutput.value.toString(),
122-
};
123-
} else if (!transactionCache[transactionId]) {
124-
if (disableNetworking) {
125-
throw new Error('attempting to retrieve transaction details externally with networking disabled');
126-
}
127-
if (reqId) {
128-
bitgo.setRequestTracer(reqId);
129-
}
130-
transactionCache[transactionId] = await bitgo.get(coin.url(`/public/tx/${transactionId}`)).result();
131-
}
132-
const transactionDetails = transactionCache[transactionId];
133-
return transactionDetails.outputs[currentInput.index];
134-
})
135-
);
136-
}
13+
import { toExtendedAddressFormat } from './recipient';
13714

13815
function explainCommon<TNumber extends number | bigint>(
13916
tx: bitgo.UtxoTransaction<TNumber>,
@@ -258,17 +135,11 @@ function getTxInputSignaturesCount<TNumber extends number | bigint>(
258135
* Decompose a raw psbt into useful information, such as the total amounts,
259136
* change amounts, and transaction outputs.
260137
*/
261-
export function explainPsbt<TNumber extends number | bigint>(
138+
export function explainPsbt<TNumber extends number | bigint, Tx extends bitgo.UtxoTransaction<bigint>>(
139+
psbt: bitgo.UtxoPsbt<Tx>,
262140
params: ExplainTransactionOptions<TNumber>,
263141
network: utxolib.Network
264142
): TransactionExplanation {
265-
const { txHex } = params;
266-
let psbt: bitgo.UtxoPsbt;
267-
try {
268-
psbt = bitgo.createPsbtFromHex(txHex, network);
269-
} catch (e) {
270-
throw new Error('failed to parse psbt hex');
271-
}
272143
const txOutputs = psbt.txOutputs;
273144

274145
function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) {
@@ -346,18 +217,12 @@ export function explainPsbt<TNumber extends number | bigint>(
346217
* change amounts, and transaction outputs.
347218
*/
348219
export function explainTx<TNumber extends number | bigint>(
220+
tx: bitgo.UtxoTransaction<TNumber>,
349221
params: ExplainTransactionOptions<TNumber>,
350-
coin: AbstractUtxoCoin
222+
network: utxolib.Network
351223
): TransactionExplanation {
352-
const { txHex } = params;
353-
let tx;
354-
try {
355-
tx = coin.createTransactionFromHex(txHex);
356-
} catch (e) {
357-
throw new Error('failed to parse transaction hex');
358-
}
359-
const common = explainCommon(tx, params, coin.network);
360-
const inputSignaturesCount = getTxInputSignaturesCount(tx, params, coin.network);
224+
const common = explainCommon(tx, params, network);
225+
const inputSignaturesCount = getTxInputSignaturesCount(tx, params, network);
361226
return {
362227
...common,
363228
inputSignatures: inputSignaturesCount,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { BitGoBase, IRequestTracer } from '@bitgo/sdk-core';
3+
4+
import { AbstractUtxoCoin, TransactionPrebuild } from '../abstractUtxoCoin';
5+
6+
/**
7+
* Get the inputs for a psbt from a prebuild.
8+
*/
9+
export function getPsbtTxInputs(
10+
psbtArg: string | utxolib.bitgo.UtxoPsbt,
11+
network: utxolib.Network
12+
): { address: string; value: bigint; valueString: string }[] {
13+
const psbt = psbtArg instanceof utxolib.bitgo.UtxoPsbt ? psbtArg : utxolib.bitgo.createPsbtFromHex(psbtArg, network);
14+
const txInputs = psbt.txInputs;
15+
return psbt.data.inputs.map((input, index) => {
16+
let address: string;
17+
let value: bigint;
18+
if (input.witnessUtxo) {
19+
address = utxolib.address.fromOutputScript(input.witnessUtxo.script, network);
20+
value = input.witnessUtxo.value;
21+
} else if (input.nonWitnessUtxo) {
22+
const tx = utxolib.bitgo.createTransactionFromBuffer<bigint>(input.nonWitnessUtxo, network, {
23+
amountType: 'bigint',
24+
});
25+
const txId = (Buffer.from(txInputs[index].hash).reverse() as Buffer).toString('hex');
26+
if (tx.getId() !== txId) {
27+
throw new Error('input transaction hex does not match id');
28+
}
29+
const prevTxOutputIndex = txInputs[index].index;
30+
address = utxolib.address.fromOutputScript(tx.outs[prevTxOutputIndex].script, network);
31+
value = tx.outs[prevTxOutputIndex].value;
32+
} else {
33+
throw new Error('psbt input is missing both witnessUtxo and nonWitnessUtxo');
34+
}
35+
return { address, value, valueString: value.toString() };
36+
});
37+
}
38+
39+
/**
40+
* Get the inputs for a transaction from a prebuild.
41+
*/
42+
export async function getTxInputs<TNumber extends number | bigint>(params: {
43+
txPrebuild: TransactionPrebuild<TNumber>;
44+
bitgo: BitGoBase;
45+
coin: AbstractUtxoCoin;
46+
disableNetworking: boolean;
47+
reqId?: IRequestTracer;
48+
}): Promise<{ address: string; value: TNumber; valueString: string }[]> {
49+
const { txPrebuild, bitgo, coin, disableNetworking, reqId } = params;
50+
if (!txPrebuild.txHex) {
51+
throw new Error(`txPrebuild.txHex not set`);
52+
}
53+
const transaction = coin.createTransactionFromHex<TNumber>(txPrebuild.txHex);
54+
const transactionCache = {};
55+
return await Promise.all(
56+
transaction.ins.map(async (currentInput): Promise<{ address: string; value: TNumber; valueString: string }> => {
57+
const transactionId = (Buffer.from(currentInput.hash).reverse() as Buffer).toString('hex');
58+
const txHex = txPrebuild.txInfo?.txHexes?.[transactionId];
59+
if (txHex) {
60+
const localTx = coin.createTransactionFromHex<TNumber>(txHex);
61+
if (localTx.getId() !== transactionId) {
62+
throw new Error('input transaction hex does not match id');
63+
}
64+
const currentOutput = localTx.outs[currentInput.index];
65+
const address = utxolib.address.fromOutputScript(currentOutput.script, coin.network);
66+
return {
67+
address,
68+
value: currentOutput.value,
69+
valueString: currentOutput.value.toString(),
70+
};
71+
} else if (!transactionCache[transactionId]) {
72+
if (disableNetworking) {
73+
throw new Error('attempting to retrieve transaction details externally with networking disabled');
74+
}
75+
if (reqId) {
76+
bitgo.setRequestTracer(reqId);
77+
}
78+
transactionCache[transactionId] = await bitgo.get(coin.url(`/public/tx/${transactionId}`)).result();
79+
}
80+
const transactionDetails = transactionCache[transactionId];
81+
return transactionDetails.outputs[currentInput.index];
82+
})
83+
);
84+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './recipient';
2+
export * from './explainTransaction';
3+
export * from './fetchInputs';

0 commit comments

Comments
 (0)