diff --git a/modules/abstract-utxo/src/replayProtection.ts b/modules/abstract-utxo/src/replayProtection.ts index 1f6abb40c3..96bf1c9113 100644 --- a/modules/abstract-utxo/src/replayProtection.ts +++ b/modules/abstract-utxo/src/replayProtection.ts @@ -1,3 +1,4 @@ +import * as wasmUtxo from '@bitgo/wasm-utxo'; import * as utxolib from '@bitgo/utxo-lib'; export function getReplayProtectionAddresses(network: utxolib.Network): string[] { @@ -13,6 +14,12 @@ export function getReplayProtectionAddresses(network: utxolib.Network): string[] return []; } +export function getReplayProtectionOutputScripts(network: utxolib.Network): Buffer[] { + return getReplayProtectionAddresses(network).map((address) => + Buffer.from(wasmUtxo.utxolibCompat.toOutputScript(address, network)) + ); +} + export function isReplayProtectionUnspent( u: utxolib.bitgo.Unspent, network: utxolib.Network diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 0e2a93d73e..18e083b5f1 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -1,13 +1,16 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { isTriple, IWallet } from '@bitgo/sdk-core'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { isTriple, IWallet, Triple } from '@bitgo/sdk-core'; import { getDescriptorMapFromWallet, isDescriptorWallet } from '../descriptor'; import { toBip32Triple } from '../keychains'; import { getPolicyForEnv } from '../descriptor/validatePolicy'; +import { getReplayProtectionOutputScripts } from '../replayProtection'; import type { TransactionExplanationUtxolibLegacy, TransactionExplanationUtxolibPsbt, + TransactionExplanationWasm, } from './fixedScript/explainTransaction'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; @@ -17,7 +20,7 @@ import * as descriptor from './descriptor'; * change amounts, and transaction outputs. */ export function explainTx( - tx: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt, + tx: utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt, params: { wallet?: IWallet; pubs?: string[]; @@ -25,7 +28,7 @@ export function explainTx( changeInfo?: fixedScript.ChangeAddressInfo[]; }, network: utxolib.Network -): TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt { +): TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt | TransactionExplanationWasm { if (params.wallet && isDescriptorWallet(params.wallet)) { if (tx instanceof utxolib.bitgo.UtxoPsbt) { if (!params.pubs || !isTriple(params.pubs)) { @@ -44,6 +47,25 @@ export function explainTx( } if (tx instanceof utxolib.bitgo.UtxoPsbt) { return fixedScript.explainPsbt(tx, params, network); + } else if (tx instanceof fixedScriptWallet.BitGoPsbt) { + const pubs = params.pubs; + if (!pubs) { + throw new Error('pub triple is required'); + } + const walletXpubs: Triple | undefined = + pubs instanceof utxolib.bitgo.RootWalletKeys + ? (pubs.triple.map((k) => k.neutered().toBase58()) as Triple) + : isTriple(pubs) + ? (pubs as Triple) + : undefined; + if (!walletXpubs) { + throw new Error('pub triple must be valid triple or RootWalletKeys'); + } + return fixedScript.explainPsbtWasm(tx, walletXpubs, { + replayProtection: { + outputScripts: getReplayProtectionOutputScripts(network), + }, + }); } else { return fixedScript.explainLegacyTx(tx, params, network); } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts new file mode 100644 index 0000000000..e941b78366 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -0,0 +1,60 @@ +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { Triple } from '@bitgo/sdk-core'; + +import type { Output, FixedScriptWalletOutput } from '../../abstractUtxoCoin'; + +import type { TransactionExplanationWasm } from './explainTransaction'; + +function scriptToAddress(script: Uint8Array): string { + return `scriptPubKey:${Buffer.from(script).toString('hex')}`; +} + +export function explainPsbtWasm( + psbt: fixedScriptWallet.BitGoPsbt, + walletXpubs: Triple, + params: { + replayProtection: { + checkSignature?: boolean; + outputScripts: Buffer[]; + }; + } +): TransactionExplanationWasm { + const parsed = psbt.parseTransactionWithWalletKeys(walletXpubs, params.replayProtection); + + const changeOutputs: FixedScriptWalletOutput[] = []; + const outputs: Output[] = []; + + parsed.outputs.forEach((output) => { + const address = output.address ?? scriptToAddress(output.script); + + if (output.scriptId) { + // This is a change output + changeOutputs.push({ + address, + amount: output.value.toString(), + chain: output.scriptId.chain, + index: output.scriptId.index, + external: false, + }); + } else { + // This is an external output + outputs.push({ + address, + amount: output.value.toString(), + external: true, + }); + } + }); + + const changeAmount = changeOutputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); + const outputAmount = outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)); + + return { + id: psbt.unsignedTxid(), + outputAmount: outputAmount.toString(), + changeAmount: changeAmount.toString(), + outputs, + changeOutputs, + fee: parsed.minerFee.toString(), + }; +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index e136810db1..03ac278ae9 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -41,13 +41,19 @@ interface TransactionExplanationWithSignatures extends AbstractUt signatures: number; } +/** For our wasm backend, we do not return the deprecated fields. We set TFee to string for backwards compatibility. */ +export type TransactionExplanationWasm = AbstractUtxoTransactionExplanation; + /** When parsing the legacy transaction format, we cannot always infer the fee so we set it to string | undefined */ export type TransactionExplanationUtxolibLegacy = TransactionExplanationWithSignatures; /** When parsing a PSBT, we can infer the fee so we set TFee to string. */ export type TransactionExplanationUtxolibPsbt = TransactionExplanationWithSignatures; -export type TransactionExplanation = TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt; +export type TransactionExplanation = + | TransactionExplanationUtxolibLegacy + | TransactionExplanationUtxolibPsbt + | TransactionExplanationWasm; export type ChangeAddressInfo = { address: string; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index 3226025cd1..a1290e62a2 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -1,4 +1,5 @@ export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction'; +export { explainPsbtWasm } from './explainPsbtWasm'; export { parseTransaction } from './parseTransaction'; export { CustomChangeOptions } from './parseOutput'; export { verifyTransaction } from './verifyTransaction'; diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 9b0e1fe237..5bc6a3eaf4 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -1,18 +1,31 @@ import assert from 'node:assert/strict'; +import * as utxolib from '@bitgo/utxo-lib'; import { testutil } from '@bitgo/utxo-lib'; +import { fixedScriptWallet, Triple } from '@bitgo/wasm-utxo'; import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction'; -import { explainPsbt } from '../../../../src/transaction/fixedScript'; +import { explainPsbt, explainPsbtWasm } from '../../../../src/transaction/fixedScript'; + +function hasWasmUtxoSupport(network: utxolib.Network): boolean { + return ![ + utxolib.networks.bitcoincash, + utxolib.networks.bitcoingold, + utxolib.networks.ecash, + utxolib.networks.zcash, + ].includes(utxolib.getMainnet(network)); +} function describeTransactionWith(acidTest: testutil.AcidTest) { describe(`${acidTest.name}`, function () { + let psbtBytes: Buffer; let refExplanation: TransactionExplanation; before('prepare', function () { const psbt = acidTest.createPsbt(); refExplanation = explainPsbt(psbt, { pubs: acidTest.rootWalletKeys }, acidTest.network, { strict: true, }); + psbtBytes = psbt.toBuffer(); }); it('should match the expected values for explainPsbt', function () { @@ -26,9 +39,41 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { assert.strictEqual(typeof change.address, 'string'); }); }); + + it('should match explainPsbtWasm', function () { + if (!hasWasmUtxoSupport(acidTest.network)) { + return this.skip(); + } + + 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; + const wasmExplanation = explainPsbtWasm(wasmPsbt, walletXpubs, { + replayProtection: { + outputScripts: [acidTest.getReplayProtectionOutputScript()], + }, + }); + + for (const key of Object.keys(refExplanation)) { + const refValue = refExplanation[key]; + const wasmValue = wasmExplanation[key]; + switch (key) { + case 'displayOrder': + case 'inputSignatures': + case 'signatures': + // these are deprecated fields that we want to get rid of + assert.deepStrictEqual(wasmValue, undefined); + break; + default: + assert.deepStrictEqual(wasmValue, refValue, `mismatch for key ${key}`); + break; + } + } + }); }); } -describe('explainPsbt', function () { +describe('explainPsbt(Wasm)', function () { testutil.AcidTest.suite().forEach((test) => describeTransactionWith(test)); });