diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index cffc2b7632..7ae4b5f961 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -77,6 +77,7 @@ import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptor import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names'; import { assertFixedScriptWalletAddress } from './address/fixedScript'; import { ParsedTransaction } from './transaction/types'; +import { decodePsbtWith, stringToBufferTryFormats } from './transaction/decode'; import { toBip32Triple, UtxoKeychain } from './keychains'; import { verifyKeySignature, verifyUserPublicKey } from './verifyKey'; import { getPolicyForEnv } from './descriptor/validatePolicy'; @@ -527,22 +528,12 @@ export abstract class AbstractUtxoCoin extends BaseCoin { decodeTransaction(input: Buffer | string): DecodedTransaction { if (typeof input === 'string') { - for (const format of ['hex', 'base64'] as const) { - const buffer = Buffer.from(input, format); - const bufferToString = buffer.toString(format); - if ( - (format === 'base64' && bufferToString === input) || - (format === 'hex' && bufferToString === input.toLowerCase()) - ) { - return this.decodeTransaction(buffer); - } - } - - throw new Error('input must be a valid hex or base64 string'); + const buffer = stringToBufferTryFormats(input, ['hex', 'base64']); + return this.decodeTransaction(buffer); } if (utxolib.bitgo.isPsbt(input)) { - return utxolib.bitgo.createPsbtFromBuffer(input, this.network); + return decodePsbtWith(input, this.network, 'utxolib'); } else { return utxolib.bitgo.createTransactionFromBuffer(input, this.network, { amountType: this.amountType, diff --git a/modules/abstract-utxo/src/index.ts b/modules/abstract-utxo/src/index.ts index d045dd51e0..641c0e7ec6 100644 --- a/modules/abstract-utxo/src/index.ts +++ b/modules/abstract-utxo/src/index.ts @@ -3,7 +3,7 @@ export * from './address'; export * from './config'; export * from './recovery'; export * from './transaction/fixedScript/replayProtection'; -export * from './transaction/fixedScript/sign'; +export * from './transaction/fixedScript/signLegacyTransaction'; export { UtxoWallet } from './wallet'; export * as descriptor from './descriptor'; diff --git a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts index 7a99b65760..5439196fba 100644 --- a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts @@ -16,7 +16,7 @@ import { import { getMainnet, networks } from '@bitgo/utxo-lib'; import { AbstractUtxoCoin } from '../abstractUtxoCoin'; -import { signAndVerifyPsbt } from '../transaction/fixedScript/sign'; +import { signAndVerifyPsbt } from '../transaction/fixedScript/signPsbt'; import { generateAddressWithChainAndIndex } from '../address'; import { forCoin, RecoveryProvider } from './RecoveryProvider'; diff --git a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts index 957ca89a67..420ea01fbd 100644 --- a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts @@ -5,7 +5,7 @@ import { BitGoBase, IWallet, Keychain, Triple, Wallet } from '@bitgo/sdk-core'; import { decrypt } from '@bitgo/sdk-api'; import { AbstractUtxoCoin, TransactionInfo } from '../abstractUtxoCoin'; -import { signAndVerifyWalletTransaction } from '../transaction/fixedScript/sign'; +import { signAndVerifyWalletTransaction } from '../transaction/fixedScript/signLegacyTransaction'; const { unspentSum, scriptTypeForChain, outputScripts } = utxolib.bitgo; type RootWalletKeys = utxolib.bitgo.RootWalletKeys; diff --git a/modules/abstract-utxo/src/transaction/decode.ts b/modules/abstract-utxo/src/transaction/decode.ts new file mode 100644 index 0000000000..e4077d0224 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/decode.ts @@ -0,0 +1,59 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo'; + +import { SdkBackend } from './types'; + +type BufferEncoding = 'hex' | 'base64'; + +export function stringToBufferTryFormats(input: string, formats: BufferEncoding[] = ['hex', 'base64']): Buffer { + for (const format of formats) { + const buffer = Buffer.from(input, format); + const bufferToString = buffer.toString(format); + if ( + (format === 'base64' && bufferToString === input) || + (format === 'hex' && bufferToString === input.toLowerCase()) + ) { + return buffer; + } + } + + throw new Error('input must be a valid hex or base64 string'); +} + +function toNetworkName(network: utxolib.Network): utxolibCompat.UtxolibName { + const networkName = utxolib.getNetworkName(network); + if (!networkName) { + throw new Error(`Invalid network: ${network}`); + } + return networkName; +} + +export function decodePsbtWith( + psbt: string | Buffer, + network: utxolib.Network, + backend: 'utxolib' +): utxolib.bitgo.UtxoPsbt; +export function decodePsbtWith( + psbt: string | Buffer, + network: utxolib.Network, + backend: 'wasm-utxo' +): fixedScriptWallet.BitGoPsbt; +export function decodePsbtWith( + psbt: string | Buffer, + network: utxolib.Network, + backend: SdkBackend +): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt; +export function decodePsbtWith( + psbt: string | Buffer, + network: utxolib.Network, + backend: SdkBackend +): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt { + if (typeof psbt === 'string') { + psbt = Buffer.from(psbt, 'hex'); + } + if (backend === 'utxolib') { + return utxolib.bitgo.createPsbtFromBuffer(psbt, network); + } else { + return fixedScriptWallet.BitGoPsbt.fromBytes(psbt, toNetworkName(network)); + } +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/SigningError.ts b/modules/abstract-utxo/src/transaction/fixedScript/SigningError.ts new file mode 100644 index 0000000000..821d71b7c2 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/SigningError.ts @@ -0,0 +1,38 @@ +import * as utxolib from '@bitgo/utxo-lib'; + +import type { PsbtParsedScriptType } from './signPsbt'; + +type Unspent = utxolib.bitgo.Unspent; + +export class InputSigningError extends Error { + static expectedWalletUnspent( + inputIndex: number, + inputType: PsbtParsedScriptType | null, // null for legacy transaction format + unspent: Unspent | { id: string } + ): InputSigningError { + return new InputSigningError( + inputIndex, + inputType, + unspent, + `not a wallet unspent, not a replay protection unspent` + ); + } + + constructor( + public inputIndex: number, + public inputType: PsbtParsedScriptType | null, // null for legacy transaction format + public unspent: Unspent | { id: string }, + public reason: Error | string + ) { + super(`signing error at input ${inputIndex}: type=${inputType} unspentId=${unspent.id}: ${reason}`); + } +} + +export class TransactionSigningError extends Error { + constructor(signErrors: InputSigningError[], verifyError: InputSigningError[]) { + super( + `sign errors at inputs: [${signErrors.join(',')}], ` + + `verify errors at inputs: [${verifyError.join(',')}], see log for details` + ); + } +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index 12521bfe3e..d8c161bb44 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -3,6 +3,8 @@ export { explainPsbtWasm } from './explainPsbtWasm'; export { parseTransaction } from './parseTransaction'; export { CustomChangeOptions } from './parseOutput'; export { verifyTransaction } from './verifyTransaction'; -export { signTransaction, Musig2Participant } from './signTransaction'; -export * from './sign'; +export { signTransaction } from './signTransaction'; +export { Musig2Participant } from './signPsbt'; +export * from './signLegacyTransaction'; +export * from './SigningError'; export * from './replayProtection'; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/sign.ts b/modules/abstract-utxo/src/transaction/fixedScript/signLegacyTransaction.ts similarity index 51% rename from modules/abstract-utxo/src/transaction/fixedScript/sign.ts rename to modules/abstract-utxo/src/transaction/fixedScript/signLegacyTransaction.ts index ec73016469..c271422e2f 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/sign.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signLegacyTransaction.ts @@ -1,7 +1,13 @@ +import assert from 'assert'; + import * as utxolib from '@bitgo/utxo-lib'; +import { BIP32Interface, bip32 } from '@bitgo/secp256k1'; +import { bitgo } from '@bitgo/utxo-lib'; +import { isTriple, Triple } from '@bitgo/sdk-core'; import debugLib from 'debug'; import { getReplayProtectionAddresses } from './replayProtection'; +import { InputSigningError, TransactionSigningError } from './SigningError'; const debug = debugLib('bitgo:v2:utxo'); @@ -11,132 +17,6 @@ type Unspent = utxolib.bitgo.Unspent extends Error { - static expectedWalletUnspent( - inputIndex: number, - inputType: PsbtParsedScriptType | null, // null for legacy transaction format - unspent: Unspent | { id: string } - ): InputSigningError { - return new InputSigningError( - inputIndex, - inputType, - unspent, - `not a wallet unspent, not a replay protection unspent` - ); - } - - constructor( - public inputIndex: number, - public inputType: PsbtParsedScriptType | null, // null for legacy transaction format - public unspent: Unspent | { id: string }, - public reason: Error | string - ) { - super(`signing error at input ${inputIndex}: type=${inputType} unspentId=${unspent.id}: ${reason}`); - } -} - -export class TransactionSigningError extends Error { - constructor(signErrors: InputSigningError[], verifyError: InputSigningError[]) { - super( - `sign errors at inputs: [${signErrors.join(',')}], ` + - `verify errors at inputs: [${verifyError.join(',')}], see log for details` - ); - } -} - -/** - * Sign all inputs of a psbt and verify signatures after signing. - * Collects and logs signing errors and verification errors, throws error in the end if any of them - * failed. - * - * If it is the last signature, finalize and extract the transaction from the psbt. - * - * This function mirrors signAndVerifyWalletTransaction, but is used for signing PSBTs instead of - * using TransactionBuilder - * - * @param psbt - * @param signerKeychain - * @param isLastSignature - */ -export function signAndVerifyPsbt( - psbt: utxolib.bitgo.UtxoPsbt, - signerKeychain: utxolib.BIP32Interface, - { - isLastSignature, - /** deprecated */ - allowNonSegwitSigningWithoutPrevTx, - }: { isLastSignature: boolean; allowNonSegwitSigningWithoutPrevTx?: boolean } -): utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction { - const txInputs = psbt.txInputs; - const outputIds: string[] = []; - const scriptTypes: PsbtParsedScriptType[] = []; - - const signErrors: InputSigningError[] = psbt.data.inputs - .map((input, inputIndex: number) => { - const outputId = utxolib.bitgo.formatOutputId(utxolib.bitgo.getOutputIdForInput(txInputs[inputIndex])); - outputIds.push(outputId); - - const { scriptType } = utxolib.bitgo.parsePsbtInput(input); - scriptTypes.push(scriptType); - - if (scriptType === 'p2shP2pk') { - debug('Skipping signature for input %d of %d (RP input?)', inputIndex + 1, psbt.data.inputs.length); - return; - } - - try { - psbt.signInputHD(inputIndex, signerKeychain); - debug('Successfully signed input %d of %d', inputIndex + 1, psbt.data.inputs.length); - } catch (e) { - return new InputSigningError(inputIndex, scriptType, { id: outputId }, e); - } - }) - .filter((e): e is InputSigningError => e !== undefined); - - const verifyErrors: InputSigningError[] = psbt.data.inputs - .map((input, inputIndex) => { - const scriptType = scriptTypes[inputIndex]; - if (scriptType === 'p2shP2pk') { - debug( - 'Skipping input signature %d of %d (unspent from replay protection address which is platform signed only)', - inputIndex + 1, - psbt.data.inputs.length - ); - return; - } - - const outputId = outputIds[inputIndex]; - try { - if (!psbt.validateSignaturesOfInputHD(inputIndex, signerKeychain)) { - return new InputSigningError(inputIndex, scriptType, { id: outputId }, new Error(`invalid signature`)); - } - } catch (e) { - debug('Invalid signature'); - return new InputSigningError(inputIndex, scriptType, { id: outputId }, e); - } - }) - .filter((e): e is InputSigningError => e !== undefined); - - if (signErrors.length || verifyErrors.length) { - throw new TransactionSigningError(signErrors, verifyErrors); - } - - if (isLastSignature) { - psbt.finalizeAllInputs(); - return psbt.extractTransaction(); - } - - return psbt; -} - /** * Sign all inputs of a wallet transaction and verify signatures after signing. * Collects and logs signing errors and verification errors, throws error in the end if any of them @@ -232,3 +112,43 @@ export function signAndVerifyWalletTransaction( return signedTransaction; } + +export function signLegacyTransaction( + tx: utxolib.bitgo.UtxoTransaction, + signerKeychain: BIP32Interface | undefined, + params: { + isLastSignature: boolean; + signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined; + txInfo: { unspents?: utxolib.bitgo.Unspent[] } | undefined; + pubs: string[] | undefined; + cosignerPub: string | undefined; + } +): utxolib.bitgo.UtxoTransaction { + switch (params.signingStep) { + case 'signerNonce': + case 'cosignerNonce': + /** + * In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s). + * Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence. + */ + return tx; + } + + if (tx.ins.length !== params.txInfo?.unspents?.length) { + throw new Error('length of unspents array should equal to the number of transaction inputs'); + } + + if (!params.pubs || !isTriple(params.pubs)) { + throw new Error(`must provide xpub array`); + } + + const keychains = params.pubs.map((pub) => bip32.fromBase58(pub)) as Triple; + const cosignerPub = params.cosignerPub ?? params.pubs[2]; + const cosignerKeychain = bip32.fromBase58(cosignerPub); + + assert(signerKeychain); + const walletSigner = new bitgo.WalletUnspentSigner(keychains, signerKeychain, cosignerKeychain); + return signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, { + isLastSignature: params.isLastSignature, + }) as utxolib.bitgo.UtxoTransaction; +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts b/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts new file mode 100644 index 0000000000..0f55cf2442 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts @@ -0,0 +1,182 @@ +import assert from 'assert'; + +import * as utxolib from '@bitgo/utxo-lib'; +import { BIP32Interface } from '@bitgo/secp256k1'; +import { bitgo } from '@bitgo/utxo-lib'; +import debugLib from 'debug'; + +import { InputSigningError, TransactionSigningError } from './SigningError'; + +const debug = debugLib('bitgo:v2:utxo'); + +export type PsbtParsedScriptType = + | 'p2sh' + | 'p2wsh' + | 'p2shP2wsh' + | 'p2shP2pk' + | 'taprootKeyPathSpend' + | 'taprootScriptPathSpend'; + +/** + * Sign all inputs of a psbt and verify signatures after signing. + * Collects and logs signing errors and verification errors, throws error in the end if any of them + * failed. + * + * If it is the last signature, finalize and extract the transaction from the psbt. + * + * This function mirrors signAndVerifyWalletTransaction, but is used for signing PSBTs instead of + * using TransactionBuilder + * + * @param psbt + * @param signerKeychain + * @param isLastSignature + */ +export function signAndVerifyPsbt( + psbt: utxolib.bitgo.UtxoPsbt, + signerKeychain: utxolib.BIP32Interface, + { + isLastSignature, + /** deprecated */ + allowNonSegwitSigningWithoutPrevTx, + }: { isLastSignature: boolean; allowNonSegwitSigningWithoutPrevTx?: boolean } +): utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction { + const txInputs = psbt.txInputs; + const outputIds: string[] = []; + const scriptTypes: PsbtParsedScriptType[] = []; + + const signErrors: InputSigningError[] = psbt.data.inputs + .map((input, inputIndex: number) => { + const outputId = utxolib.bitgo.formatOutputId(utxolib.bitgo.getOutputIdForInput(txInputs[inputIndex])); + outputIds.push(outputId); + + const { scriptType } = utxolib.bitgo.parsePsbtInput(input); + scriptTypes.push(scriptType); + + if (scriptType === 'p2shP2pk') { + debug('Skipping signature for input %d of %d (RP input?)', inputIndex + 1, psbt.data.inputs.length); + return; + } + + try { + psbt.signInputHD(inputIndex, signerKeychain); + debug('Successfully signed input %d of %d', inputIndex + 1, psbt.data.inputs.length); + } catch (e) { + return new InputSigningError(inputIndex, scriptType, { id: outputId }, e); + } + }) + .filter((e): e is InputSigningError => e !== undefined); + + const verifyErrors: InputSigningError[] = psbt.data.inputs + .map((input, inputIndex) => { + const scriptType = scriptTypes[inputIndex]; + if (scriptType === 'p2shP2pk') { + debug( + 'Skipping input signature %d of %d (unspent from replay protection address which is platform signed only)', + inputIndex + 1, + psbt.data.inputs.length + ); + return; + } + + const outputId = outputIds[inputIndex]; + try { + if (!psbt.validateSignaturesOfInputHD(inputIndex, signerKeychain)) { + return new InputSigningError(inputIndex, scriptType, { id: outputId }, new Error(`invalid signature`)); + } + } catch (e) { + debug('Invalid signature'); + return new InputSigningError(inputIndex, scriptType, { id: outputId }, e); + } + }) + .filter((e): e is InputSigningError => e !== undefined); + + if (signErrors.length || verifyErrors.length) { + throw new TransactionSigningError(signErrors, verifyErrors); + } + + if (isLastSignature) { + psbt.finalizeAllInputs(); + return psbt.extractTransaction(); + } + + return psbt; +} + +export interface Musig2Participant { + getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise; +} + +/** + * Key Value: Unsigned tx id => PSBT + * It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated. + * Reason: MuSig2 signer secure nonce is cached in the UtxoPsbt object. It will be required during the signing step. + * For more info, check SignTransactionOptions.signingStep + * + * TODO BTC-276: This cache may need to be done with LRU like memory safe caching if memory issues comes up. + */ +const PSBT_CACHE = new Map(); + +export async function signPsbtWithMusig2Participant( + coin: Musig2Participant, + tx: utxolib.bitgo.UtxoPsbt, + signerKeychain: BIP32Interface | undefined, + params: { + isLastSignature: boolean; + signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined; + walletId: string | undefined; + } +): Promise> { + if (bitgo.isTransactionWithKeyPathSpendInput(tx)) { + // 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. + if (params.isLastSignature) { + throw new Error('Cannot be last signature on a transaction with key path spend inputs'); + } + + switch (params.signingStep) { + case 'signerNonce': + assert(signerKeychain); + tx.setAllInputsMusig2NonceHD(signerKeychain); + PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx); + return tx; + case 'cosignerNonce': + assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce'); + return await coin.getMusig2Nonces(tx, params.walletId); + case 'signerSignature': + const txId = tx.getUnsignedTx().getId(); + const psbt = PSBT_CACHE.get(txId); + assert( + psbt, + `Psbt is missing from txCache (cache size ${PSBT_CACHE.size}). + This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.` + ); + PSBT_CACHE.delete(txId); + tx = psbt.combine(tx); + break; + default: + // this instance is not an external signer + assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce'); + assert(signerKeychain); + tx.setAllInputsMusig2NonceHD(signerKeychain); + const response = await coin.getMusig2Nonces(tx, params.walletId); + tx = tx.combine(response); + break; + } + } else { + switch (params.signingStep) { + case 'signerNonce': + case 'cosignerNonce': + /** + * In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s). + * Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence. + */ + return tx; + } + } + + assert(signerKeychain); + return signAndVerifyPsbt(tx, signerKeychain, { + isLastSignature: params.isLastSignature, + }); +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts index d521ade6f0..9fe194ac8c 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts @@ -1,39 +1,21 @@ -import assert from 'assert'; - import _ from 'lodash'; -import { BIP32Interface, bip32 } from '@bitgo/secp256k1'; +import { BIP32Interface } from '@bitgo/secp256k1'; import { bitgo } from '@bitgo/utxo-lib'; import * as utxolib from '@bitgo/utxo-lib'; -import { isTriple, Triple } from '@bitgo/sdk-core'; import { DecodedTransaction } from '../types'; -import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign'; - -type RootWalletKeys = bitgo.RootWalletKeys; - -export interface Musig2Participant { - getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise; -} +import { signLegacyTransaction } from './signLegacyTransaction'; +import { Musig2Participant, signPsbtWithMusig2Participant } from './signPsbt'; -/** - * Key Value: Unsigned tx id => PSBT - * It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated. - * Reason: MuSig2 signer secure nonce is cached in the UtxoPsbt object. It will be required during the signing step. - * For more info, check SignTransactionOptions.signingStep - * - * TODO BTC-276: This cache may need to be done with LRU like memory safe caching if memory issues comes up. - */ -const PSBT_CACHE = new Map(); - -export async function signTransaction( +export async function signTransaction( coin: Musig2Participant, - tx: DecodedTransaction, + tx: DecodedTransaction, signerKeychain: BIP32Interface | undefined, network: utxolib.Network, params: { walletId: string | undefined; - txInfo: { unspents?: utxolib.bitgo.Unspent[] } | undefined; + txInfo: { unspents?: utxolib.bitgo.Unspent[] } | undefined; isLastSignature: boolean; signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined; /** deprecated */ @@ -41,91 +23,26 @@ export async function signTransaction( pubs: string[] | undefined; cosignerPub: string | undefined; } -): Promise<{ txHex: string }> { - const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx); - +): Promise> { let isLastSignature = false; if (_.isBoolean(params.isLastSignature)) { - // 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. - if (params.isLastSignature && isTxWithKeyPathSpendInput) { - throw new Error('Cannot be last signature on a transaction with key path spend inputs'); - } - // if build is called instead of buildIncomplete, no signature placeholders are left in the sig script isLastSignature = params.isLastSignature; } - if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) { - switch (params.signingStep) { - case 'signerNonce': - assert(signerKeychain); - tx.setAllInputsMusig2NonceHD(signerKeychain); - PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx); - return { txHex: tx.toHex() }; - case 'cosignerNonce': - assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce'); - return { txHex: (await coin.getMusig2Nonces(tx, params.walletId)).toHex() }; - case 'signerSignature': - const txId = tx.getUnsignedTx().getId(); - const psbt = PSBT_CACHE.get(txId); - assert( - psbt, - `Psbt is missing from txCache (cache size ${PSBT_CACHE.size}). - This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.` - ); - PSBT_CACHE.delete(txId); - tx = psbt.combine(tx); - break; - default: - // this instance is not an external signer - assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce'); - assert(signerKeychain); - tx.setAllInputsMusig2NonceHD(signerKeychain); - const response = await coin.getMusig2Nonces(tx, params.walletId); - tx = tx.combine(response); - break; - } - } else { - switch (params.signingStep) { - case 'signerNonce': - case 'cosignerNonce': - /** - * In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s). - * Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence. - */ - return { txHex: tx.toHex() }; - } - } - - let signedTransaction: bitgo.UtxoTransaction | bitgo.UtxoPsbt; if (tx instanceof bitgo.UtxoPsbt) { - assert(signerKeychain); - signedTransaction = signAndVerifyPsbt(tx, signerKeychain, { + return signPsbtWithMusig2Participant(coin, tx, signerKeychain, { isLastSignature, + signingStep: params.signingStep, + walletId: params.walletId, }); - } else { - if (tx.ins.length !== params.txInfo?.unspents?.length) { - throw new Error('length of unspents array should equal to the number of transaction inputs'); - } - - if (!params.pubs || !isTriple(params.pubs)) { - throw new Error(`must provide xpub array`); - } - - const keychains = params.pubs.map((pub) => bip32.fromBase58(pub)) as Triple; - const cosignerPub = params.cosignerPub ?? params.pubs[2]; - const cosignerKeychain = bip32.fromBase58(cosignerPub); - - assert(signerKeychain); - const walletSigner = new bitgo.WalletUnspentSigner(keychains, signerKeychain, cosignerKeychain); - signedTransaction = signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, { - isLastSignature, - }) as bitgo.UtxoTransaction; } - return { - txHex: signedTransaction.toBuffer().toString('hex'), - }; + return signLegacyTransaction(tx, signerKeychain, { + isLastSignature, + signingStep: params.signingStep, + txInfo: params.txInfo, + pubs: params.pubs, + cosignerPub: params.cosignerPub, + }); } diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts index 9a4d45ee19..f99b4fcb1f 100644 --- a/modules/abstract-utxo/src/transaction/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -63,7 +63,7 @@ export async function signTransaction( throw new Error('expected a UtxoPsbt object'); } } else { - return fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.network, { + const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.network, { walletId: params.txPrebuild.walletId, txInfo: params.txPrebuild.txInfo, isLastSignature: params.isLastSignature ?? false, @@ -72,5 +72,6 @@ export async function signTransaction( pubs: params.pubs, cosignerPub: params.cosignerPub, }); + return { txHex: signedTx.toBuffer().toString('hex') }; } } diff --git a/modules/abstract-utxo/src/transaction/types.ts b/modules/abstract-utxo/src/transaction/types.ts index 1f5f2c443e..06484aadbe 100644 --- a/modules/abstract-utxo/src/transaction/types.ts +++ b/modules/abstract-utxo/src/transaction/types.ts @@ -4,6 +4,8 @@ import type { UtxoNamedKeychains } from '../keychains'; import type { CustomChangeOptions } from './fixedScript'; +export type SdkBackend = 'utxolib' | 'wasm-utxo'; + export type DecodedTransaction = | utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt; diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts index 63880d4cdc..b19201e79e 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts @@ -2,22 +2,27 @@ import assert from 'node:assert/strict'; import * as utxolib from '@bitgo/utxo-lib'; -import { signAndVerifyPsbt } from '../../../../src/transaction/fixedScript/sign'; +import { Musig2Participant, signPsbtWithMusig2Participant } from '../../../../src/transaction/fixedScript/signPsbt'; -function describeSignAndVerifyPsbt(acidTest: utxolib.testutil.AcidTest) { +function describeSignPsbtWithMusig2Participant(acidTest: utxolib.testutil.AcidTest) { describe(`${acidTest.name}`, function () { - it('should sign unsigned psbt to halfsigned', function () { + it('should sign unsigned psbt to halfsigned', async function () { // Create unsigned PSBT const psbt = acidTest.createPsbt(); - // Set musig2 nonces for taproot inputs before signing - const sessionId = Buffer.alloc(32); - psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.user, { sessionId }); - psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.bitgo, { deterministic: true }); + // Create mock Musig2Participant that sets BitGo nonces + const mockCoin: Musig2Participant = { + async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise { + psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.bitgo, { deterministic: true }); + return psbt; + }, + }; - // Sign with user key - const result = signAndVerifyPsbt(psbt, acidTest.rootWalletKeys.user, { + // Sign with user key through signPsbtWithMusig2Participant + const result = await signPsbtWithMusig2Participant(mockCoin, psbt, acidTest.rootWalletKeys.user, { isLastSignature: false, + signingStep: undefined, + walletId: 'test-wallet-id', }); // Result should be a PSBT (not finalized) @@ -40,13 +45,13 @@ function describeSignAndVerifyPsbt(acidTest: utxolib.testutil.AcidTest) { }); } -describe('signAndVerifyPsbt', function () { +describe('signPsbtWithMusig2Participant', function () { // Create test suite with includeP2trMusig2ScriptPath set to false // p2trMusig2 script path inputs are signed by user and backup keys, // which is not the typical signing pattern and makes testing more complex utxolib.testutil.AcidTest.suite({ includeP2trMusig2ScriptPath: false }) .filter((test) => test.signStage === 'unsigned') .forEach((test) => { - describeSignAndVerifyPsbt(test); + describeSignPsbtWithMusig2Participant(test); }); });