Skip to content

Commit fd2e902

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): refactor transaction explanation utilities
Extracted helper functions from the main explainPsbt function to improve readability and maintainability. Updated getRootWalletKeys to properly handle RootWalletKeys instances and added proper typing throughout the codebase. Issue: BTC-2732 Co-authored-by: llm-git <[email protected]>
1 parent 17b5a94 commit fd2e902

File tree

2 files changed

+146
-145
lines changed

2 files changed

+146
-145
lines changed

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

Lines changed: 145 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,18 @@ function explainCommon<TNumber extends number | bigint>(
9292
return { displayOrder, id: tx.getId(), ...outputDetails, fee, locktime };
9393
}
9494

95-
function getRootWalletKeys(params: { pubs?: string[] }) {
95+
function getRootWalletKeys(params: { pubs?: bitgo.RootWalletKeys | string[] }): bitgo.RootWalletKeys | undefined {
96+
if (params.pubs instanceof bitgo.RootWalletKeys) {
97+
return params.pubs;
98+
}
9699
const keys = params.pubs?.map((xpub) => bip32.fromBase58(xpub));
97100
return keys && keys.length === 3 ? new bitgo.RootWalletKeys(keys as Triple<BIP32Interface>) : undefined;
98101
}
99102

100103
function getPsbtInputSignaturesCount(
101104
psbt: bitgo.UtxoPsbt,
102105
params: {
103-
pubs?: string[];
106+
pubs?: bitgo.RootWalletKeys | string[];
104107
}
105108
) {
106109
const rootWalletKeys = getRootWalletKeys(params);
@@ -113,7 +116,7 @@ function getTxInputSignaturesCount<TNumber extends number | bigint>(
113116
tx: bitgo.UtxoTransaction<TNumber>,
114117
params: {
115118
txInfo?: { unspents?: bitgo.Unspent<TNumber>[] };
116-
pubs?: string[];
119+
pubs?: bitgo.RootWalletKeys | string[];
117120
},
118121
network: utxolib.Network
119122
) {
@@ -142,162 +145,165 @@ function getTxInputSignaturesCount<TNumber extends number | bigint>(
142145
});
143146
}
144147

145-
/**
146-
* Decompose a raw psbt into useful information, such as the total amounts,
147-
* change amounts, and transaction outputs.
148-
*/
149-
export function explainPsbt<TNumber extends number | bigint, Tx extends bitgo.UtxoTransaction<bigint>>(
150-
psbt: bitgo.UtxoPsbt<Tx>,
151-
params: {
152-
pubs?: string[];
153-
txInfo?: { unspents?: bitgo.Unspent<TNumber>[] };
154-
},
155-
network: utxolib.Network,
156-
{ strict = false }: { strict?: boolean } = {}
157-
): TransactionExplanation {
158-
const txOutputs = psbt.txOutputs;
159-
const txInputs = psbt.txInputs;
148+
function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) {
149+
const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined;
150+
if (!derivations) {
151+
return undefined;
152+
}
153+
const paths = derivations.map((d) => d.path);
154+
if (!paths || paths.length !== 3) {
155+
throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation');
156+
}
157+
if (!paths.every((p) => paths[0] === p)) {
158+
throw new Error('expected all paths to be the same');
159+
}
160160

161-
function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) {
162-
const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined;
163-
if (!derivations) {
164-
return undefined;
165-
}
166-
const paths = derivations.map((d) => d.path);
167-
if (!paths || paths.length !== 3) {
168-
throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation');
169-
}
170-
if (!paths.every((p) => paths[0] === p)) {
171-
throw new Error('expected all paths to be the same');
161+
paths.forEach((path) => {
162+
if (paths[0] !== path) {
163+
throw new Error(
164+
'Unable to get a single chain and index on the output because there are different paths for different keys'
165+
);
172166
}
167+
});
168+
return utxolib.bitgo.getChainAndIndexFromPath(paths[0]);
169+
}
173170

174-
paths.forEach((path) => {
175-
if (paths[0] !== path) {
176-
throw new Error(
177-
'Unable to get a single chain and index on the output because there are different paths for different keys'
178-
);
171+
function getChangeInfo(psbt: bitgo.UtxoPsbt): ChangeAddressInfo[] | undefined {
172+
try {
173+
return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => {
174+
const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]);
175+
if (!derivationInformation) {
176+
throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation');
179177
}
178+
return {
179+
address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network),
180+
external: false,
181+
...derivationInformation,
182+
};
180183
});
181-
return utxolib.bitgo.getChainAndIndexFromPath(paths[0]);
184+
} catch (e) {
185+
if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) {
186+
return undefined;
187+
}
188+
throw e;
182189
}
190+
}
183191

