diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 1a05af1ccb..ccb820f418 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -2,8 +2,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; import _ from 'lodash'; import * as utxolib from '@bitgo/utxo-lib'; -import { bip32, BIP32Interface, bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib'; -import debugLib from 'debug'; +import { bip32, bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib'; import { backupKeyRecovery, @@ -61,7 +60,6 @@ import { WalletData, } from '@bitgo/sdk-core'; import { isReplayProtectionUnspent } from './replayProtection'; -import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign'; import { supportedCrossChainRecoveries } from './config'; import { assertValidTransactionRecipient, @@ -77,11 +75,10 @@ import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } fro import { CustomChangeOptions } from './transaction/fixedScript'; import { NamedKeychains, toBip32Triple } from './keychains'; -const debug = debugLib('bitgo:v2:utxo'); - import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; import { verifyKeySignature, verifyUserPublicKey } from './verifyKey'; import { getPolicyForEnv } from './descriptor/validatePolicy'; +import { signTransaction } from './transaction/signTransaction'; type UtxoCustomSigningFunction = { (params: { @@ -112,11 +109,11 @@ const { getExternalChainCode, isChainCode, scriptTypeForChain, outputScripts } = type Unspent = bitgo.Unspent; -type DecodedTransaction = +export type DecodedTransaction = | utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt; -type RootWalletKeys = bitgo.RootWalletKeys; +export type RootWalletKeys = bitgo.RootWalletKeys; export type UtxoCoinSpecific = AddressCoinSpecific | DescriptorAddressCoinSpecific; @@ -376,16 +373,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin { this._network = network; } - /** - * 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. - */ - private static readonly PSBT_CACHE = new Map(); - get network() { return this._network; } @@ -849,132 +836,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { async signTransaction( params: SignTransactionOptions ): Promise { - const txPrebuild = params.txPrebuild; - - if (_.isUndefined(txPrebuild) || !_.isObject(txPrebuild)) { - if (!_.isUndefined(txPrebuild) && !_.isObject(txPrebuild)) { - throw new Error(`txPrebuild must be an object, got type ${typeof txPrebuild}`); - } - throw new Error('missing txPrebuild parameter'); - } - - let tx = this.decodeTransactionFromPrebuild(params.txPrebuild); - - const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx); - - 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; - } - - const getSignerKeychain = (): utxolib.BIP32Interface => { - const userPrv = params.prv; - if (_.isUndefined(userPrv) || !_.isString(userPrv)) { - if (!_.isUndefined(userPrv)) { - throw new Error(`prv must be a string, got type ${typeof userPrv}`); - } - throw new Error('missing prv parameter to sign transaction'); - } - const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin); - if (signerKeychain.isNeutered()) { - throw new Error('expected user private key but received public key'); - } - debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`); - return signerKeychain; - }; - - const setSignerMusigNonceWithOverride = ( - psbt: utxolib.bitgo.UtxoPsbt, - signerKeychain: utxolib.BIP32Interface, - nonSegwitOverride: boolean - ) => { - utxolib.bitgo.withUnsafeNonSegwit(psbt, () => psbt.setAllInputsMusig2NonceHD(signerKeychain), nonSegwitOverride); - }; - - let signerKeychain: utxolib.BIP32Interface | undefined; - - if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) { - switch (params.signingStep) { - case 'signerNonce': - signerKeychain = getSignerKeychain(); - setSignerMusigNonceWithOverride(tx, signerKeychain, !!params.allowNonSegwitSigningWithoutPrevTx); - AbstractUtxoCoin.PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx); - return { txHex: tx.toHex() }; - case 'cosignerNonce': - assert(txPrebuild.walletId, 'walletId is required for MuSig2 bitgo nonce'); - return { txHex: (await this.signPsbt(tx.toHex(), txPrebuild.walletId)).psbt }; - case 'signerSignature': - const txId = tx.getUnsignedTx().getId(); - const psbt = AbstractUtxoCoin.PSBT_CACHE.get(txId); - assert( - psbt, - `Psbt is missing from txCache (cache size ${AbstractUtxoCoin.PSBT_CACHE.size}). - This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.` - ); - AbstractUtxoCoin.PSBT_CACHE.delete(txId); - tx = psbt.combine(tx); - break; - default: - // this instance is not an external signer - assert(txPrebuild.walletId, 'walletId is required for MuSig2 bitgo nonce'); - signerKeychain = getSignerKeychain(); - setSignerMusigNonceWithOverride(tx, signerKeychain, !!params.allowNonSegwitSigningWithoutPrevTx); - const response = await this.signPsbt(tx.toHex(), txPrebuild.walletId); - tx.combine(bitgo.createPsbtFromHex(response.psbt, this.network)); - 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() }; - } - } - - if (signerKeychain === undefined) { - signerKeychain = getSignerKeychain(); - } - - let signedTransaction: bitgo.UtxoTransaction | bitgo.UtxoPsbt; - if (tx instanceof bitgo.UtxoPsbt) { - signedTransaction = signAndVerifyPsbt(tx, signerKeychain, { - isLastSignature, - allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx, - }); - } else { - if (tx.ins.length !== txPrebuild.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); - - const walletSigner = new bitgo.WalletUnspentSigner(keychains, signerKeychain, cosignerKeychain); - signedTransaction = signAndVerifyWalletTransaction(tx, txPrebuild.txInfo.unspents, walletSigner, { - isLastSignature, - }) as bitgo.UtxoTransaction; - } - - return { - txHex: signedTransaction.toBuffer().toString('hex'), - }; + return signTransaction(this, params); } /** diff --git a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts index a1fab26144..de6ad39ce5 100644 --- a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts @@ -3,7 +3,7 @@ import * as utxolib from '@bitgo/utxo-lib'; import { bip32, BIP32Interface } from '@bitgo/utxo-lib'; const { unspentSum, scriptTypeForChain, outputScripts } = utxolib.bitgo; -export type RootWalletKeys = utxolib.bitgo.RootWalletKeys; +type RootWalletKeys = utxolib.bitgo.RootWalletKeys; type Unspent = utxolib.bitgo.Unspent; type WalletUnspent = utxolib.bitgo.WalletUnspent; type WalletUnspentLegacy = utxolib.bitgo.WalletUnspentLegacy; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index f3cd23ac32..3226025cd1 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -1,4 +1,5 @@ export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction'; export { parseTransaction } from './parseTransaction'; -export { verifyTransaction } from './verifyTransaction'; export { CustomChangeOptions } from './parseOutput'; +export { verifyTransaction } from './verifyTransaction'; +export { signTransaction } from './signTransaction'; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts new file mode 100644 index 0000000000..49a53c979d --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts @@ -0,0 +1,152 @@ +import assert from 'assert'; +import _ from 'lodash'; +import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib'; +import * as utxolib from '@bitgo/utxo-lib'; +import { isTriple, Triple } from '@bitgo/sdk-core'; +import buildDebug from 'debug'; + +import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from '../../sign'; +import { AbstractUtxoCoin, DecodedTransaction, RootWalletKeys } from '../../abstractUtxoCoin'; + +const debug = buildDebug('bitgo:abstract-utxo:signTransaction'); + +/** + * 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( + coin: AbstractUtxoCoin, + tx: DecodedTransaction, + params: { + walletId: string | undefined; + txInfo: { unspents?: utxolib.bitgo.Unspent[] } | undefined; + isLastSignature: boolean; + prv: string | undefined; + signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined; + allowNonSegwitSigningWithoutPrevTx: boolean; + pubs: string[] | undefined; + cosignerPub: string | undefined; + } +): Promise<{ txHex: string }> { + const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx); + + 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; + } + + const getSignerKeychain = (): utxolib.BIP32Interface => { + const userPrv = params.prv; + if (_.isUndefined(userPrv) || !_.isString(userPrv)) { + if (!_.isUndefined(userPrv)) { + throw new Error(`prv must be a string, got type ${typeof userPrv}`); + } + throw new Error('missing prv parameter to sign transaction'); + } + const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin); + if (signerKeychain.isNeutered()) { + throw new Error('expected user private key but received public key'); + } + debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`); + return signerKeychain; + }; + + const setSignerMusigNonceWithOverride = ( + psbt: utxolib.bitgo.UtxoPsbt, + signerKeychain: utxolib.BIP32Interface, + nonSegwitOverride: boolean + ) => { + utxolib.bitgo.withUnsafeNonSegwit(psbt, () => psbt.setAllInputsMusig2NonceHD(signerKeychain), nonSegwitOverride); + }; + + let signerKeychain: utxolib.BIP32Interface | undefined; + + if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) { + switch (params.signingStep) { + case 'signerNonce': + signerKeychain = getSignerKeychain(); + setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx); + 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.signPsbt(tx.toHex(), params.walletId)).psbt }; + 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'); + signerKeychain = getSignerKeychain(); + setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx); + const response = await coin.signPsbt(tx.toHex(), params.walletId); + tx.combine(bitgo.createPsbtFromHex(response.psbt, coin.network)); + 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() }; + } + } + + if (signerKeychain === undefined) { + signerKeychain = getSignerKeychain(); + } + + let signedTransaction: bitgo.UtxoTransaction | bitgo.UtxoPsbt; + if (tx instanceof bitgo.UtxoPsbt) { + signedTransaction = signAndVerifyPsbt(tx, signerKeychain, { + isLastSignature, + allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx, + }); + } 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); + + 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'), + }; +} diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts new file mode 100644 index 0000000000..4a1175a66e --- /dev/null +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin'; +import { isDescriptorWallet } from '../descriptor'; +import * as fixedScript from './fixedScript'; +import { IWallet } from '@bitgo/sdk-core'; + +export async function signTransaction( + coin: AbstractUtxoCoin, + params: SignTransactionOptions +): Promise<{ txHex: string }> { + const txPrebuild = params.txPrebuild; + + if (_.isUndefined(txPrebuild) || !_.isObject(txPrebuild)) { + if (!_.isUndefined(txPrebuild) && !_.isObject(txPrebuild)) { + throw new Error(`txPrebuild must be an object, got type ${typeof txPrebuild}`); + } + throw new Error('missing txPrebuild parameter'); + } + + const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); + + if (params.wallet && isDescriptorWallet(params.wallet as IWallet)) { + throw new Error('Descriptor wallets are not supported'); + } else { + return fixedScript.signTransaction(coin, tx, { + walletId: params.txPrebuild.walletId, + txInfo: params.txPrebuild.txInfo, + isLastSignature: params.isLastSignature ?? false, + prv: typeof params.prv === 'string' ? params.prv : undefined, + signingStep: params.signingStep, + allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx ?? false, + pubs: params.pubs, + cosignerPub: params.cosignerPub, + }); + } +}