Skip to content

Commit 4d80186

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): implement support for custom change outputs
Implement support for identifying custom change outputs in transaction explanations. This feature helps users understand which outputs are regular wallet change vs. custom change, such as those from BitGo's sweep consolidation wallets. Issue: BTC-2732 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 2e945a1 commit 4d80186

2 files changed

Lines changed: 78 additions & 43 deletions

File tree

modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ITransactionRecipient } from '@bitgo/sdk-core';
33
import * as coreDescriptors from '@bitgo/utxo-core/descriptor';
44

55
import { toExtendedAddressFormat } from '../recipient';
6-
import type { TransactionExplanationUtxolibPsbt } from '../fixedScript/explainTransaction';
6+
import type { TransactionExplanationDescriptor } from '../fixedScript/explainTransaction';
77

88
function toRecipient(output: coreDescriptors.ParsedOutput, network: utxolib.Network): ITransactionRecipient {
99
return {
@@ -34,7 +34,7 @@ function getInputSignatures(psbt: utxolib.bitgo.UtxoPsbt): number[] {
3434
export function explainPsbt(
3535
psbt: utxolib.bitgo.UtxoPsbt,
3636
descriptors: coreDescriptors.DescriptorMap
37-
): TransactionExplanationUtxolibPsbt {
37+
): TransactionExplanationDescriptor {
3838
const parsedTransaction = coreDescriptors.parse(psbt, descriptors, psbt.network);
3939
const { inputs, outputs } = parsedTransaction;
4040
const externalOutputs = outputs.filter((o) => o.scriptId === undefined);

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

Lines changed: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import type { Bip322Message } from '../../abstractUtxoCoin';
99
import type { Output, FixedScriptWalletOutput } from '../types';
1010
import { toExtendedAddressFormat } from '../recipient';
1111
import { getPayGoVerificationPubkey } from '../getPayGoVerificationPubkey';
12+
import { toBip32Triple } from '../../keychains';
1213

1314
// ===== Transaction Explanation Type Definitions =====
1415

15-
export interface AbstractUtxoTransactionExplanation<TFee = string> extends BaseTransactionExplanation<TFee, string> {
16+
export interface AbstractUtxoTransactionExplanation<TFee = string, TChangeOutput extends Output = Output>
17+
extends BaseTransactionExplanation<TFee, string> {
1618
/** NOTE: this actually only captures external outputs */
1719
outputs: Output[];
18-
changeOutputs: Output[];
20+
changeOutputs: TChangeOutput[];
21+
customChangeOutputs?: TChangeOutput[];
22+
customChangeAmount?: string;
1923

2024
/**
2125
* BIP322 messages extracted from the transaction inputs.
@@ -25,7 +29,8 @@ export interface AbstractUtxoTransactionExplanation<TFee = string> extends BaseT
2529
}
2630

2731
/** @deprecated - the signature fields are not very useful */
28-
interface TransactionExplanationWithSignatures<TFee = string> extends AbstractUtxoTransactionExplanation<TFee> {
32+
interface TransactionExplanationWithSignatures<TFee = string, TChangeOutput extends Output = Output>
33+
extends AbstractUtxoTransactionExplanation<TFee, TChangeOutput> {
2934
/** @deprecated - unused outside of tests */
3035
locktime?: number;
3136

@@ -43,14 +48,16 @@ interface TransactionExplanationWithSignatures<TFee = string> extends AbstractUt
4348
}
4449

4550
/** For our wasm backend, we do not return the deprecated fields. We set TFee to string for backwards compatibility. */
46-
export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation<string>;
51+
export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation<string, FixedScriptWalletOutput>;
4752

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

5156
/** When parsing a PSBT, we can infer the fee so we set TFee to string. */
5257
export type TransactionExplanationUtxolibPsbt = TransactionExplanationWithSignatures<string>;
5358

59+
export type TransactionExplanationDescriptor = TransactionExplanationWithSignatures<string, Output>;
60+
5461
export type TransactionExplanation =
5562
| TransactionExplanationUtxolibLegacy
5663
| TransactionExplanationUtxolibPsbt
@@ -62,49 +69,67 @@ export type ChangeAddressInfo = {
6269
index: number;
6370
};
6471

72+
function toChangeOutput(
73+
txOutput: utxolib.TxOutput<number | bigint>,
74+
network: utxolib.Network,
75+
changeInfo: ChangeAddressInfo[] | undefined
76+
): FixedScriptWalletOutput | undefined {
77+
if (!changeInfo) {
78+
return undefined;
79+
}
80+
const address = toExtendedAddressFormat(txOutput.script, network);
81+
const change = changeInfo.find((change) => change.address === address);
82+
if (!change) {
83+
return undefined;
84+
}
85+
return {
86+
address,
87+
amount: txOutput.value.toString(),
88+
chain: change.chain,
89+
index: change.index,
90+
external: false,
91+
};
92+
}
93+
94+
function outputSum(outputs: { amount: string | number }[]): bigint {
95+
return outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0));
96+
}
97+
6598
function explainCommon<TNumber extends number | bigint>(
6699
tx: bitgo.UtxoTransaction<TNumber>,
67100
params: {
68101
changeInfo?: ChangeAddressInfo[];
102+
customChangeInfo?: ChangeAddressInfo[];
69103
feeInfo?: string;
70104
},
71105
network: utxolib.Network
72106
) {
73107
const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs'];
74-
let spendAmount = BigInt(0);
75-
let changeAmount = BigInt(0);
76108
const changeOutputs: FixedScriptWalletOutput[] = [];
77-
const outputs: Output[] = [];
109+
const customChangeOutputs: FixedScriptWalletOutput[] = [];
110+
const externalOutputs: Output[] = [];
78111

79-
const { changeInfo } = params;
80-
const changeAddresses = changeInfo?.map((info) => info.address) ?? [];
112+
const { changeInfo, customChangeInfo } = params;
81113

82114
tx.outs.forEach((currentOutput) => {
83115
// Try to encode the script pubkey with an address. If it fails, try to parse it as an OP_RETURN output with the prefix.
84116
// If that fails, then it is an unrecognized scriptPubkey and should fail
85117
const currentAddress = toExtendedAddressFormat(currentOutput.script, network);
86118
const currentAmount = BigInt(currentOutput.value);
87119

88-
if (changeAddresses.includes(currentAddress)) {
89-
// this is change
90-
changeAmount += currentAmount;
91-
const change = changeInfo?.find((change) => change.address === currentAddress);
120+
const changeOutput = toChangeOutput(currentOutput, network, changeInfo);
121+
if (changeOutput) {
122+
changeOutputs.push(changeOutput);
123+
return;
124+
}
92125

93-
if (!change) {
94-
throw new Error('changeInfo must have change information for all change outputs');
95-
}
96-
changeOutputs.push({
97-
address: currentAddress,
98-
amount: currentAmount.toString(),
99-
chain: change.chain,
100-
index: change.index,
101-
external: false,
102-
});
126+
const customChangeOutput = toChangeOutput(currentOutput, network, customChangeInfo);
127+
if (customChangeOutput) {
128+
customChangeOutputs.push(customChangeOutput);
103129
return;
104130
}
105131

106-
spendAmount += currentAmount;
107-
outputs.push({
132+
externalOutputs.push({
108133
address: currentAddress,
109134
amount: currentAmount.toString(),
110135
// If changeInfo has a length greater than or equal to zero, it means that the change information
@@ -117,10 +142,14 @@ function explainCommon<TNumber extends number | bigint>(
117142
});
118143

119144
const outputDetails = {
120-
outputAmount: spendAmount.toString(),
121-
changeAmount: changeAmount.toString(),
122-
outputs,
145+
outputs: externalOutputs,
146+
outputAmount: outputSum(externalOutputs).toString(),
147+
123148
changeOutputs,
149+
changeAmount: outputSum(changeOutputs).toString(),
150+
151+
customChangeAmount: outputSum(customChangeOutputs).toString(),
152+
customChangeOutputs,
124153
};
125154

126155
let fee: string | undefined;
@@ -215,25 +244,27 @@ function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) {
215244
return utxolib.bitgo.getChainAndIndexFromPath(paths[0]);
216245
}
217246

218-
function getChangeInfo(psbt: bitgo.UtxoPsbt): ChangeAddressInfo[] | undefined {
247+
function getChangeInfo(psbt: bitgo.UtxoPsbt, walletKeys?: Triple<BIP32Interface>): ChangeAddressInfo[] | undefined {
219248
try {
220-
return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => {
221-
const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]);
222-
if (!derivationInformation) {
223-
throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation');
224-
}
225-
return {
226-
address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network),
227-
external: false,
228-
...derivationInformation,
229-
};
230-
});
249+
walletKeys = walletKeys ?? utxolib.bitgo.getSortedRootNodes(psbt);
231250
} catch (e) {
232251
if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) {
233252
return undefined;
234253
}
235254
throw e;
236255
}
256+
257+
return utxolib.bitgo.findWalletOutputIndices(psbt, walletKeys).map((i) => {
258+
const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]);
259+
if (!derivationInformation) {
260+
throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation');
261+
}
262+
return {
263+
address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network),
264+
external: false,
265+
...derivationInformation,
266+
};
267+
});
237268
}
238269

239270
/**
@@ -351,6 +382,7 @@ export function explainPsbt(
351382
psbt: bitgo.UtxoPsbt,
352383
params: {
353384
pubs?: bitgo.RootWalletKeys | string[];
385+
customChangePubs?: bitgo.RootWalletKeys | string[];
354386
},
355387
network: utxolib.Network,
356388
{ strict = false }: { strict?: boolean } = {}
@@ -373,8 +405,11 @@ export function explainPsbt(
373405

374406
const messages = getBip322MessageInfoAndVerify(psbt, network);
375407
const changeInfo = getChangeInfo(psbt);
408+
const customChangeInfo = params.customChangePubs
409+
? getChangeInfo(psbt, toBip32Triple(params.customChangePubs))
410+
: undefined;
376411
const tx = psbt.getUnsignedTx();
377-
const common = explainCommon(tx, { ...params, changeInfo }, network);
412+
const common = explainCommon(tx, { ...params, changeInfo, customChangeInfo }, network);
378413
const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params);
379414

380415
// Set fee from subtracting inputs from outputs

0 commit comments

Comments
 (0)