diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 5b3a455541..297398ec96 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -296,6 +296,7 @@ type UtxoBaseSignTransactionOptions = * transaction (nonWitnessUtxo) */ allowNonSegwitSigningWithoutPrevTx?: boolean; + wallet?: UtxoWallet; }; export type SignTransactionOptions = UtxoBaseSignTransactionOptions & @@ -508,9 +509,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin { async postProcessPrebuild( prebuild: TransactionPrebuild ): Promise> { - if (_.isUndefined(prebuild.txHex)) { - throw new Error('missing required txPrebuild property txHex'); - } const tx = this.decodeTransactionFromPrebuild(prebuild); if (_.isUndefined(prebuild.blockHeight)) { prebuild.blockHeight = (await this.getLatestBlockHeight()) as number; @@ -837,7 +835,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { async signTransaction( params: SignTransactionOptions ): Promise { - return signTransaction(this, params); + return signTransaction(this, this.bitgo, params); } /** diff --git a/modules/abstract-utxo/src/transaction/descriptor/index.ts b/modules/abstract-utxo/src/transaction/descriptor/index.ts index 7e1a9425da..84ac88bc42 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/index.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/index.ts @@ -3,3 +3,4 @@ export { explainPsbt } from './explainPsbt'; export { parse } from './parse'; export { parseToAmountType } from './parseToAmountType'; export { verifyTransaction } from './verifyTransaction'; +export { signPsbt } from './signPsbt'; diff --git a/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts new file mode 100644 index 0000000000..b827584e34 --- /dev/null +++ b/modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts @@ -0,0 +1,47 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { DescriptorMap } from '../../core/descriptor'; +import { findDescriptorForInput } from '../../core/descriptor/psbt/findDescriptors'; + +export class ErrorUnknownInput extends Error { + constructor(public vin: number) { + super(`missing descriptor for input ${vin}`); + } +} + +/** + * Sign a PSBT with the given keychain. + * + * Checks the descriptor map for each input in the PSBT. If the input is not + * found in the descriptor map, the behavior is determined by the `onUnknownInput` + * parameter. + * + * + * @param tx - psbt to sign + * @param descriptorMap - map of input index to descriptor + * @param signerKeychain - key to sign with + * @param params - onUnknownInput: 'throw' | 'skip' | 'sign'. + * Determines what to do when an input is not found in the + * descriptor map. + */ +export function signPsbt( + tx: utxolib.Psbt, + descriptorMap: DescriptorMap, + signerKeychain: utxolib.BIP32Interface, + params: { + onUnknownInput: 'throw' | 'skip' | 'sign'; + } +): void { + for (const [vin, input] of tx.data.inputs.entries()) { + if (!findDescriptorForInput(input, descriptorMap)) { + switch (params.onUnknownInput) { + case 'skip': + continue; + case 'throw': + throw new ErrorUnknownInput(vin); + case 'sign': + break; + } + } + tx.signInputHD(vin, signerKeychain); + } +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts index 49a53c979d..bb09bf807f 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts @@ -3,13 +3,10 @@ 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. @@ -23,11 +20,11 @@ const PSBT_CACHE = new Map(); export async function signTransaction( coin: AbstractUtxoCoin, tx: DecodedTransaction, + signerKeychain: BIP32Interface | undefined, 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; @@ -49,22 +46,6 @@ export async function signTransaction( 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, @@ -73,12 +54,10 @@ export async function signTransaction( 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(); + assert(signerKeychain); setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx); PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx); return { txHex: tx.toHex() }; @@ -99,7 +78,7 @@ export async function signTransaction( default: // this instance is not an external signer assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce'); - signerKeychain = getSignerKeychain(); + assert(signerKeychain); setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx); const response = await coin.signPsbt(tx.toHex(), params.walletId); tx.combine(bitgo.createPsbtFromHex(response.psbt, coin.network)); @@ -117,12 +96,9 @@ export async function signTransaction( } } - if (signerKeychain === undefined) { - signerKeychain = getSignerKeychain(); - } - let signedTransaction: bitgo.UtxoTransaction | bitgo.UtxoPsbt; if (tx instanceof bitgo.UtxoPsbt) { + assert(signerKeychain); signedTransaction = signAndVerifyPsbt(tx, signerKeychain, { isLastSignature, allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx, @@ -140,6 +116,7 @@ export async function signTransaction( 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, diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts index 4a1175a66e..323020b845 100644 --- a/modules/abstract-utxo/src/transaction/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -1,11 +1,35 @@ import _ from 'lodash'; +import { BitGoBase } from '@bitgo/sdk-core'; +import * as utxolib from '@bitgo/utxo-lib'; +import { bip32 } from '@bitgo/utxo-lib'; +import buildDebug from 'debug'; + import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin'; -import { isDescriptorWallet } from '../descriptor'; +import { getDescriptorMapFromWallet, getPolicyForEnv, isDescriptorWallet } from '../descriptor'; import * as fixedScript from './fixedScript'; -import { IWallet } from '@bitgo/sdk-core'; +import * as descriptor from './descriptor'; +import { fetchKeychains, toBip32Triple } from '../keychains'; + +const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction'); + +function getSignerKeychain(userPrv: unknown): utxolib.BIP32Interface | undefined { + if (userPrv === undefined) { + return undefined; + } + if (typeof userPrv !== 'string') { + throw new Error('expected user private key to be a string'); + } + 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; +} export async function signTransaction( coin: AbstractUtxoCoin, + bitgo: BitGoBase, params: SignTransactionOptions ): Promise<{ txHex: string }> { const txPrebuild = params.txPrebuild; @@ -19,14 +43,29 @@ export async function signTransaction( const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); - if (params.wallet && isDescriptorWallet(params.wallet as IWallet)) { - throw new Error('Descriptor wallets are not supported'); + const signerKeychain = getSignerKeychain(params.prv); + + const { wallet } = params; + + if (wallet && isDescriptorWallet(wallet)) { + if (!signerKeychain) { + throw new Error('missing signer'); + } + const walletKeys = toBip32Triple(await fetchKeychains(coin, wallet)); + const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(bitgo.env)); + if (tx instanceof utxolib.bitgo.UtxoPsbt) { + descriptor.signPsbt(tx, descriptorMap, signerKeychain, { + onUnknownInput: 'throw', + }); + return { txHex: tx.toHex() }; + } else { + throw new Error('expected a UtxoPsbt object'); + } } else { - return fixedScript.signTransaction(coin, tx, { + return fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), { 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, diff --git a/modules/abstract-utxo/test/transaction/descriptor/sign.ts b/modules/abstract-utxo/test/transaction/descriptor/sign.ts new file mode 100644 index 0000000000..b4a1ad9770 --- /dev/null +++ b/modules/abstract-utxo/test/transaction/descriptor/sign.ts @@ -0,0 +1,30 @@ +import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils'; +import { signPsbt } from '../../../src/transaction/descriptor'; +import { getKeyTriple } from '../../core/key.utils'; +import { getDescriptorMap } from '../../core/descriptor/descriptor.utils'; +import assert from 'assert'; +import { ErrorUnknownInput } from '../../../src/transaction/descriptor/signPsbt'; + +describe('sign', function () { + const psbtUnsigned = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3'); + const keychain = getKeyTriple('a'); + const descriptorMap = getDescriptorMap('Wsh2Of3', keychain); + const emptyDescriptorMap = new Map(); + + it('should sign a transaction', async function () { + const psbt = psbtUnsigned.clone(); + signPsbt(psbt, descriptorMap, keychain[0], { onUnknownInput: 'throw' }); + assert(psbt.validateSignaturesOfAllInputs()); + }); + + it('should be sensitive to onUnknownInput', async function () { + const psbt = psbtUnsigned.clone(); + assert.throws(() => { + signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'throw' }); + }, new ErrorUnknownInput(0)); + signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'skip' }); + assert(psbt.data.inputs[0].partialSig === undefined); + signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'sign' }); + assert(psbt.validateSignaturesOfAllInputs()); + }); +});