184-
function getChangeInfo() {
185-
try {
186-
return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => {
187-
const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]);
188-
if (!derivationInformation) {
189-
throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation');
190-
}
191-
return {
192-
address: utxolib.address.fromOutputScript(txOutputs[i].script, network),
193-
external: false,
194-
...derivationInformation,
195-
};
196-
});
197-
} catch (e) {
198-
if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) {
199-
return undefined;
200-
}
201-
throw e;
202-
}
192+
/**
193+
* Extract PayGo address proof information from the PSBT if present
194+
* @returns Information about the PayGo proof, including the output index and address
195+
*/
196+
function getPayGoVerificationInfo(
197+
psbt: bitgo.UtxoPsbt,
198+
network: utxolib.Network
199+
): { outputIndex: number; verificationPubkey: string } | undefined {
200+
let outputIndex: number | undefined = undefined;
201+
let address: string | undefined = undefined;
202+
// Check if this PSBT has any PayGo address proofs
203+
if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) {
204+
return undefined;
203205
}
204206

205-
/**
206-
* Extract PayGo address proof information from the PSBT if present
207-
* @returns Information about the PayGo proof, including the output index and address
208-
*/
209-
function getPayGoVerificationInfo(): { outputIndex: number; verificationPubkey: string } | undefined {
210-
let outputIndex: number | undefined = undefined;
211-
let address: string | undefined = undefined;
212-
// Check if this PSBT has any PayGo address proofs
213-
if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) {
214-
return undefined;
215-
}
207+
// This pulls the pubkey depending on given network
208+
const verificationPubkey = getPayGoVerificationPubkey(network);
209+
// find which output index that contains the PayGo proof
210+
outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt);
211+
if (outputIndex === undefined || !verificationPubkey) {
212+
return undefined;
213+
}
214+
const output = psbt.txOutputs[outputIndex];
215+
address = utxolib.address.fromOutputScript(output.script, network);
216+
if (!address) {
217+
throw new Error(`Can not derive address ${address} Pay Go Attestation.`);
218+
}
216219

217-
// This pulls the pubkey depending on given network
218-
const verificationPubkey = getPayGoVerificationPubkey(network);
219-
// find which output index that contains the PayGo proof
220-
outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt);
221-
if (outputIndex === undefined || !verificationPubkey) {
222-
return undefined;
223-
}
224-
const output = txOutputs[outputIndex];
225-
address = utxolib.address.fromOutputScript(output.script, network);
226-
if (!address) {
227-
throw new Error(`Can not derive address ${address} Pay Go Attestation.`);
228-
}
220+
return { outputIndex, verificationPubkey };
221+
}
229222

