Skip to content

Commit 0ad731a

Browse files
Merge pull request #8304 from BitGo/BTC-2768.add-retail-helpers
feat(abstract-utxo): add PSBT helpers and transaction explanation utilities
2 parents 7d396b6 + e3b97e2 commit 0ad731a

7 files changed

Lines changed: 222 additions & 33 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo';
33

44
import { getNetworkFromCoinName, UtxoCoinName } from '../names';
55

6-
import { SdkBackend } from './types';
6+
import { SdkBackend, BitGoPsbt } from './types';
77

88
type BufferEncoding = 'hex' | 'base64';
99

@@ -62,6 +62,10 @@ export function decodePsbtWith(
6262
}
6363
}
6464

65+
export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName): BitGoPsbt {
66+
return decodePsbtWith(psbt, coinName, 'wasm-utxo');
67+
}
68+
6569
export function encodeTransaction(
6670
transaction: utxolib.bitgo.UtxoTransaction<bigint | number> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt
6771
): Buffer {
Lines changed: 122 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
22
import { Triple } from '@bitgo/sdk-core';
33

4-
import type { FixedScriptWalletOutput, Output } from '../types';
4+
import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types';
55

66
import type { TransactionExplanationWasm } from './explainTransaction';
77

@@ -20,71 +20,164 @@ function isParsedExternalOutput(output: ParsedWalletOutput | ParsedExternalOutpu
2020
return output.scriptId === null;
2121
}
2222

23-
function toChangeOutput(output: ParsedWalletOutput): FixedScriptWalletOutput {
23+
function toChangeOutputBigInt(output: ParsedWalletOutput): FixedScriptWalletOutput<bigint> {
2424
return {
2525
address: output.address ?? scriptToAddress(output.script),
26-
amount: output.value.toString(),
26+
amount: output.value,
2727
chain: output.scriptId.chain,
2828
index: output.scriptId.index,
2929
external: false,
3030
};
3131
}
3232

33-
function toExternalOutput(output: ParsedExternalOutput): Output {
33+
function toExternalOutputBigInt(output: ParsedExternalOutput): Output<bigint> {
3434
return {
3535
address: output.address ?? scriptToAddress(output.script),
36-
amount: output.value.toString(),
36+
amount: output.value,
3737
external: true,
3838
};
3939
}
4040

41-
export function explainPsbtWasm(
42-
psbt: fixedScriptWallet.BitGoPsbt,
41+
interface ExplainPsbtWasmParams {
42+
replayProtection: {
43+
checkSignature?: boolean;
44+
publicKeys: Buffer[];
45+
};
46+
customChangeWalletXpubs?: Triple<string> | fixedScriptWallet.RootWalletKeys;
47+
}
48+
49+
export interface ExplainedInput<TAmount = bigint> {
50+
address: string;
51+
value: TAmount;
52+
}
53+
54+
export interface TransactionExplanationBigInt {
55+
id: string;
56+
inputs: ExplainedInput[];
57+
inputAmount: bigint;
58+
outputs: Output<bigint>[];
59+
changeOutputs: FixedScriptWalletOutput<bigint>[];
60+
customChangeOutputs: FixedScriptWalletOutput<bigint>[];
61+
outputAmount: bigint;
62+
changeAmount: bigint;
63+
customChangeAmount: bigint;
64+
fee: bigint;
65+
}
66+
67+
export function explainPsbtWasmBigInt(
68+
psbt: BitGoPsbt,
4369
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
44-
params: {
45-
replayProtection: {
46-
checkSignature?: boolean;
47-
publicKeys: Buffer[];
48-
};
49-
customChangeWalletXpubs?: Triple<string> | fixedScriptWallet.RootWalletKeys;
50-
}
51-
): TransactionExplanationWasm {
70+
params: ExplainPsbtWasmParams
71+
): TransactionExplanationBigInt {
5272
const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, { replayProtection: params.replayProtection });
5373

54-
const changeOutputs: FixedScriptWalletOutput[] = [];
55-
const outputs: Output[] = [];
74+
const changeOutputs: FixedScriptWalletOutput<bigint>[] = [];
75+
const outputs: Output<bigint>[] = [];
5676
const parsedCustomChangeOutputs = params.customChangeWalletXpubs
5777
? psbt.parseOutputsWithWalletKeys(params.customChangeWalletXpubs)
5878
: undefined;
5979

60-
const customChangeOutputs: FixedScriptWalletOutput[] = [];
80+
const customChangeOutputs: FixedScriptWalletOutput<bigint>[] = [];
6181

6282
parsed.outputs.forEach((output, i) => {
6383
const parseCustomChangeOutput = parsedCustomChangeOutputs?.[i];
6484
if (isParsedWalletOutput(output)) {
65-
// This is a change output
66-
changeOutputs.push(toChangeOutput(output));
85+
changeOutputs.push(toChangeOutputBigInt(output));
6786
} else if (parseCustomChangeOutput && isParsedWalletOutput(parseCustomChangeOutput)) {
68-
customChangeOutputs.push(toChangeOutput(parseCustomChangeOutput));
87+
customChangeOutputs.push(toChangeOutputBigInt(parseCustomChangeOutput));
6988
} else if (isParsedExternalOutput(output)) {
70-
outputs.push(toExternalOutput(output));
89+
outputs.push(toExternalOutputBigInt(output));
7190
} else {
7291
throw new Error('Invalid output');
7392
}
7493
});
7594

76-
const outputAmount = outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0));
77-
const changeAmount = changeOutputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0));
78-
const customChangeAmount = customChangeOutputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0));
95+
const inputs = parsed.inputs.map((input) => ({ address: input.address, value: input.value }));
96+
const inputAmount = inputs.reduce((sum, input) => sum + input.value, 0n);
97+
const outputAmount = outputs.reduce((sum, output) => sum + output.amount, 0n);
98+
const changeAmount = changeOutputs.reduce((sum, output) => sum + output.amount, 0n);
99+
const customChangeAmount = customChangeOutputs.reduce((sum, output) => sum + output.amount, 0n);
79100

