Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions modules/abstract-utxo/src/replayProtection.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Expand All @@ -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<TNumber extends number | bigint>(
u: utxolib.bitgo.Unspent<TNumber>,
network: utxolib.Network
Expand Down
28 changes: 25 additions & 3 deletions modules/abstract-utxo/src/transaction/explainTransaction.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,15 +20,15 @@ import * as descriptor from './descriptor';
* change amounts, and transaction outputs.
*/
export function explainTx<TNumber extends number | bigint>(
tx: utxolib.bitgo.UtxoTransaction<TNumber> | utxolib.bitgo.UtxoPsbt,
tx: utxolib.bitgo.UtxoTransaction<TNumber> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
params: {
wallet?: IWallet;
pubs?: string[];
txInfo?: { unspents?: utxolib.bitgo.Unspent<TNumber>[] };
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)) {
Expand All @@ -44,6 +47,25 @@ export function explainTx<TNumber extends number | bigint>(
}
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<string> | undefined =
pubs instanceof utxolib.bitgo.RootWalletKeys
? (pubs.triple.map((k) => k.neutered().toBase58()) as Triple<string>)
: isTriple(pubs)
? (pubs as Triple<string>)
: 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>,
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(),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@ interface TransactionExplanationWithSignatures<TFee = string> 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<string>;

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

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

export type TransactionExplanation = TransactionExplanationUtxolibLegacy | TransactionExplanationUtxolibPsbt;
export type TransactionExplanation =
| TransactionExplanationUtxolibLegacy
| TransactionExplanationUtxolibPsbt
| TransactionExplanationWasm;

export type ChangeAddressInfo = {
address: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 () {
Expand All @@ -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<string>;
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));
});