230-
return { outputIndex, verificationPubkey };
231-
}
223+
/**
224+
* Extract the BIP322 messages and addresses from the PSBT inputs and perform
225+
* verification on the transaction to ensure that it meets the BIP322 requirements.
226+
* @returns An array of objects containing the message and address for each input,
227+
* or undefined if no BIP322 messages are found.
228+
*/
229+
function getBip322MessageInfoAndVerify(psbt: bitgo.UtxoPsbt, network: utxolib.Network): Bip322Message[] | undefined {
230+
const bip322Messages: { message: string; address: string }[] = [];
231+
for (let i = 0; i < psbt.data.inputs.length; i++) {
232+
const message = bip322.getBip322ProofMessageAtIndex(psbt, i);
233+
if (message) {
234+
const input = psbt.data.inputs[i];
235+
if (!input.witnessUtxo) {
236+
throw new Error(`Missing witnessUtxo for input index ${i}`);
237+
}
238+
if (!input.nonWitnessUtxo) {
239+
throw new Error(`Missing nonWitnessUtxo for input index ${i}`);
240+
}
241+
const scriptPubKey = input.witnessUtxo.script;
232242

233-
/**
234-
* Extract the BIP322 messages and addresses from the PSBT inputs and perform
235-
* verification on the transaction to ensure that it meets the BIP322 requirements.
236-
* @returns An array of objects containing the message and address for each input,
237-
* or undefined if no BIP322 messages are found.
238-
*/
239-
function getBip322MessageInfoAndVerify(): Bip322Message[] | undefined {
240-
const bip322Messages: { message: string; address: string }[] = [];
241-
for (let i = 0; i < psbt.data.inputs.length; i++) {
242-
const message = bip322.getBip322ProofMessageAtIndex(psbt, i);
243-
if (message) {
244-
const input = psbt.data.inputs[i];
245-
if (!input.witnessUtxo) {
246-
throw new Error(`Missing witnessUtxo for input index ${i}`);
247-
}
248-
if (!input.nonWitnessUtxo) {
249-
throw new Error(`Missing nonWitnessUtxo for input index ${i}`);
250-
}
251-
const scriptPubKey = input.witnessUtxo.script;
252-
253-
// Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo
254-
const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message);
255-
const toSpendB64 = toSpend.toBuffer().toString('base64');
256-
if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) {
257-
throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`);
258-
}
259-
260-
// Verify that the toSpend transaction ID matches the input's referenced transaction ID
261-
if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(txInputs[i]).txid) {
262-
throw new Error(`ToSpend transaction ID does not match the input at index ${i}`);
263-
}
264-
265-
// Verify the input specifics
266-
if (txInputs[i].sequence !== 0) {
267-
throw new Error(`Unexpected sequence number at input index ${i}: ${txInputs[i].sequence}. Expected 0.`);
268-
}
269-
if (txInputs[i].index !== 0) {
270-
throw new Error(`Unexpected input index at position ${i}: ${txInputs[i].index}. Expected 0.`);
271-
}
272-
273-
bip322Messages.push({
274-
message: message.toString('utf8'),
275-
address: utxolib.address.fromOutputScript(scriptPubKey, network),
276-
});
243+
// Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo
244+
const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message);
245+
const toSpendB64 = toSpend.toBuffer().toString('base64');
246+
if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) {
247+
throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`);
277248
}
278-
}
279249

280-
if (bip322Messages.length > 0) {
281-
// If there is a BIP322 message in any input, all inputs must have one.
282-
if (bip322Messages.length !== psbt.data.inputs.length) {
283-
throw new Error('Inconsistent BIP322 messages across inputs.');
250+
// Verify that the toSpend transaction ID matches the input's referenced transaction ID
251+
if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(psbt.txInputs[i]).txid) {
252+
throw new Error(`ToSpend transaction ID does not match the input at index ${i}`);
284253
}
285254

286-
// Verify the transaction specifics for BIP322
287-
if (psbt.version !== 0 && psbt.version !== 2) {
288-
throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `);
255+
// Verify the input specifics
256+
if (psbt.txInputs[i].sequence !== 0) {
257+
throw new Error(`Unexpected sequence number at input index ${i}: ${psbt.txInputs[i].sequence}. Expected 0.`);
289258
}
290-
if (psbt.data.outputs.length !== 1 || txOutputs[0].script.toString('hex') !== '6a' || txOutputs[0].value !== 0n) {
291-
throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`);
259+
if (psbt.txInputs[i].index !== 0) {
260+
throw new Error(`Unexpected input index at position ${i}: ${psbt.txInputs[i].index}. Expected 0.`);
292261
}
293262

294-
return bip322Messages;
263+
bip322Messages.push({
264+
message: message.toString('utf8'),
265+
address: utxolib.address.fromOutputScript(scriptPubKey, network),
266+
});
267+
}
268+
}
269+
270+
if (bip322Messages.length > 0) {
271+
// If there is a BIP322 message in any input, all inputs must have one.
272+
if (bip322Messages.length !== psbt.data.inputs.length) {
273+
throw new Error('Inconsistent BIP322 messages across inputs.');
295274
}
296275

297-
return undefined;
276+
// Verify the transaction specifics for BIP322
277+
if (psbt.version !== 0 && psbt.version !== 2) {
278+
throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `);
279+
}
280+
if (
281+
psbt.data.outputs.length !== 1 ||
282+
psbt.txOutputs[0].script.toString('hex') !== '6a' ||
283+
psbt.txOutputs[0].value !== 0n
284+
) {
285+
throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`);
286+
}
287+
288+
return bip322Messages;
298289
}
299290