80101
return {
81102
id: psbt.unsignedTxId(),
82-
outputAmount: outputAmount.toString(),
83-
changeAmount: changeAmount.toString(),
84-
customChangeAmount: customChangeAmount.toString(),
103+
inputs,
104+
inputAmount,
85105
outputs,
86106
changeOutputs,
87107
customChangeOutputs,
88-
fee: parsed.minerFee.toString(),
108+
outputAmount,
109+
changeAmount,
110+
customChangeAmount,
111+
fee: parsed.minerFee,
112+
};
113+
}
114+
115+
function stringifyOutput(output: Output<bigint>): Output {
116+
return { ...output, amount: output.amount.toString() };
117+
}
118+
119+
function stringifyChangeOutput(output: FixedScriptWalletOutput<bigint>): FixedScriptWalletOutput {
120+
return { ...output, amount: output.amount.toString() };
121+
}
122+
123+
export function explainPsbtWasm(
124+
psbt: BitGoPsbt,
125+
walletXpubs: Triple<string> | fixedScriptWallet.RootWalletKeys,
126+
params: ExplainPsbtWasmParams
127+
): TransactionExplanationWasm {
128+
const result = explainPsbtWasmBigInt(psbt, walletXpubs, params);
129+
return {
130+
id: result.id,
131+
inputs: result.inputs.map((i) => ({ address: i.address, value: i.value.toString() })),
132+
inputAmount: result.inputAmount.toString(),
133+
outputAmount: result.outputAmount.toString(),
134+
changeAmount: result.changeAmount.toString(),
135+
customChangeAmount: result.customChangeAmount.toString(),
136+
outputs: result.outputs.map(stringifyOutput),
137+
changeOutputs: result.changeOutputs.map(stringifyChangeOutput),
138+
customChangeOutputs: result.customChangeOutputs.map(stringifyChangeOutput),
139+
fee: result.fee.toString(),
140+
};
141+
}
142+
143+
export interface AggregatedTransactionExplanation {
144+
inputCount: number;
145+
outputCount: number;
146+
changeOutputCount: number;
147+
inputAmount: bigint;
148+
outputAmount: bigint;
149+
changeAmount: bigint;
150+
fee: bigint;
151+
}
152+
153+
export function aggregateTransactionExplanations(
154+
explanations: TransactionExplanationBigInt[]
155+
): AggregatedTransactionExplanation {
156+
let inputCount = 0;
157+
let outputCount = 0;
158+
let changeOutputCount = 0;
159+
let fee = 0n;
160+
let inputAmount = 0n;
161+
let outputAmount = 0n;
162+
let changeAmount = 0n;
163+
164+
for (const e of explanations) {
165+
inputCount += e.inputs.length;
166+
outputCount += e.outputs.length;
167+
changeOutputCount += e.changeOutputs.length;
168+
fee += e.fee;
169+
inputAmount += e.inputAmount;
170+
outputAmount += e.outputAmount;
171+
changeAmount += e.changeAmount;
172+
}
173+
174+
return {
175+
inputCount,
176+
outputCount,
177+
changeOutputCount,
178+
inputAmount,
179+
outputAmount,
180+
changeAmount,
181+
fee,
89182
};
90183
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ interface TransactionExplanationWithSignatures<TFee = string, TChangeOutput exte
5151
}
5252

