From 473b0760a69477f0463adf828dd6f7831db81152 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 3 Feb 2026 12:30:50 +0100 Subject: [PATCH] feat(abstract-utxo): switch to using wasm-utxo primitives Replace all usages of utxo-lib and utxo-core descriptor primitives with the equivalent functionality from wasm-utxo. This includes: - Using wasm-utxo Descriptor, Psbt, and descriptorWallet implementations - Adding wasm utility functions for type conversion - Converting existing PSBT handling to use the new API - Updating address validation and derivation Issue: BTC-2866 Co-authored-by: llm-git --- .../assertDescriptorWalletAddress.ts | 11 ++-- .../src/descriptor/descriptorWallet.ts | 2 +- modules/abstract-utxo/src/descriptor/index.ts | 2 +- .../src/descriptor/validatePolicy.ts | 6 +- .../offlineVault/OfflineVaultHalfSigned.ts | 6 +- .../offlineVault/descriptor/transaction.ts | 20 +++--- .../src/transaction/descriptor/explainPsbt.ts | 42 ++++++------ .../src/transaction/descriptor/index.ts | 2 +- .../src/transaction/descriptor/parse.ts | 49 ++++++++------ .../src/transaction/descriptor/signPsbt.ts | 43 ++++++------ .../descriptor/verifyTransaction.ts | 20 +++--- .../src/transaction/explainTransaction.ts | 3 +- .../transaction/fixedScript/signPsbtWasm.ts | 9 +-- .../src/transaction/signTransaction.ts | 17 ++--- modules/abstract-utxo/src/wasmUtil.ts | 66 +++++++++++++++++++ .../transaction/descriptor/explainPsbt.ts | 14 ++-- .../test/unit/transaction/descriptor/parse.ts | 9 ++- .../test/unit/transaction/descriptor/sign.ts | 25 +++++-- 18 files changed, 215 insertions(+), 131 deletions(-) create mode 100644 modules/abstract-utxo/src/wasmUtil.ts diff --git a/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts b/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts index f297adbb66..aefa7d3bc0 100644 --- a/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts +++ b/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts @@ -1,11 +1,9 @@ import assert from 'assert'; -import * as utxolib from '@bitgo/utxo-lib'; -import { Descriptor } from '@bitgo/wasm-utxo'; -import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; +import { Descriptor, address, descriptorWallet } from '@bitgo/wasm-utxo'; import { UtxoCoinSpecific, VerifyAddressOptions } from '../abstractUtxoCoin'; -import { getNetworkFromCoinName, UtxoCoinName } from '../names'; +import { UtxoCoinName } from '../names'; class DescriptorAddressMismatchError extends Error { constructor(descriptor: Descriptor, index: number, derivedAddress: string, expectedAddress: string) { @@ -18,7 +16,7 @@ class DescriptorAddressMismatchError extends Error { export function assertDescriptorWalletAddress( coinName: UtxoCoinName, params: VerifyAddressOptions, - descriptors: DescriptorMap + descriptors: descriptorWallet.DescriptorMap ): void { assert(params.coinSpecific); assert('descriptorName' in params.coinSpecific); @@ -35,8 +33,7 @@ export function assertDescriptorWalletAddress( ); } const derivedScript = Buffer.from(descriptor.atDerivationIndex(params.index).scriptPubkey()); - const network = getNetworkFromCoinName(coinName); - const derivedAddress = utxolib.address.fromOutputScript(derivedScript, network); + const derivedAddress = address.fromOutputScriptWithCoin(derivedScript, coinName); if (params.address !== derivedAddress) { throw new DescriptorAddressMismatchError(descriptor, params.index, derivedAddress, params.address); } diff --git a/modules/abstract-utxo/src/descriptor/descriptorWallet.ts b/modules/abstract-utxo/src/descriptor/descriptorWallet.ts index e235023bb3..691c42e129 100644 --- a/modules/abstract-utxo/src/descriptor/descriptorWallet.ts +++ b/modules/abstract-utxo/src/descriptor/descriptorWallet.ts @@ -1,8 +1,8 @@ import * as t from 'io-ts'; import { IWallet, WalletCoinSpecific } from '@bitgo/sdk-core'; -import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; import { UtxoWallet, UtxoWalletData } from '../wallet'; +import type { DescriptorMap } from '../wasmUtil'; import { NamedDescriptor } from './NamedDescriptor'; import { DescriptorValidationPolicy, KeyTriple, toDescriptorMapValidate } from './validatePolicy'; diff --git a/modules/abstract-utxo/src/descriptor/index.ts b/modules/abstract-utxo/src/descriptor/index.ts index d655525e21..cf72b63e55 100644 --- a/modules/abstract-utxo/src/descriptor/index.ts +++ b/modules/abstract-utxo/src/descriptor/index.ts @@ -1,5 +1,5 @@ export { Miniscript, Descriptor } from '@bitgo/wasm-utxo'; -export { DescriptorMap } from '@bitgo/utxo-core/descriptor'; +export type { DescriptorMap } from '../wasmUtil'; export { assertDescriptorWalletAddress } from './assertDescriptorWalletAddress'; export { NamedDescriptor, diff --git a/modules/abstract-utxo/src/descriptor/validatePolicy.ts b/modules/abstract-utxo/src/descriptor/validatePolicy.ts index 0af7d3cad3..2f90ed235a 100644 --- a/modules/abstract-utxo/src/descriptor/validatePolicy.ts +++ b/modules/abstract-utxo/src/descriptor/validatePolicy.ts @@ -1,6 +1,8 @@ import { EnvironmentName, Triple } from '@bitgo/sdk-core'; import * as utxolib from '@bitgo/utxo-lib'; -import { DescriptorMap, toDescriptorMap } from '@bitgo/utxo-core/descriptor'; +import { descriptorWallet } from '@bitgo/wasm-utxo'; + +import type { DescriptorMap } from '../wasmUtil'; import { parseDescriptor } from './builder'; import { hasValidSignature, NamedDescriptor, NamedDescriptorNative, toNamedDescriptorNative } from './NamedDescriptor'; @@ -91,7 +93,7 @@ export function toDescriptorMapValidate( toNamedDescriptorNative(v, 'derivable') ); assertDescriptorPolicy(namedDescriptorsNative, policy, walletKeys); - return toDescriptorMap(namedDescriptorsNative); + return descriptorWallet.toDescriptorMap(namedDescriptorsNative); } export function getPolicyForEnv(env: EnvironmentName): DescriptorValidationPolicy { diff --git a/modules/abstract-utxo/src/offlineVault/OfflineVaultHalfSigned.ts b/modules/abstract-utxo/src/offlineVault/OfflineVaultHalfSigned.ts index d61b2b7906..59f6401172 100644 --- a/modules/abstract-utxo/src/offlineVault/OfflineVaultHalfSigned.ts +++ b/modules/abstract-utxo/src/offlineVault/OfflineVaultHalfSigned.ts @@ -1,5 +1,5 @@ import { BIP32Interface, bip32 } from '@bitgo/secp256k1'; -import * as utxolib from '@bitgo/utxo-lib'; +import { Psbt } from '@bitgo/wasm-utxo'; import { BaseCoin } from '@bitgo/sdk-core'; import { UtxoCoinName } from '../names'; @@ -11,8 +11,8 @@ export type OfflineVaultHalfSigned = { halfSigned: { txHex: string }; }; -function createHalfSignedFromPsbt(psbt: utxolib.Psbt): OfflineVaultHalfSigned { - return { halfSigned: { txHex: psbt.toHex() } }; +function createHalfSignedFromPsbt(psbt: Psbt): OfflineVaultHalfSigned { + return { halfSigned: { txHex: Buffer.from(psbt.serialize()).toString('hex') } }; } export function createHalfSigned( diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index 223335e9fb..85575de304 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -1,5 +1,6 @@ -import * as utxolib from '@bitgo/utxo-lib'; import * as t from 'io-ts'; +import { Psbt } from '@bitgo/wasm-utxo'; +import type { BIP32Interface } from '@bitgo/utxo-lib'; import { DescriptorMap, NamedDescriptor } from '../../descriptor'; import { OfflineVaultSignable, toKeyTriple } from '../OfflineVaultSignable'; @@ -11,7 +12,8 @@ import { } from '../../descriptor/validatePolicy'; import { explainPsbt, signPsbt } from '../../transaction/descriptor'; import { TransactionExplanation } from '../TransactionExplanation'; -import { getNetworkFromCoinName, UtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; +import { toWasmPsbt } from '../../wasmUtil'; export const DescriptorTransaction = t.intersection( [OfflineVaultSignable, t.type({ descriptors: t.array(NamedDescriptor) })], @@ -32,13 +34,8 @@ export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransactio return toDescriptorMapValidate(descriptors, pubkeys, policy); } -export function getHalfSignedPsbt( - tx: DescriptorTransaction, - prv: utxolib.BIP32Interface, - coinName: UtxoCoinName -): utxolib.Psbt { - const network = getNetworkFromCoinName(coinName); - const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network); +export function getHalfSignedPsbt(tx: DescriptorTransaction, prv: BIP32Interface, coinName: UtxoCoinName): Psbt { + const psbt = toWasmPsbt(Buffer.from(tx.coinSpecific.txHex, 'hex')); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' }); return psbt; @@ -48,10 +45,9 @@ export function getTransactionExplanationFromPsbt( tx: DescriptorTransaction, coinName: UtxoCoinName ): TransactionExplanation { - const network = getNetworkFromCoinName(coinName); - const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network); + const psbt = toWasmPsbt(Buffer.from(tx.coinSpecific.txHex, 'hex')); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); - const { outputs, changeOutputs, fee } = explainPsbt(psbt, descriptorMap); + const { outputs, changeOutputs, fee } = explainPsbt(psbt, descriptorMap, coinName); return { outputs, changeOutputs, diff --git a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts index a7b277579d..fd68d897d3 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts @@ -1,53 +1,49 @@ -import * as utxolib from '@bitgo/utxo-lib'; import { ITransactionRecipient } from '@bitgo/sdk-core'; -import * as coreDescriptors from '@bitgo/utxo-core/descriptor'; +import { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; -import { toExtendedAddressFormat } from '../recipient'; import type { TransactionExplanationDescriptor } from '../fixedScript/explainTransaction'; -import { getCoinName, UtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; +import { sumValues } from '../../wasmUtil'; -function toRecipient(output: coreDescriptors.ParsedOutput, coinName: UtxoCoinName): ITransactionRecipient { +function toRecipient(output: descriptorWallet.ParsedOutput, coinName: UtxoCoinName): ITransactionRecipient { + const address = output.address ?? `scriptPubKey:${Buffer.from(output.script).toString('hex')}`; return { - address: toExtendedAddressFormat(output.script, coinName), + address, amount: output.value.toString(), }; } -function sumValues(arr: { value: bigint }[]): bigint { - return arr.reduce((sum, e) => sum + e.value, BigInt(0)); -} - -function getInputSignaturesForInputIndex(psbt: utxolib.bitgo.UtxoPsbt, inputIndex: number): number { - const { partialSig } = psbt.data.inputs[inputIndex]; - if (!partialSig) { +function getInputSignaturesForInputIndex(psbt: Psbt, inputIndex: number): number { + if (!psbt.hasPartialSignatures(inputIndex)) { return 0; } - return partialSig.reduce((agg, p) => { - const valid = psbt.validateSignaturesOfInputCommon(inputIndex, p.pubkey); + const partialSigs = psbt.getPartialSignatures(inputIndex); + return partialSigs.reduce((agg, p) => { + const valid = psbt.validateSignatureAtInput(inputIndex, p.pubkey); return agg + (valid ? 1 : 0); }, 0); } -function getInputSignatures(psbt: utxolib.bitgo.UtxoPsbt): number[] { - return psbt.data.inputs.map((_, i) => getInputSignaturesForInputIndex(psbt, i)); +function getInputSignatures(psbt: Psbt): number[] { + return Array.from({ length: psbt.inputCount() }, (_, i) => getInputSignaturesForInputIndex(psbt, i)); } export function explainPsbt( - psbt: utxolib.bitgo.UtxoPsbt, - descriptors: coreDescriptors.DescriptorMap + psbt: Psbt, + descriptors: descriptorWallet.DescriptorMap, + coinName: UtxoCoinName ): TransactionExplanationDescriptor { - const parsedTransaction = coreDescriptors.parse(psbt, descriptors, psbt.network); + const parsedTransaction = descriptorWallet.parse(psbt, descriptors, coinName); const { inputs, outputs } = parsedTransaction; const externalOutputs = outputs.filter((o) => o.scriptId === undefined); const changeOutputs = outputs.filter((o) => o.scriptId !== undefined); const fee = sumValues(inputs) - sumValues(outputs); const inputSignatures = getInputSignatures(psbt); - const coinName = getCoinName(psbt.network); return { inputSignatures, signatures: inputSignatures.reduce((a, b) => Math.min(a, b), Infinity), - locktime: psbt.locktime, - id: psbt.getUnsignedTx().getId(), + locktime: psbt.lockTime(), + id: psbt.unsignedTxId(), outputs: externalOutputs.map((o) => toRecipient(o, coinName)), outputAmount: sumValues(externalOutputs).toString(), changeOutputs: changeOutputs.map((o) => toRecipient(o, coinName)), diff --git a/modules/abstract-utxo/src/transaction/descriptor/index.ts b/modules/abstract-utxo/src/transaction/descriptor/index.ts index 4dbb0ee65f..7186ef1205 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/index.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/index.ts @@ -1,4 +1,4 @@ -export { DescriptorMap } from '@bitgo/utxo-core/descriptor'; +export type { DescriptorMap } from '../../wasmUtil'; export { explainPsbt } from './explainPsbt'; export { parse } from './parse'; export { parseToAmountType } from './parseToAmountType'; diff --git a/modules/abstract-utxo/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index 62cc2252e6..ce3911e57f 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -1,6 +1,5 @@ -import * as utxolib from '@bitgo/utxo-lib'; import { ITransactionRecipient } from '@bitgo/sdk-core'; -import * as coreDescriptors from '@bitgo/utxo-core/descriptor'; +import { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, ParseTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransaction, BaseParsedTransactionOutputs } from '../types'; @@ -10,8 +9,9 @@ import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient'; import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference'; import { UtxoCoinName } from '../../names'; +import { sumValues, toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil'; -type ParsedOutput = coreDescriptors.ParsedOutput; +type ParsedOutput = Omit & { script: Buffer }; export type RecipientOutput = Omit & { value: bigint | 'max'; @@ -22,6 +22,7 @@ function toRecipientOutput(recipient: ITransactionRecipient, coinName: UtxoCoinN address: recipient.address, value: recipient.amount === 'max' ? 'max' : BigInt(recipient.amount), script: fromExtendedAddressFormatToScript(recipient.address, coinName), + scriptId: undefined, // Recipients are external outputs }; } @@ -32,24 +33,25 @@ type ParsedOutputs = OutputDifferenceWithExpected }; function parseOutputsWithPsbt( - psbt: utxolib.bitgo.UtxoPsbt, - descriptorMap: coreDescriptors.DescriptorMap, - recipientOutputs: RecipientOutput[] + psbt: Psbt, + descriptorMap: descriptorWallet.DescriptorMap, + recipientOutputs: RecipientOutput[], + coinName: UtxoCoinName ): ParsedOutputs { - const parsed = coreDescriptors.parse(psbt, descriptorMap, psbt.network); - const changeOutputs = parsed.outputs.filter((o) => o.scriptId !== undefined); - const outputDiffs = outputDifferencesWithExpected(parsed.outputs, recipientOutputs); + const parsed = descriptorWallet.parse(psbt, descriptorMap, coinName); + const outputs: ParsedOutput[] = parsed.outputs.map((output) => ({ + ...output, + script: Buffer.from(output.script), + })); + const changeOutputs = outputs.filter((o) => o.scriptId !== undefined); + const outputDiffs = outputDifferencesWithExpected(outputs, recipientOutputs); return { - outputs: parsed.outputs, + outputs, changeOutputs, ...outputDiffs, }; } -function sumValues(arr: { value: bigint }[]): bigint { - return arr.reduce((sum, e) => sum + e.value, BigInt(0)); -} - function toBaseOutputs(outputs: ParsedOutput[], coinName: UtxoCoinName): BaseOutput[]; function toBaseOutputs(outputs: RecipientOutput[], coinName: UtxoCoinName): BaseOutput[]; function toBaseOutputs( @@ -85,16 +87,18 @@ function toBaseParsedTransactionOutputs( } export function toBaseParsedTransactionOutputsFromPsbt( - psbt: utxolib.bitgo.UtxoPsbt, - descriptorMap: coreDescriptors.DescriptorMap, + psbt: Psbt | UtxoLibPsbt | Uint8Array, + descriptorMap: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], coinName: UtxoCoinName ): ParsedOutputsBigInt { + const wasmPsbt = toWasmPsbt(psbt); return toBaseParsedTransactionOutputs( parseOutputsWithPsbt( - psbt, + wasmPsbt, descriptorMap, - recipients.map((r) => toRecipientOutput(r, coinName)) + recipients.map((r) => toRecipientOutput(r, coinName)), + coinName ), coinName ); @@ -125,13 +129,16 @@ export function parse( throw new Error('recipients is required'); } const psbt = coin.decodeTransactionFromPrebuild(params.txPrebuild); - if (!(psbt instanceof utxolib.bitgo.UtxoPsbt)) { - throw new Error('expected psbt to be an instance of UtxoPsbt'); + let wasmPsbt: Psbt; + try { + wasmPsbt = toWasmPsbt(psbt as Psbt | UtxoLibPsbt | Uint8Array); + } catch (e) { + throw new Error(`expected psbt to be a wasm-utxo or utxo-lib PSBT: ${e instanceof Error ? e.message : e}`); } const walletKeys = toBip32Triple(keychains); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); return { - ...toBaseParsedTransactionOutputsFromPsbt(psbt, descriptorMap, recipients, coin.name), + ...toBaseParsedTransactionOutputsFromPsbt(wasmPsbt, descriptorMap, recipients, coin.name), keychains, keySignatures: getKeySignatures(wallet) ?? {}, customChange: undefined, diff --git a/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts index 9aec62e48d..9d067da376 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts @@ -1,5 +1,6 @@ -import * as utxolib from '@bitgo/utxo-lib'; -import { DescriptorMap, findDescriptorForInput } from '@bitgo/utxo-core/descriptor'; +import { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; + +import type { SignerKey } from '../../wasmUtil'; export class ErrorUnknownInput extends Error { constructor(public vin: number) { @@ -14,33 +15,35 @@ export class ErrorUnknownInput extends Error { * found in the descriptor map, the behavior is determined by the `onUnknownInput` * parameter. * - * - * @param tx - psbt to sign - * @param descriptorMap - map of input index to descriptor - * @param signerKeychain - key to sign with + * @param psbt - psbt to sign + * @param descriptorMap - map of descriptor name to descriptor + * @param signerKey - key to sign with (BIP32 or ECPair) * @param params - onUnknownInput: 'throw' | 'skip' | 'sign'. * Determines what to do when an input is not found in the * descriptor map. */ export function signPsbt( - tx: utxolib.Psbt, - descriptorMap: DescriptorMap, - signerKeychain: utxolib.BIP32Interface, + psbt: Psbt, + descriptorMap: descriptorWallet.DescriptorMap, + signerKey: SignerKey, params: { onUnknownInput: 'throw' | 'skip' | 'sign'; } ): void { - for (const [vin, input] of tx.data.inputs.entries()) { - if (!findDescriptorForInput(input, descriptorMap)) { - switch (params.onUnknownInput) { - case 'skip': - continue; - case 'throw': - throw new ErrorUnknownInput(vin); - case 'sign': - break; - } + const inputs = psbt.getInputs(); + const unknownInputs = inputs + .map((input, vin) => ({ input, vin })) + .filter(({ input }) => !descriptorWallet.findDescriptorForInput(input, descriptorMap)); + + if (unknownInputs.length > 0) { + switch (params.onUnknownInput) { + case 'skip': + return; + case 'throw': + throw new ErrorUnknownInput(unknownInputs[0].vin); + case 'sign': + break; } - tx.signInputHD(vin, signerKeychain); } + descriptorWallet.signWithKey(psbt, signerKey); } diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 3774cfc068..699f915400 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -1,10 +1,10 @@ -import * as utxolib from '@bitgo/utxo-lib'; import { ITransactionRecipient, TxIntentMismatchError, IBaseCoin } from '@bitgo/sdk-core'; -import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; +import type { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; import { UtxoCoinName } from '../../names'; +import { toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -51,8 +51,8 @@ export function assertExpectedOutputDifference( } export function assertValidTransaction( - psbt: utxolib.bitgo.UtxoPsbt, - descriptors: DescriptorMap, + psbt: Psbt | UtxoLibPsbt | Uint8Array, + descriptors: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], coinName: UtxoCoinName ): void { @@ -74,16 +74,20 @@ export function assertValidTransaction( export async function verifyTransaction( coin: AbstractUtxoCoin, params: VerifyTransactionOptions, - descriptorMap: DescriptorMap + descriptorMap: descriptorWallet.DescriptorMap ): Promise { const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); - if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) { + let psbt: Psbt; + try { + psbt = toWasmPsbt(tx as Psbt | UtxoLibPsbt | Uint8Array); + } catch (e) { const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( coin as unknown as IBaseCoin, params.txPrebuild ); + const errorDetail = e instanceof Error ? e.message : String(e); throw new TxIntentMismatchError( - 'unexpected transaction type', + `unexpected transaction type: ${errorDetail}`, params.reqId, [params.txParams], params.txPrebuild.txHex, @@ -91,7 +95,7 @@ export async function verifyTransaction( ); } - assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], coin.name); + assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name); return true; } diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index c7d599654c..9ebe143ae4 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -7,6 +7,7 @@ import { toBip32Triple } from '../keychains'; import { getPolicyForEnv } from '../descriptor/validatePolicy'; import { UtxoCoinName } from '../names'; import type { Unspent } from '../unspent'; +import { toWasmPsbt } from '../wasmUtil'; import { getReplayProtectionPubkeys } from './fixedScript/replayProtection'; import type { @@ -42,7 +43,7 @@ export function explainTx( walletKeys, getPolicyForEnv(params.wallet.bitgo.env) ); - return descriptor.explainPsbt(tx, descriptors); + return descriptor.explainPsbt(toWasmPsbt(tx), descriptors, coinName); } throw new Error('legacy transactions are not supported for descriptor wallets'); diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts index 24b8c989fa..98b9cd8143 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts @@ -1,7 +1,9 @@ import assert from 'assert'; import { BIP32Interface } from '@bitgo/utxo-lib'; -import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { toWasmBIP32 } from '../../wasmUtil'; import { BulkSigningError, InputSigningError, TransactionSigningError } from './SigningError'; import { Musig2Participant } from './musig2'; @@ -76,11 +78,6 @@ export function signAndVerifyPsbtWasm( return tx; } -function toWasmBIP32(key: BIP32Interface): BIP32 { - // Convert using base58 string to ensure private key is properly transferred - return BIP32.fromBase58(key.toBase58()); -} - export async function signPsbtWithMusig2ParticipantWasm( coin: Musig2Participant, tx: fixedScriptWallet.BitGoPsbt, diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts index 65d962750a..44329d8d75 100644 --- a/modules/abstract-utxo/src/transaction/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -7,6 +7,7 @@ import buildDebug from 'debug'; import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin'; import { getDescriptorMapFromWallet, getPolicyForEnv, isDescriptorWallet } from '../descriptor'; import { fetchKeychains, toBip32Triple } from '../keychains'; +import { isUtxoLibPsbt, toWasmPsbt } from '../wasmUtil'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; @@ -53,16 +54,16 @@ export async function signTransaction( if (!signerKeychain) { throw new Error('missing signer'); } + if (!isUtxoLibPsbt(tx) && !(tx instanceof Uint8Array)) { + throw new Error('descriptor wallets require PSBT format transactions'); + } const walletKeys = toBip32Triple(await fetchKeychains(coin, wallet)); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(bitgo.env)); - if (tx instanceof utxolib.bitgo.UtxoPsbt) { - descriptor.signPsbt(tx, descriptorMap, signerKeychain, { - onUnknownInput: 'throw', - }); - return { txHex: tx.toHex() }; - } else { - throw new Error('expected a UtxoPsbt object'); - } + const psbt = toWasmPsbt(tx); + descriptor.signPsbt(psbt, descriptorMap, signerKeychain, { + onUnknownInput: 'throw', + }); + return { txHex: Buffer.from(psbt.serialize()).toString('hex') }; } else { const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.name, { walletId: params.txPrebuild.walletId, diff --git a/modules/abstract-utxo/src/wasmUtil.ts b/modules/abstract-utxo/src/wasmUtil.ts new file mode 100644 index 0000000000..1f67833d48 --- /dev/null +++ b/modules/abstract-utxo/src/wasmUtil.ts @@ -0,0 +1,66 @@ +import { BIP32, ECPair, Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; +import * as utxolib from '@bitgo/utxo-lib'; + +export type BIP32Key = BIP32 | utxolib.BIP32Interface; +export type ECPairKey = ECPair | utxolib.ECPairInterface | Uint8Array; +export type UtxoLibPsbt = utxolib.Psbt | utxolib.bitgo.UtxoPsbt; + +/** + * Map of descriptor name to Descriptor instance. + * Re-exported from wasm-utxo for consistency. + */ +export type DescriptorMap = descriptorWallet.DescriptorMap; + +/** + * Key type accepted by descriptorWallet.signWithKey + */ +export type SignerKey = Parameters[1]; + +/** + * Convert a utxo-lib BIP32Interface to a wasm-utxo BIP32 instance. + * Preserves private key by using base58 serialization. + */ +export function toWasmBIP32(key: BIP32Key): BIP32 { + if (key instanceof BIP32) { + return key; + } + // All utxo-lib BIP32Interface instances have toBase58 + return BIP32.fromBase58(key.toBase58()); +} + +export function toWasmECPair(key: ECPairKey): ECPair { + if (key instanceof ECPair) { + return key; + } + if (key instanceof Uint8Array) { + return ECPair.from(key); + } + if (key.privateKey) { + return ECPair.fromPrivateKey(key.privateKey); + } + return ECPair.fromPublicKey(key.publicKey); +} + +export function isUtxoLibPsbt(psbt: unknown): psbt is UtxoLibPsbt { + return psbt instanceof utxolib.Psbt || psbt instanceof utxolib.bitgo.UtxoPsbt; +} + +export function toWasmPsbt(psbt: Psbt | UtxoLibPsbt | Uint8Array): Psbt { + if (psbt instanceof Psbt) { + return psbt; + } + if (psbt instanceof Uint8Array) { + return Psbt.deserialize(psbt); + } + if (isUtxoLibPsbt(psbt)) { + return Psbt.deserialize(psbt.toBuffer()); + } + throw new Error('Unsupported PSBT type'); +} + +/** + * Sum the `value` property of an array of objects. + */ +export function sumValues(arr: { value: bigint }[]): bigint { + return arr.reduce((sum, e) => sum + e.value, BigInt(0)); +} diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/explainPsbt.ts index 29dfd4e490..4919d4570e 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/explainPsbt.ts @@ -2,9 +2,11 @@ import assert from 'assert'; import { getKeyTriple } from '@bitgo/utxo-core/testutil'; import { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } from '@bitgo/utxo-core/testutil/descriptor'; +import { descriptorWallet } from '@bitgo/wasm-utxo'; import type { TransactionExplanation } from '../../../../src/transaction/fixedScript/explainTransaction'; import { explainPsbt } from '../../../../src/transaction/descriptor'; +import { toWasmPsbt } from '../../../../src/wasmUtil'; import { getFixtureRoot } from './fixtures.utils'; @@ -19,13 +21,13 @@ function assertSignatureCount(expl: TransactionExplanation, signatures: number, describe('explainPsbt', function () { it('has expected values', async function () { - const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + const psbt = toWasmPsbt(mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3')); const keys = getKeyTriple('a'); const descriptorMap = getDescriptorMap('Wsh2Of3', keys); - await assertEqualFixture('explainPsbt.a.json', explainPsbt(psbt, descriptorMap)); - psbt.signAllInputsHD(keys[0]); - assertSignatureCount(explainPsbt(psbt, descriptorMap), 1, [1, 1]); - psbt.signAllInputsHD(keys[1]); - assertSignatureCount(explainPsbt(psbt, descriptorMap), 2, [2, 2]); + await assertEqualFixture('explainPsbt.a.json', explainPsbt(psbt, descriptorMap, 'btc')); + descriptorWallet.signWithKey(psbt, keys[0]); + assertSignatureCount(explainPsbt(psbt, descriptorMap, 'btc'), 1, [1, 1]); + descriptorWallet.signWithKey(psbt, keys[1]); + assertSignatureCount(explainPsbt(psbt, descriptorMap, 'btc'), 2, [2, 2]); }); }); diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts index 9287c4fbc5..e1252fd929 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts @@ -1,7 +1,6 @@ import assert from 'assert'; -import * as utxolib from '@bitgo/utxo-lib'; -import { Descriptor } from '@bitgo/wasm-utxo'; +import { Descriptor, descriptorWallet } from '@bitgo/wasm-utxo'; import { getDefaultXPubs, getDescriptor, @@ -9,12 +8,12 @@ import { mockPsbtDefault, } from '@bitgo/utxo-core/testutil/descriptor'; import { toPlainObject } from '@bitgo/utxo-core/testutil'; -import { createAddressFromDescriptor } from '@bitgo/utxo-core/descriptor'; import { ParsedOutputsBigInt, toBaseParsedTransactionOutputsFromPsbt, } from '../../../../src/transaction/descriptor/parse'; +import type { UtxoLibPsbt } from '../../../../src/wasmUtil'; import { AggregateValidationError, assertExpectedOutputDifference, @@ -62,7 +61,7 @@ describe('parse', function () { const psbt = mockPsbtDefault({ descriptorSelf, descriptorOther }); function recipient(descriptor: Descriptor, index: number, value = 1000) { - return { value, address: createAddressFromDescriptor(descriptor, index, utxolib.networks.bitcoin) }; + return { value, address: descriptorWallet.createAddressFromDescriptor(descriptor, index, 'btc') }; } function internalRecipient(index: number, value?: number): OutputWithValue { @@ -73,7 +72,7 @@ describe('parse', function () { return recipient(descriptorOther, index, value); } - function getBaseParsedTransaction(psbt: utxolib.bitgo.UtxoPsbt, recipients: OutputWithValue[]): ParsedOutputsBigInt { + function getBaseParsedTransaction(psbt: UtxoLibPsbt, recipients: OutputWithValue[]): ParsedOutputsBigInt { return toBaseParsedTransactionOutputsFromPsbt( psbt, getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')), diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/sign.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/sign.ts index 93ca504cf2..250ef621eb 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/sign.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/sign.ts @@ -2,30 +2,43 @@ import assert from 'assert'; import { getKeyTriple } from '@bitgo/utxo-core/testutil'; import { getDescriptorMap, mockPsbtDefaultWithDescriptorTemplate } from '@bitgo/utxo-core/testutil/descriptor'; +import { Psbt } from '@bitgo/wasm-utxo'; import { signPsbt } from '../../../../src/transaction/descriptor'; import { ErrorUnknownInput } from '../../../../src/transaction/descriptor/signPsbt'; +import { toWasmPsbt } from '../../../../src/wasmUtil'; + +function assertInputHasValidSignatures(psbt: Psbt, inputIndex: number) { + assert(psbt.hasPartialSignatures(inputIndex)); + const partialSigs = psbt.getPartialSignatures(inputIndex); + assert(partialSigs.length > 0); + for (const sig of partialSigs) { + assert(psbt.validateSignatureAtInput(inputIndex, sig.pubkey)); + } +} describe('sign', function () { - const psbtUnsigned = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + const psbtUnsigned = toWasmPsbt(mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3')); const keychain = getKeyTriple('a'); const descriptorMap = getDescriptorMap('Wsh2Of3', keychain); const emptyDescriptorMap = new Map(); it('should sign a transaction', async function () { - const psbt = psbtUnsigned.clone(); + const psbt = Psbt.deserialize(psbtUnsigned.serialize()); signPsbt(psbt, descriptorMap, keychain[0], { onUnknownInput: 'throw' }); - assert(psbt.validateSignaturesOfAllInputs()); + assertInputHasValidSignatures(psbt, 0); + assertInputHasValidSignatures(psbt, 1); }); it('should be sensitive to onUnknownInput', async function () { - const psbt = psbtUnsigned.clone(); + const psbt = Psbt.deserialize(psbtUnsigned.serialize()); assert.throws(() => { signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'throw' }); }, new ErrorUnknownInput(0)); signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'skip' }); - assert(psbt.data.inputs[0].partialSig === undefined); + assert(!psbt.hasPartialSignatures(0)); signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'sign' }); - assert(psbt.validateSignaturesOfAllInputs()); + assertInputHasValidSignatures(psbt, 0); + assertInputHasValidSignatures(psbt, 1); }); });