Skip to content

Commit

Permalink
feat(abstract-utxo): implement parseTransaction for descriptor
Browse files Browse the repository at this point in the history
TICKET: BTC-1450
  • Loading branch information
OttoAllmendinger committed Dec 16, 2024
1 parent b658ab5 commit 9573556
Show file tree
Hide file tree
Showing 14 changed files with 361 additions and 14 deletions.
4 changes: 2 additions & 2 deletions modules/abstract-utxo/src/descriptor/descriptorWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -20,7 +20,7 @@ type DescriptorWalletData = UtxoWalletData & {
coinSpecific: DescriptorWalletCoinSpecific;
};

interface IDescriptorWallet extends IWallet {
export interface IDescriptorWallet extends UtxoWallet {
coinSpecific(): WalletCoinSpecific & DescriptorWalletCoinSpecific;
}

Expand Down
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/descriptor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 11 additions & 5 deletions modules/abstract-utxo/src/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,17 @@ export function toKeychainTriple(keychains: UtxoNamedKeychains): Triple<UtxoKeyc
return [user, backup, bitgo];
}

export function toBip32Triple(keychains: Triple<{ pub: string }> | Triple<string>): Triple<utxolib.BIP32Interface> {
return keychains.map((keychain: { pub: string } | string) => {
const v = typeof keychain === 'string' ? keychain : keychain.pub;
return utxolib.bip32.fromBase58(v);
}) as Triple<utxolib.BIP32Interface>;
export function toBip32Triple(
keychains: UtxoNamedKeychains | Triple<{ pub: string }> | Triple<string>
): Triple<utxolib.BIP32Interface> {
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<utxolib.BIP32Interface>;
}

return toBip32Triple(toKeychainTriple(keychains));
}

export async function fetchKeychains(
Expand Down
2 changes: 2 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { DescriptorMap } from '../../core/descriptor';
export { explainPsbt } from './explainPsbt';
export { parse } from './parse';
export { parseToAmountType } from './parseToAmountType';
130 changes: 130 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/parse.ts
Original file line number Diff line number Diff line change
@@ -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<ParsedOutput> & {
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<bigint>[] {
return outputs.map(
(o): BaseOutput<bigint> => ({
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<bigint, BaseOutput<bigint>> {
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<bigint, BaseOutput<bigint>> {
return toBaseParsedTransactionOutputs(
parseOutputsWithPsbt(
psbt,
descriptorMap,
recipients.map((r) => toParsedOutput(r, psbt.network))
),
network
);
}

export type ParsedDescriptorTransaction<TAmount extends number | bigint> = BaseParsedTransaction<
TAmount,
BaseOutput<TAmount>
>;

export function parse(
coin: AbstractUtxoCoin,
wallet: IDescriptorWallet,
params: ParseTransactionOptions<number | bigint>
): ParsedDescriptorTransaction<bigint> {
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,
};
}
Original file line number Diff line number Diff line change
@@ -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<TAmount extends number | bigint>(
output: BaseOutput<bigint>,
amountType: AmountType
): BaseOutput<TAmount> {
return {
address: output.address,
amount: toAmountType(output.amount, amountType) as TAmount,
external: output.external,
};
}

function entryToTNumber<
K extends keyof ParsedDescriptorTransaction<bigint>,
V extends ParsedDescriptorTransaction<bigint>[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<TAmount extends number | bigint, TOutput>(
obj: ParsedDescriptorTransaction<bigint>,
params: AmountTypeOptions
): BaseParsedTransaction<TAmount, TOutput> {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => entryToTNumber(k as keyof ParsedDescriptorTransaction<bigint>, v, params))
) as BaseParsedTransaction<TAmount, TOutput>;
}

export function parseToAmountType<TAmount extends number | bigint>(
coin: AbstractUtxoCoin,
wallet: IDescriptorWallet,
params: ParseTransactionOptions<TAmount>
): BaseParsedTransaction<TAmount, BaseOutput<string>> {
return parsedDescriptorTransactionToTNumber<TAmount, BaseOutput<string>>(parse(coin, wallet, params), {
amountTypeAggregate: coin.amountType,
amountTypeBaseOutput: 'string',
});
}
4 changes: 4 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/recipient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Recipient = {
address: string;
amount: bigint;
};
3 changes: 2 additions & 1 deletion modules/abstract-utxo/src/transaction/parseTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { AbstractUtxoCoin, ParsedTransaction, ParseTransactionOptions } from '..

import { isDescriptorWallet } from '../descriptor';

import * as descriptor from './descriptor';
import * as fixedScript from './fixedScript';

export async function parseTransaction<TNumber extends bigint | number>(
coin: AbstractUtxoCoin,
params: ParseTransactionOptions<TNumber>
): Promise<ParsedTransaction<TNumber>> {
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);
}
Expand Down
8 changes: 8 additions & 0 deletions modules/abstract-utxo/src/transaction/recipient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import assert from 'assert';

import { getFixture } from '../../core/fixtures.utils';

export async function assertEqualFixture(name: string, v: unknown): Promise<void> {
assert.deepStrictEqual(v, await getFixture(__dirname + '/fixtures/' + name, v));
}
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading

0 comments on commit 9573556

Please sign in to comment.