5353
/** For our wasm backend, we do not return the deprecated fields. We set TFee to string for backwards compatibility. */
54-
export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation<string, FixedScriptWalletOutput>;
54+
export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation<string, FixedScriptWalletOutput> & {
55+
inputs: Array<{ address: string; value: string }>;
56+
inputAmount: string;
57+
};
5558

5659
/** When parsing the legacy transaction format, we cannot always infer the fee so we set it to string | undefined */
5760
export type TransactionExplanationUtxolibLegacy = TransactionExplanationWithSignatures<string | undefined>;

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction';
2-
export { explainPsbtWasm } from './explainPsbtWasm';
2+
export {
3+
explainPsbtWasm,
4+
explainPsbtWasmBigInt,
5+
aggregateTransactionExplanations,
6+
type ExplainedInput,
7+
type TransactionExplanationBigInt,
8+
type AggregatedTransactionExplanation,
9+
} from './explainPsbtWasm';
310
export { parseTransaction } from './parseTransaction';
411
export { CustomChangeOptions } from './parseOutput';
512
export { verifyTransaction } from './verifyTransaction';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { parseTransaction } from './parseTransaction';
55
export { verifyTransaction } from './verifyTransaction';
66
export * from './fetchInputs';
77
export * as bip322 from './bip322';
8+
export { decodePsbt, decodePsbtWith } from './decode';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { UtxoNamedKeychains } from '../keychains';
55

66
import type { CustomChangeOptions } from './fixedScript';
77

8+
export type BitGoPsbt = fixedScriptWallet.BitGoPsbt;
9+
810
export type SdkBackend = 'utxolib' | 'wasm-utxo';
911

1012
export function isSdkBackend(backend: string): backend is SdkBackend {

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { testutil } from '@bitgo/utxo-lib';
55
import { fixedScriptWallet, Triple } from '@bitgo/wasm-utxo';
66

77
import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction';
8-
import { explainPsbt, explainPsbtWasm } from '../../../../src/transaction/fixedScript';
8+
import {
9+
explainPsbt,
10+
explainPsbtWasm,
11+
explainPsbtWasmBigInt,
12+
aggregateTransactionExplanations,
13+
type TransactionExplanationBigInt,
14+
} from '../../../../src/transaction/fixedScript';
915
import { getCoinName } from '../../../../src/names';
1016

1117
function describeTransactionWith(acidTest: testutil.AcidTest) {
@@ -79,12 +85,42 @@ function describeTransactionWith(acidTest: testutil.AcidTest) {
7985
break;
8086
}
8187
}
88+
89+
// verify new fields are present and stringified
90+
assert.strictEqual(typeof wasmExplanation.inputAmount, 'string');
91+
assert.ok(Array.isArray(wasmExplanation.inputs));
92+
assert.ok(wasmExplanation.inputs.length > 0);
93+
for (const input of wasmExplanation.inputs) {
94+
assert.strictEqual(typeof input.address, 'string');
95+
assert.strictEqual(typeof input.value, 'string');
96+
}
8297
});
8398