300-
const payGoVerificationInfo = getPayGoVerificationInfo();
291+
return undefined;
292+
}
293+
294+
/**
295+
* Decompose a raw psbt into useful information, such as the total amounts,
296+
* change amounts, and transaction outputs.
297+
*/
298+
export function explainPsbt<TNumber extends number | bigint, Tx extends bitgo.UtxoTransaction<bigint>>(
299+
psbt: bitgo.UtxoPsbt<Tx>,
300+
params: {
301+
pubs?: bitgo.RootWalletKeys | string[];
302+
},
303+
network: utxolib.Network,
304+
{ strict = false }: { strict?: boolean } = {}
305+
): TransactionExplanation {
306+
const payGoVerificationInfo = getPayGoVerificationInfo(psbt, network);
301307
if (payGoVerificationInfo) {
302308
try {
303309
utxocore.paygo.verifyPayGoAddressProof(
@@ -313,14 +319,14 @@ export function explainPsbt<TNumber extends number | bigint, Tx extends bitgo.Ut
313319
}
314320
}
315321

316-
const messages = getBip322MessageInfoAndVerify();
317-
const changeInfo = getChangeInfo();
322+
const messages = getBip322MessageInfoAndVerify(psbt, network);
323+
const changeInfo = getChangeInfo(psbt);
318324
const tx = psbt.getUnsignedTx() as bitgo.UtxoTransaction<TNumber>;
319325
const common = explainCommon(tx, { ...params, changeInfo }, network);
320326
const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params);
321327

322328
// Set fee from subtracting inputs from outputs
323-
const outputAmount = txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0));
329+
const outputAmount = psbt.txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0));
324330
const inputAmount = psbt.txInputs.reduce((cumulative, txInput, i) => {
325331
const data = psbt.data.inputs[i];
326332
if (data.witnessUtxo) {

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) {
88
describe(`explainPsbt ${acidTest.name}`, function () {
99
it('should explain the transaction', function () {
1010
const psbt = acidTest.createPsbt();
11-
const explanation = explainPsbt(
12-
psbt,
13-
{ pubs: acidTest.rootWalletKeys.triple.map((k) => k.toBase58()) },
14-
acidTest.network,
15-
{ strict: true }
16-
);
11+
const explanation = explainPsbt(psbt, { pubs: acidTest.rootWalletKeys }, acidTest.network, { strict: true });
1712
assert.strictEqual(explanation.outputs.length, 3);
1813
assert.strictEqual(explanation.outputAmount, '2700');
1914
assert.strictEqual(explanation.changeOutputs.length, acidTest.outputs.length - 3);

0 commit comments

Comments
 (0)