From 59cfc0aec7bd3ee32519e9b9f2971d7d1f7cb2ed Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 16 Mar 2026 12:31:21 +0100 Subject: [PATCH 1/4] feat(abstract-utxo): export BitGoPsbt type from transaction module Export the BitGoPsbt type from wasm-utxo to make it available to consumers of the abstract-utxo module. Issue: BTC-2768 Co-authored-by: llm-git --- modules/abstract-utxo/src/transaction/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/abstract-utxo/src/transaction/types.ts b/modules/abstract-utxo/src/transaction/types.ts index 635f226506..329acace5b 100644 --- a/modules/abstract-utxo/src/transaction/types.ts +++ b/modules/abstract-utxo/src/transaction/types.ts @@ -5,6 +5,8 @@ import type { UtxoNamedKeychains } from '../keychains'; import type { CustomChangeOptions } from './fixedScript'; +export type BitGoPsbt = fixedScriptWallet.BitGoPsbt; + export type SdkBackend = 'utxolib' | 'wasm-utxo'; export function isSdkBackend(backend: string): backend is SdkBackend { From 2a5ab1a02740db6679fd51c83f8fed29e47d624a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 16 Mar 2026 12:30:00 +0100 Subject: [PATCH 2/4] feat(abstract-utxo): add decodePsbt helper function Add convenience function that uses wasm-utxo decoder by default Issue: BTC-2768 Co-authored-by: llm-git --- modules/abstract-utxo/src/transaction/decode.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/abstract-utxo/src/transaction/decode.ts b/modules/abstract-utxo/src/transaction/decode.ts index 10e63221cd..d985736f48 100644 --- a/modules/abstract-utxo/src/transaction/decode.ts +++ b/modules/abstract-utxo/src/transaction/decode.ts @@ -3,7 +3,7 @@ import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo'; import { getNetworkFromCoinName, UtxoCoinName } from '../names'; -import { SdkBackend } from './types'; +import { SdkBackend, BitGoPsbt } from './types'; type BufferEncoding = 'hex' | 'base64'; @@ -62,6 +62,10 @@ export function decodePsbtWith( } } +export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName): BitGoPsbt { + return decodePsbtWith(psbt, coinName, 'wasm-utxo'); +} + export function encodeTransaction( transaction: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt ): Buffer { From 781e2fe04c46065192bbf56397f9d43719a79662 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 12 Mar 2026 12:04:48 +0100 Subject: [PATCH 3/4] feat(abstract-utxo): add explainPsbtWasmBigInt for bigint amounts Refactor explainPsbtWasm to use explainPsbtWasmBigInt that returns bigint amounts instead of strings. The original function now delegates to the new implementation and converts results to strings for backward compatibility. Add input details (address, value) to transaction explanations. Export utility functions (decodePsbt, decodePsbtWith, isPsbt, getVSize) from transaction module for external use. BTC-2768 Co-authored-by: llm-git --- .../fixedScript/explainPsbtWasm.ts | 109 +++++++++++++----- .../fixedScript/explainTransaction.ts | 5 +- .../src/transaction/fixedScript/index.ts | 7 +- .../abstract-utxo/src/transaction/index.ts | 1 + .../transaction/fixedScript/explainPsbt.ts | 32 ++++- 5 files changed, 122 insertions(+), 32 deletions(-) diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts index 4c544aff94..5a4612c795 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -1,7 +1,7 @@ import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { Triple } from '@bitgo/sdk-core'; -import type { FixedScriptWalletOutput, Output } from '../types'; +import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types'; import type { TransactionExplanationWasm } from './explainTransaction'; @@ -20,71 +20,122 @@ function isParsedExternalOutput(output: ParsedWalletOutput | ParsedExternalOutpu return output.scriptId === null; } -function toChangeOutput(output: ParsedWalletOutput): FixedScriptWalletOutput { +function toChangeOutputBigInt(output: ParsedWalletOutput): FixedScriptWalletOutput { return { address: output.address ?? scriptToAddress(output.script), - amount: output.value.toString(), + amount: output.value, chain: output.scriptId.chain, index: output.scriptId.index, external: false, }; } -function toExternalOutput(output: ParsedExternalOutput): Output { +function toExternalOutputBigInt(output: ParsedExternalOutput): Output { return { address: output.address ?? scriptToAddress(output.script), - amount: output.value.toString(), + amount: output.value, external: true, }; } -export function explainPsbtWasm( - psbt: fixedScriptWallet.BitGoPsbt, +interface ExplainPsbtWasmParams { + replayProtection: { + checkSignature?: boolean; + publicKeys: Buffer[]; + }; + customChangeWalletXpubs?: Triple | fixedScriptWallet.RootWalletKeys; +} + +export interface ExplainedInput { + address: string; + value: TAmount; +} + +export interface TransactionExplanationBigInt { + id: string; + inputs: ExplainedInput[]; + inputAmount: bigint; + outputs: Output[]; + changeOutputs: FixedScriptWalletOutput[]; + customChangeOutputs: FixedScriptWalletOutput[]; + outputAmount: bigint; + changeAmount: bigint; + customChangeAmount: bigint; + fee: bigint; +} + +export function explainPsbtWasmBigInt( + psbt: BitGoPsbt, walletXpubs: Triple | fixedScriptWallet.RootWalletKeys, - params: { - replayProtection: { - checkSignature?: boolean; - publicKeys: Buffer[]; - }; - customChangeWalletXpubs?: Triple | fixedScriptWallet.RootWalletKeys; - } -): TransactionExplanationWasm { + params: ExplainPsbtWasmParams +): TransactionExplanationBigInt { const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, { replayProtection: params.replayProtection }); - const changeOutputs: FixedScriptWalletOutput[] = []; - const outputs: Output[] = []; + const changeOutputs: FixedScriptWalletOutput[] = []; + const outputs: Output[] = []; const parsedCustomChangeOutputs = params.customChangeWalletXpubs ? psbt.parseOutputsWithWalletKeys(params.customChangeWalletXpubs) : undefined; - const customChangeOutputs: FixedScriptWalletOutput[] = []; + const customChangeOutputs: FixedScriptWalletOutput[] = []; parsed.outputs.forEach((output, i) => { const parseCustomChangeOutput = parsedCustomChangeOutputs?.[i]; if (isParsedWalletOutput(output)) { - // This is a change output - changeOutputs.push(toChangeOutput(output)); + changeOutputs.push(toChangeOutputBigInt(output)); } else if (parseCustomChangeOutput && isParsedWalletOutput(parseCustomChangeOutput)) { - customChangeOutputs.push(toChangeOutput(parseCustomChangeOutput)); + customChangeOutputs.push(toChangeOutputBigInt(parseCustomChangeOutput)); } else if (isParsedExternalOutput(output)) { - outputs.push(toExternalOutput(output)); + outputs.push(toExternalOutputBigInt(output)); } else { throw new Error('Invalid output'); } }); - const outputAmount = outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); - const changeAmount = changeOutputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); - const customChangeAmount = customChangeOutputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); + const inputs = parsed.inputs.map((input) => ({ address: input.address, value: input.value })); + const inputAmount = inputs.reduce((sum, input) => sum + input.value, 0n); + const outputAmount = outputs.reduce((sum, output) => sum + output.amount, 0n); + const changeAmount = changeOutputs.reduce((sum, output) => sum + output.amount, 0n); + const customChangeAmount = customChangeOutputs.reduce((sum, output) => sum + output.amount, 0n); return { id: psbt.unsignedTxId(), - outputAmount: outputAmount.toString(), - changeAmount: changeAmount.toString(), - customChangeAmount: customChangeAmount.toString(), + inputs, + inputAmount, outputs, changeOutputs, customChangeOutputs, - fee: parsed.minerFee.toString(), + outputAmount, + changeAmount, + customChangeAmount, + fee: parsed.minerFee, + }; +} + +function stringifyOutput(output: Output): Output { + return { ...output, amount: output.amount.toString() }; +} + +function stringifyChangeOutput(output: FixedScriptWalletOutput): FixedScriptWalletOutput { + return { ...output, amount: output.amount.toString() }; +} + +export function explainPsbtWasm( + psbt: BitGoPsbt, + walletXpubs: Triple | fixedScriptWallet.RootWalletKeys, + params: ExplainPsbtWasmParams +): TransactionExplanationWasm { + const result = explainPsbtWasmBigInt(psbt, walletXpubs, params); + return { + id: result.id, + inputs: result.inputs.map((i) => ({ address: i.address, value: i.value.toString() })), + inputAmount: result.inputAmount.toString(), + outputAmount: result.outputAmount.toString(), + changeAmount: result.changeAmount.toString(), + customChangeAmount: result.customChangeAmount.toString(), + outputs: result.outputs.map(stringifyOutput), + changeOutputs: result.changeOutputs.map(stringifyChangeOutput), + customChangeOutputs: result.customChangeOutputs.map(stringifyChangeOutput), + fee: result.fee.toString(), }; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index 2dcbca9f0c..1698989e16 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -51,7 +51,10 @@ interface TransactionExplanationWithSignatures; +export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation & { + inputs: Array<{ address: string; value: string }>; + inputAmount: string; +}; /** When parsing the legacy transaction format, we cannot always infer the fee so we set it to string | undefined */ export type TransactionExplanationUtxolibLegacy = TransactionExplanationWithSignatures; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index db46d13099..55b677b6a1 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -1,5 +1,10 @@ export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction'; -export { explainPsbtWasm } from './explainPsbtWasm'; +export { + explainPsbtWasm, + explainPsbtWasmBigInt, + type ExplainedInput, + type TransactionExplanationBigInt, +} from './explainPsbtWasm'; export { parseTransaction } from './parseTransaction'; export { CustomChangeOptions } from './parseOutput'; export { verifyTransaction } from './verifyTransaction'; diff --git a/modules/abstract-utxo/src/transaction/index.ts b/modules/abstract-utxo/src/transaction/index.ts index 4bacd7522e..788f3fe112 100644 --- a/modules/abstract-utxo/src/transaction/index.ts +++ b/modules/abstract-utxo/src/transaction/index.ts @@ -5,3 +5,4 @@ export { parseTransaction } from './parseTransaction'; export { verifyTransaction } from './verifyTransaction'; export * from './fetchInputs'; export * as bip322 from './bip322'; +export { decodePsbt, decodePsbtWith } from './decode'; diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 05b3de54f6..9804b00f3f 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -5,7 +5,7 @@ import { testutil } from '@bitgo/utxo-lib'; import { fixedScriptWallet, Triple } from '@bitgo/wasm-utxo'; import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction'; -import { explainPsbt, explainPsbtWasm } from '../../../../src/transaction/fixedScript'; +import { explainPsbt, explainPsbtWasm, explainPsbtWasmBigInt } from '../../../../src/transaction/fixedScript'; import { getCoinName } from '../../../../src/names'; function describeTransactionWith(acidTest: testutil.AcidTest) { @@ -79,12 +79,42 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { break; } } + + // verify new fields are present and stringified + assert.strictEqual(typeof wasmExplanation.inputAmount, 'string'); + assert.ok(Array.isArray(wasmExplanation.inputs)); + assert.ok(wasmExplanation.inputs.length > 0); + for (const input of wasmExplanation.inputs) { + assert.strictEqual(typeof input.address, 'string'); + assert.strictEqual(typeof input.value, 'string'); + } }); if (acidTest.network !== utxolib.networks.bitcoin) { return; } + it('explainPsbtWasmBigInt returns bigint amounts and inputs array', function () { + const result = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] }, + }); + assert.strictEqual(typeof result.fee, 'bigint'); + assert.strictEqual(typeof result.outputAmount, 'bigint'); + assert.strictEqual(typeof result.changeAmount, 'bigint'); + assert.strictEqual(typeof result.inputAmount, 'bigint'); + assert.ok(result.inputs.length > 0); + for (const input of result.inputs) { + assert.strictEqual(typeof input.address, 'string'); + assert.strictEqual(typeof input.value, 'bigint'); + } + const sumInputs = result.inputs.reduce((s, i) => s + i.value, 0n); + assert.strictEqual(result.inputAmount, sumInputs); + assert.strictEqual( + result.fee, + result.inputAmount - result.outputAmount - result.changeAmount - result.customChangeAmount + ); + }); + // extended test suite for bitcoin it('returns custom change outputs when parameter is set', function () { From e3b97e2d5bf0cc65c9b85302d3d87de1d28c99f3 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 16 Mar 2026 15:16:52 +0100 Subject: [PATCH 4/4] feat(abstract-utxo): add aggregateTransactionExplanations with tests Add comprehensive test coverage for the new aggregation function, verifying identity property for single explanations and additive property for multiple explanations. Issue: BTC-2768 Co-authored-by: llm-git --- .../fixedScript/explainPsbtWasm.ts | 42 +++++++++++++++ .../src/transaction/fixedScript/index.ts | 2 + .../transaction/fixedScript/explainPsbt.ts | 51 ++++++++++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts index 5a4612c795..e2eec34bce 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -139,3 +139,45 @@ export function explainPsbtWasm( fee: result.fee.toString(), }; } + +export interface AggregatedTransactionExplanation { + inputCount: number; + outputCount: number; + changeOutputCount: number; + inputAmount: bigint; + outputAmount: bigint; + changeAmount: bigint; + fee: bigint; +} + +export function aggregateTransactionExplanations( + explanations: TransactionExplanationBigInt[] +): AggregatedTransactionExplanation { + let inputCount = 0; + let outputCount = 0; + let changeOutputCount = 0; + let fee = 0n; + let inputAmount = 0n; + let outputAmount = 0n; + let changeAmount = 0n; + + for (const e of explanations) { + inputCount += e.inputs.length; + outputCount += e.outputs.length; + changeOutputCount += e.changeOutputs.length; + fee += e.fee; + inputAmount += e.inputAmount; + outputAmount += e.outputAmount; + changeAmount += e.changeAmount; + } + + return { + inputCount, + outputCount, + changeOutputCount, + inputAmount, + outputAmount, + changeAmount, + fee, + }; +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index 55b677b6a1..ee341fdcfc 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -2,8 +2,10 @@ export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransa export { explainPsbtWasm, explainPsbtWasmBigInt, + aggregateTransactionExplanations, type ExplainedInput, type TransactionExplanationBigInt, + type AggregatedTransactionExplanation, } from './explainPsbtWasm'; export { parseTransaction } from './parseTransaction'; export { CustomChangeOptions } from './parseOutput'; diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 9804b00f3f..cacbbe2d6c 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -5,7 +5,13 @@ import { testutil } from '@bitgo/utxo-lib'; import { fixedScriptWallet, Triple } from '@bitgo/wasm-utxo'; import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction'; -import { explainPsbt, explainPsbtWasm, explainPsbtWasmBigInt } from '../../../../src/transaction/fixedScript'; +import { + explainPsbt, + explainPsbtWasm, + explainPsbtWasmBigInt, + aggregateTransactionExplanations, + type TransactionExplanationBigInt, +} from '../../../../src/transaction/fixedScript'; import { getCoinName } from '../../../../src/names'; function describeTransactionWith(acidTest: testutil.AcidTest) { @@ -135,3 +141,46 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { describe('explainPsbt(Wasm)', function () { testutil.AcidTest.suite().forEach((test) => describeTransactionWith(test)); }); + +describe('aggregateTransactionExplanations', function () { + testutil.AcidTest.suite() + .filter((t) => t.network === utxolib.networks.bitcoin) + .forEach((acidTest) => { + describe(acidTest.name, function () { + let exp: TransactionExplanationBigInt; + + before('prepare', function () { + const psbtBytes = acidTest.createPsbt().toBuffer(); + const networkName = utxolib.getNetworkName(acidTest.network); + assert(networkName); + const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName); + const walletXpubs = acidTest.rootWalletKeys.triple.map((k) => k.neutered().toBase58()) as Triple; + exp = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] }, + }); + }); + + it('aggregating a single explanation is identity', function () { + const agg = aggregateTransactionExplanations([exp]); + assert.strictEqual(agg.inputCount, exp.inputs.length); + assert.strictEqual(agg.outputCount, exp.outputs.length); + assert.strictEqual(agg.changeOutputCount, exp.changeOutputs.length); + assert.strictEqual(agg.inputAmount, exp.inputAmount); + assert.strictEqual(agg.outputAmount, exp.outputAmount); + assert.strictEqual(agg.changeAmount, exp.changeAmount); + assert.strictEqual(agg.fee, exp.fee); + }); + + it('aggregating two identical explanations doubles all counts and amounts', function () { + const agg = aggregateTransactionExplanations([exp, exp]); + assert.strictEqual(agg.inputCount, exp.inputs.length * 2); + assert.strictEqual(agg.outputCount, exp.outputs.length * 2); + assert.strictEqual(agg.changeOutputCount, exp.changeOutputs.length * 2); + assert.strictEqual(agg.inputAmount, exp.inputAmount * 2n); + assert.strictEqual(agg.outputAmount, exp.outputAmount * 2n); + assert.strictEqual(agg.changeAmount, exp.changeAmount * 2n); + assert.strictEqual(agg.fee, exp.fee * 2n); + }); + }); + }); +});