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 13, 2024
1 parent b7eac0e commit d13a956
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 13 deletions.
2 changes: 1 addition & 1 deletion modules/abstract-utxo/src/descriptor/descriptorWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type DescriptorWalletData = UtxoWalletData & {
coinSpecific: DescriptorWalletCoinSpecific;
};

interface IDescriptorWallet extends IWallet {
export interface IDescriptorWallet extends IWallet {
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';
46 changes: 35 additions & 11 deletions modules/abstract-utxo/src/keychains.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,65 @@
import * as t from 'io-ts';
import assert from 'assert';
import * as utxolib from '@bitgo/utxo-lib';
import { IRequestTracer, IWallet, Keychain, KeyIndices, promiseProps, Triple } from '@bitgo/sdk-core';

import { AbstractUtxoCoin } from './abstractUtxoCoin';
import { UtxoWallet } from './wallet';

export type NamedKeychains = {
type UsefulKeychain = Keychain & { pub: string };

function isUsefulKeychain(keychain: Keychain): keychain is UsefulKeychain {
return !!keychain.pub;
}

export function isUsefulNamedKeychains(v: {
user?: Keychain;
backup?: Keychain;
bitgo?: Keychain;
}): v is NamedKeychains {
return Boolean(
v.user && isUsefulKeychain(v.user) && v.backup && isUsefulKeychain(v.backup) && v.bitgo && isUsefulKeychain(v.bitgo)
);
}

export type NamedKeychains = {
user: UsefulKeychain;
backup: UsefulKeychain;
bitgo: UsefulKeychain;
};

export function toKeychainTriple(keychains: NamedKeychains): Triple<Keychain> {
export function toKeychainTriple(keychains: NamedKeychains): Triple<UsefulKeychain> {
const { user, backup, bitgo } = keychains;
if (!user || !backup || !bitgo) {
throw new Error('keychains must include user, backup, and bitgo');
}
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: NamedKeychains | 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(
coin: AbstractUtxoCoin,
wallet: IWallet,
reqId?: IRequestTracer
): Promise<NamedKeychains> {
return promiseProps({
const { user, backup, bitgo } = await promiseProps({
user: coin.keychains().get({ id: wallet.keyIds()[KeyIndices.USER], reqId }),
backup: coin.keychains().get({ id: wallet.keyIds()[KeyIndices.BACKUP], reqId }),
bitgo: coin.keychains().get({ id: wallet.keyIds()[KeyIndices.BITGO], reqId }),
});
assert(user && isUsefulKeychain(user));
assert(backup && isUsefulKeychain(backup));
assert(bitgo && isUsefulKeychain(bitgo));
return { user, backup, bitgo };
}

export const KeySignatures = t.partial({
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/transaction/descriptor/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { DescriptorMap } from '../../core/descriptor';
export { explainPsbt } from './explainPsbt';
export { parse, parse } from './parse';
124 changes: 124 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as utxolib from '@bitgo/utxo-lib';
import { ITransactionRecipient } from '@bitgo/sdk-core';

import { AbstractUtxoCoin, BaseOutput, BaseParsedTransaction, ParseTransactionOptions } from '../../abstractUtxoCoin';
import { isUsefulNamedKeychains, NamedKeychains, toBip32Triple } 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 { parsedDescriptorTransactionToTNumber } from './toTNumber';

function toParsedOutput(
coin: AbstractUtxoCoin,
recipient: ITransactionRecipient,
network: utxolib.Network
): ParsedOutput {
return {
address: recipient.address,
value: BigInt(recipient.amount),
script: coin.addressToScript(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,
};
}

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

function sumValues(arr: { value: bigint }[]): bigint {
return arr.reduce((sum, e) => sum + e.value, BigInt(0));
}

function toBaseOutputs(outputs: ParsedOutput[]): BaseOutput<bigint>[] {
return outputs.map((o) => ({
address: o.address,
amount: o.value,
external: o.scriptId === undefined,
}));
}

function toParsedTransaction(
{ outputs, changeOutputs, explicitExternalOutputs, implicitExternalOutputs, missingOutputs }: ParsedOutputs,
{
keychains,
keySignatures,
}: {
keychains: NamedKeychains;
keySignatures: Record<string, string>;
}
): ParsedDescriptorTransaction<bigint> {
return {
outputs: toBaseOutputs(outputs),
changeOutputs: toBaseOutputs(changeOutputs),
explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs),
explicitExternalSpendAmount: sumValues(explicitExternalOutputs),
implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs),
implicitExternalSpendAmount: sumValues(implicitExternalOutputs),
missingOutputs: toBaseOutputs(missingOutputs),
keychains,
keySignatures,
customChange: undefined,
needsCustomChangeKeySignatureVerification: false,
};
}

export function parse<TNumber extends number | bigint>(
coin: AbstractUtxoCoin,
wallet: IDescriptorWallet,
params: ParseTransactionOptions<TNumber>
): ParsedDescriptorTransaction<TNumber> {
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) {
throw new Error('keychain is required for descriptor wallets');
}
if (!isUsefulNamedKeychains(keychains)) {
throw new Error('keychain must contain user, backup, and bitgo keys');
}
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));
const recipientOutputs = recipients.map((r) => toParsedOutput(coin, r, psbt.network));
return parsedDescriptorTransactionToTNumber(
toParsedTransaction(parseOutputsWithPsbt(psbt, descriptorMap, recipientOutputs), {
keychains,
keySignatures,
}),
coin.amountType
);
}
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;
};
52 changes: 52 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/toTNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as utxolib from '@bitgo/utxo-lib';

import { BaseOutput } from '../../abstractUtxoCoin';
import { ParsedDescriptorTransaction } from './parse';

function baseOutputToTNumber<TAmount extends number | bigint>(
output: BaseOutput<bigint>,
amountType: 'number' | 'bigint'
): BaseOutput<TAmount> {
return {
address: output.address,
amount: utxolib.bitgo.toTNumber(output.amount, amountType),
external: output.external,
};
}

function entryToTNumber<
K extends keyof ParsedDescriptorTransaction<bigint>,
V extends ParsedDescriptorTransaction<bigint>[K]
>(k: K, v: V, amountType: 'bigint' | 'number'): [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, amountType)) as V];
}
throw new Error('expected array');
case 'explicitExternalSpendAmount':
case 'implicitExternalSpendAmount':
if (typeof v !== 'bigint') {
throw new Error('expected bigint');
}
return [k, utxolib.bitgo.toTNumber(v, amountType) as V];
default:
return [k, v];
}
}

export function parsedDescriptorTransactionToTNumber<TAmount extends number | bigint>(
obj: ParsedDescriptorTransaction<bigint>,
amountType: 'bigint' | 'number'
): ParsedDescriptorTransaction<TAmount> {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => entryToTNumber(k as keyof ParsedDescriptorTransaction<bigint>, v, amountType))
) as ParsedDescriptorTransaction<TAmount>;
}
4 changes: 3 additions & 1 deletion modules/abstract-utxo/src/transaction/parseTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import * as utxolib from '@bitgo/utxo-lib';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import utxolib.
import { AbstractUtxoCoin, ParsedTransaction, ParseTransactionOptions } from '../abstractUtxoCoin';

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.parse(coin, params.wallet, params);
} else {
return fixedScript.parseTransaction(coin, params);
}
Expand Down

0 comments on commit d13a956

Please sign in to comment.