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 { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts index 4c544aff94..e2eec34bce 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,164 @@ 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(), + }; +} + +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/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..ee341fdcfc 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -1,5 +1,12 @@ export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction'; -export { explainPsbtWasm } from './explainPsbtWasm'; +export { + explainPsbtWasm, + explainPsbtWasmBigInt, + aggregateTransactionExplanations, + type ExplainedInput, + type TransactionExplanationBigInt, + type AggregatedTransactionExplanation, +} 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/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 { diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 05b3de54f6..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 } 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) { @@ -79,12 +85,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 () { @@ -105,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); + }); + }); + }); +});