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
81 changes: 58 additions & 23 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
isValidPrv,
isValidXprv,
} from '@bitgo/sdk-core';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';

import {
backupKeyRecovery,
Expand All @@ -68,6 +69,7 @@ import {
verifyTransaction,
} from './transaction';
import type { TransactionExplanation } from './transaction/fixedScript/explainTransaction';
import { Musig2Participant } from './transaction/fixedScript/musig2';
import {
AggregateValidationError,
ErrorMissingOutputs,
Expand All @@ -76,8 +78,8 @@ import {
import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor';
import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names';
import { assertFixedScriptWalletAddress } from './address/fixedScript';
import { ParsedTransaction } from './transaction/types';
import { decodePsbtWith, stringToBufferTryFormats } from './transaction/decode';
import { isSdkBackend, ParsedTransaction, SdkBackend } from './transaction/types';
import { decodePsbtWith, encodeTransaction, stringToBufferTryFormats } from './transaction/decode';
import { toBip32Triple, UtxoKeychain } from './keychains';
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
import { getPolicyForEnv } from './descriptor/validatePolicy';
Expand Down Expand Up @@ -213,6 +215,7 @@ export interface ExplainTransactionOptions<TNumber extends number | bigint = num
txInfo?: TransactionInfo<TNumber>;
feeInfo?: string;
pubs?: Triple<string>;
decodeWith?: SdkBackend;
}

export interface DecoratedExplainTransactionOptions<TNumber extends number | bigint = number>
Expand All @@ -225,6 +228,7 @@ export type UtxoNetwork = utxolib.Network;
export interface TransactionPrebuild<TNumber extends number | bigint = number> extends BaseTransactionPrebuild {
txInfo?: TransactionInfo<TNumber>;
blockHeight?: number;
decodeWith?: SdkBackend;
}

export interface TransactionParams extends BaseTransactionParams {
Expand Down Expand Up @@ -284,6 +288,7 @@ type UtxoBaseSignTransactionOptions<TNumber extends number | bigint = number> =
walletId?: string;
txHex: string;
txInfo?: TransactionInfo<TNumber>;
decodeWith?: SdkBackend;
};
/** xpubs triple for wallet (user, backup, bitgo). Required only when txPrebuild.txHex is not a PSBT */
pubs?: Triple<string>;
Expand Down Expand Up @@ -360,7 +365,10 @@ export interface SignPsbtResponse {
psbt: string;
}

export abstract class AbstractUtxoCoin extends BaseCoin {
export abstract class AbstractUtxoCoin
extends BaseCoin
implements Musig2Participant<utxolib.bitgo.UtxoPsbt>, Musig2Participant<fixedScriptWallet.BitGoPsbt>
{
public altScriptHash?: number;
public supportAltScriptDestination?: boolean;
public readonly amountType: 'number' | 'bigint';
Expand Down Expand Up @@ -509,7 +517,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
if (_.isUndefined(prebuild.blockHeight)) {
prebuild.blockHeight = (await this.getLatestBlockHeight()) as number;
}
return _.extend({}, prebuild, { txHex: tx.toHex() });
return _.extend({}, prebuild, { txHex: encodeTransaction(tx).toString('hex') });
}

/**
Expand All @@ -526,38 +534,52 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
return utxolib.bitgo.createTransactionFromHex<TNumber>(hex, this.network, this.amountType);
}

decodeTransaction<TNumber extends number | bigint>(input: Buffer | string): DecodedTransaction<TNumber> {
decodeTransaction<TNumber extends number | bigint>(
input: Buffer | string,
decodeWith?: SdkBackend
): DecodedTransaction<TNumber> {
if (typeof input === 'string') {
const buffer = stringToBufferTryFormats(input, ['hex', 'base64']);
return this.decodeTransaction(buffer);
return this.decodeTransaction(buffer, decodeWith);
}

if (utxolib.bitgo.isPsbt(input)) {
return decodePsbtWith(input, this.network, 'utxolib');
return decodePsbtWith(input, this.network, decodeWith ?? 'utxolib');
} else {
if (decodeWith ?? 'utxolib' !== 'utxolib') {
console.error('received decodeWith hint %s, ignoring for legacy transaction', decodeWith);
}
return utxolib.bitgo.createTransactionFromBuffer(input, this.network, {
amountType: this.amountType,
});
}
}

decodeTransactionAsPsbt(input: Buffer | string): utxolib.bitgo.UtxoPsbt {
decodeTransactionAsPsbt(input: Buffer | string): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt {
const decoded = this.decodeTransaction(input);
if (!(decoded instanceof utxolib.bitgo.UtxoPsbt)) {
throw new Error('expected psbt but got transaction');
if (decoded instanceof fixedScriptWallet.BitGoPsbt || decoded instanceof utxolib.bitgo.UtxoPsbt) {
return decoded;
}
return decoded;
throw new Error('expected psbt but got transaction');
}

decodeTransactionFromPrebuild<TNumber extends number | bigint>(prebuild: {
txHex?: string;
txBase64?: string;
decodeWith?: string;
}): DecodedTransaction<TNumber> {
const string = prebuild.txHex ?? prebuild.txBase64;
if (!string) {
throw new Error('missing required txHex or txBase64 property');
}
return this.decodeTransaction(string);
let { decodeWith } = prebuild;
if (decodeWith !== undefined) {
if (typeof decodeWith !== 'string' || !isSdkBackend(decodeWith)) {
console.error('decodeWith %s is not a valid value, using default', decodeWith);
decodeWith = undefined;
}
}
return this.decodeTransaction(string, decodeWith);
}

toCanonicalTransactionRecipient(output: { valueString: string; address?: string }): {
Expand Down Expand Up @@ -720,16 +742,29 @@ export abstract class AbstractUtxoCoin extends BaseCoin {

/**
* @returns input psbt added with deterministic MuSig2 nonce for bitgo key for each MuSig2 inputs.
* @param psbtHex all MuSig2 inputs should contain user MuSig2 nonce
* @param psbt all MuSig2 inputs should contain user MuSig2 nonce
* @param walletId
*/
async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt> {
const params: SignPsbtRequest = { psbt: psbt.toHex() };
async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt>;
async getMusig2Nonces(psbt: fixedScriptWallet.BitGoPsbt, walletId: string): Promise<fixedScriptWallet.BitGoPsbt>;
async getMusig2Nonces<T extends utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt>(
psbt: T,
walletId: string
): Promise<T>;
async getMusig2Nonces<T extends utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt>(
psbt: T,
walletId: string
): Promise<T> {
const buffer = encodeTransaction(psbt);
const response = await this.bitgo
.post(this.url('/wallet/' + walletId + '/tx/signpsbt'))
.send(params)
.send({ psbt: buffer.toString('hex') })
.result();
return this.decodeTransactionAsPsbt(response.psbt);
if (psbt instanceof utxolib.bitgo.UtxoPsbt) {
return decodePsbtWith(response.psbt, this.network, 'utxolib') as T;
} else {
return decodePsbtWith(response.psbt, this.network, 'wasm-utxo') as T;
}
}

/**
Expand All @@ -739,7 +774,8 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
* @param walletId
*/
async signPsbt(psbtHex: string, walletId: string): Promise<SignPsbtResponse> {
return { psbt: (await this.getMusig2Nonces(this.decodeTransactionAsPsbt(psbtHex), walletId)).toHex() };
const psbt = await this.getMusig2Nonces(this.decodeTransactionAsPsbt(psbtHex), walletId);
return { psbt: encodeTransaction(psbt).toString('hex') };
}

/**
Expand All @@ -749,11 +785,10 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
async signPsbtFromOVC(ovcJson: Record<string, unknown>): Promise<Record<string, unknown>> {
assert(ovcJson['psbtHex'], 'ovcJson must contain psbtHex');
assert(ovcJson['walletId'], 'ovcJson must contain walletId');
const psbt = await this.getMusig2Nonces(
this.decodeTransactionAsPsbt(ovcJson['psbtHex'] as string),
ovcJson['walletId'] as string
);
return _.extend(ovcJson, { txHex: psbt.toHex() });
const hex = ovcJson['psbtHex'] as string;
const walletId = ovcJson['walletId'] as string;
const psbt = await this.getMusig2Nonces(this.decodeTransactionAsPsbt(hex), walletId);
return _.extend(ovcJson, { txHex: encodeTransaction(psbt).toString('hex') });
}

/**
Expand Down
12 changes: 12 additions & 0 deletions modules/abstract-utxo/src/transaction/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,15 @@ export function decodePsbtWith(
return fixedScriptWallet.BitGoPsbt.fromBytes(psbt, toNetworkName(network));
}
}

export function encodeTransaction(
transaction: utxolib.bitgo.UtxoTransaction<bigint | number> | utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt
): Buffer {
if (transaction instanceof utxolib.bitgo.UtxoTransaction) {
return transaction.toBuffer();
} else if (transaction instanceof utxolib.bitgo.UtxoPsbt) {
return transaction.toBuffer();
} else {
return Buffer.from(transaction.serialize());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const PSBT_CACHE_WASM = new Map<string, fixedScriptWallet.BitGoPsbt>();

function hasKeyPathSpendInput(
tx: fixedScriptWallet.BitGoPsbt,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
replayProtection: ReplayProtectionKeys
): boolean {
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);
Expand All @@ -36,10 +36,10 @@ function hasKeyPathSpendInput(
export function signAndVerifyPsbtWasm(
tx: fixedScriptWallet.BitGoPsbt,
signerKeychain: BIP32Interface,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
replayProtection: ReplayProtectionKeys,
{ isLastSignature }: { isLastSignature: boolean }
): fixedScriptWallet.BitGoPsbt | Uint8Array {
): fixedScriptWallet.BitGoPsbt | Buffer {
const wasmSigner = toWasmBIP32(signerKeychain);
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);

Expand Down Expand Up @@ -85,7 +85,7 @@ export function signAndVerifyPsbtWasm(

if (isLastSignature) {
tx.finalizeAllInputs();
return tx.extractTransaction();
return Buffer.from(tx.extractTransaction());
}

return tx;
Expand All @@ -100,17 +100,17 @@ export async function signPsbtWithMusig2ParticipantWasm(
coin: Musig2Participant<fixedScriptWallet.BitGoPsbt>,
tx: fixedScriptWallet.BitGoPsbt,
signerKeychain: BIP32Interface | undefined,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
replayProtection: ReplayProtectionKeys,
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
params: {
replayProtection: ReplayProtectionKeys;
isLastSignature: boolean;
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
walletId: string | undefined;
}
): Promise<fixedScriptWallet.BitGoPsbt | Uint8Array> {
): Promise<fixedScriptWallet.BitGoPsbt | Buffer> {
const wasmSigner = signerKeychain ? toWasmBIP32(signerKeychain) : undefined;

if (hasKeyPathSpendInput(tx, rootWalletKeys, replayProtection)) {
if (hasKeyPathSpendInput(tx, rootWalletKeys, params.replayProtection)) {
// We can only be the first signature on a transaction with taproot key path spend inputs because
// we require the secret nonce in the cache of the first signer, which is impossible to retrieve if
// deserialized from a hex.
Expand Down Expand Up @@ -162,7 +162,7 @@ export async function signPsbtWithMusig2ParticipantWasm(
}

assert(signerKeychain);
return signAndVerifyPsbtWasm(tx, signerKeychain, rootWalletKeys, replayProtection, {
return signAndVerifyPsbtWasm(tx, signerKeychain, rootWalletKeys, params.replayProtection, {
isLastSignature: params.isLastSignature,
});
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import assert from 'assert';

import { isTriple } from '@bitgo/sdk-core';
import _ from 'lodash';
import { BIP32Interface } from '@bitgo/secp256k1';
import { bitgo } from '@bitgo/utxo-lib';
import * as utxolib from '@bitgo/utxo-lib';

import { DecodedTransaction } from '../types';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';

import { Musig2Participant } from './musig2';
import { signLegacyTransaction } from './signLegacyTransaction';
import { signPsbtWithMusig2Participant } from './signPsbt';
import { signPsbtWithMusig2ParticipantWasm } from './signPsbtWasm';
import { getReplayProtectionPubkeys } from './replayProtection';

export async function signTransaction(
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt>,
tx: DecodedTransaction<bigint | number>,
export async function signTransaction<
T extends utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint | number> | fixedScriptWallet.BitGoPsbt
>(
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt> | Musig2Participant<fixedScriptWallet.BitGoPsbt>,
tx: T,
signerKeychain: BIP32Interface | undefined,
network: utxolib.Network,
params: {
Expand All @@ -24,19 +30,39 @@ export async function signTransaction(
pubs: string[] | undefined;
cosignerPub: string | undefined;
}
): Promise<utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint | number>> {
): Promise<
utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint | number> | fixedScriptWallet.BitGoPsbt | Buffer
> {
let isLastSignature = false;
if (_.isBoolean(params.isLastSignature)) {
// if build is called instead of buildIncomplete, no signature placeholders are left in the sig script
isLastSignature = params.isLastSignature;
}

if (tx instanceof bitgo.UtxoPsbt) {
return signPsbtWithMusig2Participant(coin, tx, signerKeychain, {
return signPsbtWithMusig2Participant(coin as Musig2Participant<utxolib.bitgo.UtxoPsbt>, tx, signerKeychain, {
isLastSignature,
signingStep: params.signingStep,
walletId: params.walletId,
});
} else if (tx instanceof fixedScriptWallet.BitGoPsbt) {
assert(params.pubs, 'pubs are required for fixed script signing');
assert(isTriple(params.pubs), 'pubs must be a triple');
const rootWalletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(params.pubs);
return signPsbtWithMusig2ParticipantWasm(
coin as Musig2Participant<fixedScriptWallet.BitGoPsbt>,
tx,
signerKeychain,
rootWalletKeys,
{
replayProtection: {
publicKeys: getReplayProtectionPubkeys(network),
},
isLastSignature,
signingStep: params.signingStep,
walletId: params.walletId,
}
);
}

return signLegacyTransaction(tx, signerKeychain, {
Expand Down
4 changes: 3 additions & 1 deletion modules/abstract-utxo/src/transaction/signTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fetchKeychains, toBip32Triple } from '../keychains';

import * as fixedScript from './fixedScript';
import * as descriptor from './descriptor';
import { encodeTransaction } from './decode';

const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction');

Expand Down Expand Up @@ -72,6 +73,7 @@ export async function signTransaction<TNumber extends number | bigint>(
pubs: params.pubs,
cosignerPub: params.cosignerPub,
});
return { txHex: signedTx.toBuffer().toString('hex') };
const buffer = Buffer.isBuffer(signedTx) ? signedTx : encodeTransaction(signedTx);
return { txHex: buffer.toString('hex') };
}
}
8 changes: 7 additions & 1 deletion modules/abstract-utxo/src/transaction/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import * as utxolib from '@bitgo/utxo-lib';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';

import type { UtxoNamedKeychains } from '../keychains';

import type { CustomChangeOptions } from './fixedScript';

export type SdkBackend = 'utxolib' | 'wasm-utxo';

export function isSdkBackend(backend: string): backend is SdkBackend {
return backend === 'utxolib' || backend === 'wasm-utxo';
}

export type DecodedTransaction<TNumber extends number | bigint> =
| utxolib.bitgo.UtxoTransaction<TNumber>
| utxolib.bitgo.UtxoPsbt;
| utxolib.bitgo.UtxoPsbt
| fixedScriptWallet.BitGoPsbt;

export interface BaseOutput<TAmount = string | number> {
address: string;
Expand Down
Loading