diff --git a/modules/abstract-utxo/src/descriptor/descriptorWallet.ts b/modules/abstract-utxo/src/descriptor/descriptorWallet.ts index 2259e2b890..0f9c3e7c4d 100644 --- a/modules/abstract-utxo/src/descriptor/descriptorWallet.ts +++ b/modules/abstract-utxo/src/descriptor/descriptorWallet.ts @@ -4,7 +4,7 @@ import { IWallet, WalletCoinSpecific } from '@bitgo/sdk-core'; import { NamedDescriptor } from './NamedDescriptor'; import { DescriptorMap } from '../core/descriptor'; import { DescriptorValidationPolicy, KeyTriple, toDescriptorMapValidate } from './validatePolicy'; -import { UtxoWalletData } from '../wallet'; +import { UtxoWallet, UtxoWalletData } from '../wallet'; type DescriptorWalletCoinSpecific = { descriptors: NamedDescriptor[]; @@ -20,7 +20,7 @@ type DescriptorWalletData = UtxoWalletData & { coinSpecific: DescriptorWalletCoinSpecific; }; -interface IDescriptorWallet extends IWallet { +export interface IDescriptorWallet extends UtxoWallet { coinSpecific(): WalletCoinSpecific & DescriptorWalletCoinSpecific; } diff --git a/modules/abstract-utxo/src/descriptor/index.ts b/modules/abstract-utxo/src/descriptor/index.ts index 7206df8c3e..ebd32d2e9c 100644 --- a/modules/abstract-utxo/src/descriptor/index.ts +++ b/modules/abstract-utxo/src/descriptor/index.ts @@ -2,3 +2,4 @@ export { Miniscript, Descriptor } from '@bitgo/wasm-miniscript'; export { assertDescriptorWalletAddress } from './assertDescriptorWalletAddress'; export { NamedDescriptor } from './NamedDescriptor'; export { isDescriptorWallet, getDescriptorMapFromWallet } from './descriptorWallet'; +export { getPolicyForEnv } from './validatePolicy'; diff --git a/modules/abstract-utxo/src/keychains.ts b/modules/abstract-utxo/src/keychains.ts index bb5a77a413..4b4b6b3324 100644 --- a/modules/abstract-utxo/src/keychains.ts +++ b/modules/abstract-utxo/src/keychains.ts @@ -45,11 +45,17 @@ export function toKeychainTriple(keychains: UtxoNamedKeychains): Triple | Triple): Triple { - return keychains.map((keychain: { pub: string } | string) => { - const v = typeof keychain === 'string' ? keychain : keychain.pub; - return utxolib.bip32.fromBase58(v); - }) as Triple; +export function toBip32Triple( + keychains: UtxoNamedKeychains | Triple<{ pub: string }> | Triple +): Triple { + if (Array.isArray(keychains)) { + return keychains.map((keychain: { pub: string } | string) => { + const v = typeof keychain === 'string' ? keychain : keychain.pub; + return utxolib.bip32.fromBase58(v); + }) as Triple; + } + + return toBip32Triple(toKeychainTriple(keychains)); } export async function fetchKeychains( diff --git a/modules/abstract-utxo/src/transaction/descriptor/index.ts b/modules/abstract-utxo/src/transaction/descriptor/index.ts index 309dca1d3f..857716f36b 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/index.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/index.ts @@ -1,2 +1,4 @@ export { DescriptorMap } from '../../core/descriptor'; 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 new file mode 100644 index 0000000000..9cc73b5de6 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -0,0 +1,130 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { ITransactionRecipient } from '@bitgo/sdk-core'; + +import { + AbstractUtxoCoin, + BaseOutput, + BaseParsedTransaction, + BaseParsedTransactionOutputs, + ParseTransactionOptions, +} from '../../abstractUtxoCoin'; +import { getKeySignatures, toBip32Triple, UtxoNamedKeychains } from '../../keychains'; +import { getDescriptorMapFromWallet, getPolicyForEnv } from '../../descriptor'; +import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; +import * as coreDescriptors from '../../core/descriptor'; +import { ParsedOutput } from '../../core/descriptor/psbt/parse'; +import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from './outputDifference'; +import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient'; + +function toParsedOutput(recipient: ITransactionRecipient, network: utxolib.Network): ParsedOutput { + return { + address: recipient.address, + value: BigInt(recipient.amount), + script: fromExtendedAddressFormatToScript(recipient.address, network), + }; +} + +type ParsedOutputs = OutputDifferenceWithExpected & { + outputs: ParsedOutput[]; + changeOutputs: ParsedOutput[]; +}; + +function parseOutputsWithPsbt( + psbt: utxolib.bitgo.UtxoPsbt, + descriptorMap: coreDescriptors.DescriptorMap, + recipientOutputs: ParsedOutput[] +): ParsedOutputs { + const parsed = coreDescriptors.parse(psbt, descriptorMap, psbt.network); + const externalOutputs = parsed.outputs.filter((o) => o.scriptId === undefined); + const changeOutputs = parsed.outputs.filter((o) => o.scriptId !== undefined); + const outputDiffs = outputDifferencesWithExpected(externalOutputs, recipientOutputs); + return { + outputs: parsed.outputs, + changeOutputs, + ...outputDiffs, + }; +} + +function sumValues(arr: { value: bigint }[]): bigint { + return arr.reduce((sum, e) => sum + e.value, BigInt(0)); +} + +function toBaseOutputs(outputs: ParsedOutput[], network: utxolib.Network): BaseOutput[] { + return outputs.map( + (o): BaseOutput => ({ + address: toExtendedAddressFormat(o.script, network), + amount: BigInt(o.value), + external: o.scriptId === undefined, + }) + ); +} + +function toBaseParsedTransactionOutputs( + { outputs, changeOutputs, explicitExternalOutputs, implicitExternalOutputs, missingOutputs }: ParsedOutputs, + network: utxolib.Network +): BaseParsedTransactionOutputs> { + return { + outputs: toBaseOutputs(outputs, network), + changeOutputs: toBaseOutputs(changeOutputs, network), + explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs, network), + explicitExternalSpendAmount: sumValues(explicitExternalOutputs), + implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs, network), + implicitExternalSpendAmount: sumValues(implicitExternalOutputs), + missingOutputs: toBaseOutputs(missingOutputs, network), + }; +} + +export function toBaseParsedTransactionOutputsFromPsbt( + psbt: utxolib.bitgo.UtxoPsbt, + descriptorMap: coreDescriptors.DescriptorMap, + recipients: ITransactionRecipient[], + network: utxolib.Network +): BaseParsedTransactionOutputs> { + return toBaseParsedTransactionOutputs( + parseOutputsWithPsbt( + psbt, + descriptorMap, + recipients.map((r) => toParsedOutput(r, psbt.network)) + ), + network + ); +} + +export type ParsedDescriptorTransaction = BaseParsedTransaction< + TAmount, + BaseOutput +>; + +export function parse( + coin: AbstractUtxoCoin, + wallet: IDescriptorWallet, + params: ParseTransactionOptions +): ParsedDescriptorTransaction { + if (params.txParams.allowExternalChangeAddress) { + throw new Error('allowExternalChangeAddress is not supported for descriptor wallets'); + } + if (params.txParams.changeAddress) { + throw new Error('changeAddress is not supported for descriptor wallets'); + } + const keychains = params.verification?.keychains; + if (!keychains || !UtxoNamedKeychains.is(keychains)) { + throw new Error('keychain is required for descriptor wallets'); + } + const { recipients } = params.txParams; + if (!recipients) { + 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'); + } + const walletKeys = toBip32Triple(keychains); + const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); + return { + ...toBaseParsedTransactionOutputsFromPsbt(psbt, descriptorMap, recipients, psbt.network), + keychains, + keySignatures: getKeySignatures(wallet) ?? {}, + customChange: undefined, + needsCustomChangeKeySignatureVerification: false, + }; +} diff --git a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts new file mode 100644 index 0000000000..7706e8181c --- /dev/null +++ b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts @@ -0,0 +1,80 @@ +import { AbstractUtxoCoin, BaseOutput, BaseParsedTransaction, ParseTransactionOptions } from '../../abstractUtxoCoin'; +import { parse, ParsedDescriptorTransaction } from './parse'; +import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; + +type AmountType = 'number' | 'bigint' | 'string'; + +function toAmountType(v: number | bigint | string, t: AmountType): number | bigint | string { + switch (t) { + case 'number': + return Number(v); + case 'bigint': + return BigInt(v); + case 'string': + return String(v); + } +} + +type AmountTypeOptions = { + amountTypeBaseOutput: AmountType; + amountTypeAggregate: AmountType; +}; + +function baseOutputToTNumber( + output: BaseOutput, + amountType: AmountType +): BaseOutput { + return { + address: output.address, + amount: toAmountType(output.amount, amountType) as TAmount, + external: output.external, + }; +} + +function entryToTNumber< + K extends keyof ParsedDescriptorTransaction, + V extends ParsedDescriptorTransaction[K] +>(k: K, v: V, params: AmountTypeOptions): [K, V] { + switch (k) { + case 'outputs': + case 'changeOutputs': + case 'explicitExternalOutputs': + case 'implicitExternalOutputs': + case 'missingOutputs': + if (v === undefined) { + return [k, v]; + } + if (Array.isArray(v)) { + return [k, v.map((o) => baseOutputToTNumber(o, params.amountTypeBaseOutput)) as V]; + } + throw new Error('expected array'); + case 'explicitExternalSpendAmount': + case 'implicitExternalSpendAmount': + if (typeof v !== 'bigint') { + throw new Error('expected bigint'); + } + return [k, toAmountType(v, params.amountTypeAggregate) as V]; + default: + return [k, v]; + } +} + +export function parsedDescriptorTransactionToTNumber( + obj: ParsedDescriptorTransaction, + params: AmountTypeOptions +): BaseParsedTransaction { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => entryToTNumber(k as keyof ParsedDescriptorTransaction, v, params)) + ) as BaseParsedTransaction; +} + +export function parseToAmountType( + coin: AbstractUtxoCoin, + wallet: IDescriptorWallet, + params: ParseTransactionOptions +): BaseParsedTransaction> { + return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params), { + amountTypeAggregate: coin.amountType, + amountTypeBaseOutput: 'string', + }); +} diff --git a/modules/abstract-utxo/src/transaction/descriptor/recipient.ts b/modules/abstract-utxo/src/transaction/descriptor/recipient.ts new file mode 100644 index 0000000000..d285d7ea8d --- /dev/null +++ b/modules/abstract-utxo/src/transaction/descriptor/recipient.ts @@ -0,0 +1,4 @@ +export type Recipient = { + address: string; + amount: bigint; +}; diff --git a/modules/abstract-utxo/src/transaction/parseTransaction.ts b/modules/abstract-utxo/src/transaction/parseTransaction.ts index d502afcec2..ae0dffb558 100644 --- a/modules/abstract-utxo/src/transaction/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/parseTransaction.ts @@ -2,6 +2,7 @@ import { AbstractUtxoCoin, ParsedTransaction, ParseTransactionOptions } from '.. import { isDescriptorWallet } from '../descriptor'; +import * as descriptor from './descriptor'; import * as fixedScript from './fixedScript'; export async function parseTransaction( @@ -9,7 +10,7 @@ export async function parseTransaction( params: ParseTransactionOptions ): Promise> { if (isDescriptorWallet(params.wallet)) { - throw new Error('Descriptor wallets are not supported'); + return descriptor.parseToAmountType(coin, params.wallet, params); } else { return fixedScript.parseTransaction(coin, params); } diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 27ccf03be2..90d44d73d3 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -22,6 +22,14 @@ export function fromExtendedAddressFormat(extendedAddress: string): { address: s return { address: extendedAddress }; } +export function fromExtendedAddressFormatToScript(extendedAddress: string, network: utxolib.Network): Buffer { + const result = fromExtendedAddressFormat(extendedAddress); + if ('script' in result) { + return Buffer.from(result.script, 'hex'); + } + return utxolib.addressFormat.toOutputScriptTryFormats(result.address, network); +} + /** * Convert a script or address to the extended address format. * @param script diff --git a/modules/abstract-utxo/test/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/test/transaction/descriptor/explainPsbt.ts index 984808773a..86443a1dfd 100644 --- a/modules/abstract-utxo/test/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/test/transaction/descriptor/explainPsbt.ts @@ -1,15 +1,11 @@ import assert from 'assert'; +import { TransactionExplanation } from '../../../src'; import { explainPsbt } from '../../../src/transaction/descriptor'; import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils'; import { getDescriptorMap } from '../../core/descriptor/descriptor.utils'; -import { getFixture } from '../../core/fixtures.utils'; import { getKeyTriple } from '../../core/key.utils'; -import { TransactionExplanation } from '../../../src'; - -async function assertEqualFixture(name: string, v: unknown) { - assert.deepStrictEqual(v, await getFixture(__dirname + '/fixtures/' + name, v)); -} +import { assertEqualFixture } from './fixtures.utils'; function assertSignatureCount(expl: TransactionExplanation, signatures: number, inputSignatures: number[]) { assert.deepStrictEqual(expl.signatures, signatures); diff --git a/modules/abstract-utxo/test/transaction/descriptor/fixtures.utils.ts b/modules/abstract-utxo/test/transaction/descriptor/fixtures.utils.ts new file mode 100644 index 0000000000..22f6e377b0 --- /dev/null +++ b/modules/abstract-utxo/test/transaction/descriptor/fixtures.utils.ts @@ -0,0 +1,7 @@ +import assert from 'assert'; + +import { getFixture } from '../../core/fixtures.utils'; + +export async function assertEqualFixture(name: string, v: unknown): Promise { + assert.deepStrictEqual(v, await getFixture(__dirname + '/fixtures/' + name, v)); +} diff --git a/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithRecipient.json b/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithRecipient.json new file mode 100644 index 0000000000..ab6e85e3a1 --- /dev/null +++ b/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithRecipient.json @@ -0,0 +1,32 @@ +{ + "outputs": [ + { + "address": "bc1qvn279lx29cg843u77p3c37npay7w4uc4xw5d92xxa92z8gd3lkuq4w8477", + "amount": "400000", + "external": true + }, + { + "address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj", + "amount": "400000", + "external": false + } + ], + "changeOutputs": [ + { + "address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj", + "amount": "400000", + "external": false + } + ], + "explicitExternalOutputs": [ + { + "address": "bc1qvn279lx29cg843u77p3c37npay7w4uc4xw5d92xxa92z8gd3lkuq4w8477", + "amount": "400000", + "external": true + } + ], + "explicitExternalSpendAmount": "400000", + "implicitExternalOutputs": [], + "implicitExternalSpendAmount": "0", + "missingOutputs": [] +} diff --git a/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithoutRecipients.json b/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithoutRecipients.json new file mode 100644 index 0000000000..0f8cf3700d --- /dev/null +++ b/modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithoutRecipients.json @@ -0,0 +1,32 @@ +{ + "outputs": [ + { + "address": "bc1qvn279lx29cg843u77p3c37npay7w4uc4xw5d92xxa92z8gd3lkuq4w8477", + "amount": "400000", + "external": true + }, + { + "address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj", + "amount": "400000", + "external": false + } + ], + "changeOutputs": [ + { + "address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj", + "amount": "400000", + "external": false + } + ], + "explicitExternalOutputs": [], + "explicitExternalSpendAmount": "0", + "implicitExternalOutputs": [ + { + "address": "bc1qvn279lx29cg843u77p3c37npay7w4uc4xw5d92xxa92z8gd3lkuq4w8477", + "amount": "400000", + "external": true + } + ], + "implicitExternalSpendAmount": "400000", + "missingOutputs": [] +} diff --git a/modules/abstract-utxo/test/transaction/descriptor/parse.ts b/modules/abstract-utxo/test/transaction/descriptor/parse.ts new file mode 100644 index 0000000000..2975dfd13d --- /dev/null +++ b/modules/abstract-utxo/test/transaction/descriptor/parse.ts @@ -0,0 +1,48 @@ +import assert from 'assert'; +import * as utxolib from '@bitgo/utxo-lib'; +import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils'; +import { toBaseParsedTransactionOutputsFromPsbt } from '../../../src/transaction/descriptor/parse'; +import { getDefaultXPubs, getDescriptorMap } from '../../core/descriptor/descriptor.utils'; +import { assertEqualFixture } from './fixtures.utils'; +import { toPlainObject } from '../../core/toPlainObject.utils'; + +function toRecipient(output: utxolib.PsbtTxOutput): { + address: string; + amount: string; +} { + assert(output.address); + return { + address: output.address, + amount: output.value.toString(), + }; +} + +describe('parse', function () { + describe('toBase', function () { + it('should return the correct BaseParsedTransactionOutputs', async function () { + const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + await assertEqualFixture( + 'parseWithoutRecipients.json', + toPlainObject( + toBaseParsedTransactionOutputsFromPsbt( + psbt, + getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')), + [], + psbt.network + ) + ) + ); + await assertEqualFixture( + 'parseWithRecipient.json', + toPlainObject( + toBaseParsedTransactionOutputsFromPsbt( + psbt, + getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')), + [toRecipient(psbt.txOutputs[0])], + psbt.network + ) + ) + ); + }); + }); +});