8499
if (acidTest.network !== utxolib.networks.bitcoin) {
85100
return;
86101
}
87102

103+
it('explainPsbtWasmBigInt returns bigint amounts and inputs array', function () {
104+
const result = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, {
105+
replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] },
106+
});
107+
assert.strictEqual(typeof result.fee, 'bigint');
108+
assert.strictEqual(typeof result.outputAmount, 'bigint');
109+
assert.strictEqual(typeof result.changeAmount, 'bigint');
110+
assert.strictEqual(typeof result.inputAmount, 'bigint');
111+
assert.ok(result.inputs.length > 0);
112+
for (const input of result.inputs) {
113+
assert.strictEqual(typeof input.address, 'string');
114+
assert.strictEqual(typeof input.value, 'bigint');
115+
}
116+
const sumInputs = result.inputs.reduce((s, i) => s + i.value, 0n);
117+
assert.strictEqual(result.inputAmount, sumInputs);
118+
assert.strictEqual(
119+
result.fee,
120+
result.inputAmount - result.outputAmount - result.changeAmount - result.customChangeAmount
121+
);
122+
});
123+
88124
// extended test suite for bitcoin
89125

90126
it('returns custom change outputs when parameter is set', function () {
@@ -105,3 +141,46 @@ function describeTransactionWith(acidTest: testutil.AcidTest) {
105141
describe('explainPsbt(Wasm)', function () {
106142
testutil.AcidTest.suite().forEach((test) => describeTransactionWith(test));
107143
});
144+
145+
describe('aggregateTransactionExplanations', function () {
146+
testutil.AcidTest.suite()
147+
.filter((t) => t.network === utxolib.networks.bitcoin)
148+
.forEach((acidTest) => {
149+
describe(acidTest.name, function () {
150+
let exp: TransactionExplanationBigInt;
151+
152+
before('prepare', function () {
153+
const psbtBytes = acidTest.createPsbt().toBuffer();
154+
const networkName = utxolib.getNetworkName(acidTest.network);
155+
assert(networkName);
156+
const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName);
157+
const walletXpubs = acidTest.rootWalletKeys.triple.map((k) => k.neutered().toBase58()) as Triple<string>;
158+
exp = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, {
159+
replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] },
160+
});
161+
});
162+
163+
it('aggregating a single explanation is identity', function () {
164+
const agg = aggregateTransactionExplanations([exp]);
165+
assert.strictEqual(agg.inputCount, exp.inputs.length);
166+
assert.strictEqual(agg.outputCount, exp.outputs.length);
167+
assert.strictEqual(agg.changeOutputCount, exp.changeOutputs.length);
168+
assert.strictEqual(agg.inputAmount, exp.inputAmount);
169+
assert.strictEqual(agg.outputAmount, exp.outputAmount);
170+
assert.strictEqual(agg.changeAmount, exp.changeAmount);
171+
assert.strictEqual(agg.fee, exp.fee);
172+
});
173+
174+
it('aggregating two identical explanations doubles all counts and amounts', function () {
175+
const agg = aggregateTransactionExplanations([exp, exp]);
176+
assert.strictEqual(agg.inputCount, exp.inputs.length * 2);
177+
assert.strictEqual(agg.outputCount, exp.outputs.length * 2);
178+
assert.strictEqual(agg.changeOutputCount, exp.changeOutputs.length * 2);
179+
assert.strictEqual(agg.inputAmount, exp.inputAmount * 2n);
180+
assert.strictEqual(agg.outputAmount, exp.outputAmount * 2n);
181+
assert.strictEqual(agg.changeAmount, exp.changeAmount * 2n);
182+
assert.strictEqual(agg.fee, exp.fee * 2n);
183+
});
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)