Skip to content

Commit

Permalink
Merge pull request #5291 from BitGo/BTC-1450.impl-descriptor-signTx
Browse files Browse the repository at this point in the history
feat(abstract-utxo): implement signTx for descriptor wallets
  • Loading branch information
OttoAllmendinger authored Dec 17, 2024
2 parents 0ae87df + 24eaced commit 1afb16d
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 38 deletions.
6 changes: 2 additions & 4 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ type UtxoBaseSignTransactionOptions<TNumber extends number | bigint = number> =
* transaction (nonWitnessUtxo)
*/
allowNonSegwitSigningWithoutPrevTx?: boolean;
wallet?: UtxoWallet;
};

export type SignTransactionOptions<TNumber extends number | bigint = number> = UtxoBaseSignTransactionOptions<TNumber> &
Expand Down Expand Up @@ -508,9 +509,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
async postProcessPrebuild<TNumber extends number | bigint>(
prebuild: TransactionPrebuild<TNumber>
): Promise<TransactionPrebuild<TNumber>> {
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;
Expand Down Expand Up @@ -837,7 +835,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
async signTransaction<TNumber extends number | bigint = number>(
params: SignTransactionOptions<TNumber>
): Promise<SignedTransaction | HalfSignedUtxoTransaction> {
return signTransaction<TNumber>(this, params);
return signTransaction<TNumber>(this, this.bitgo, params);
}

/**
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
Expand Up @@ -3,3 +3,4 @@ export { explainPsbt } from './explainPsbt';
export { parse } from './parse';
export { parseToAmountType } from './parseToAmountType';
export { verifyTransaction } from './verifyTransaction';
export { signPsbt } from './signPsbt';
47 changes: 47 additions & 0 deletions modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,11 +20,11 @@ const PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();
export async function signTransaction<TNumber extends number | bigint>(
coin: AbstractUtxoCoin,
tx: DecodedTransaction<TNumber>,
signerKeychain: BIP32Interface | undefined,
params: {
walletId: string | undefined;
txInfo: { unspents?: utxolib.bitgo.Unspent<TNumber>[] } | undefined;
isLastSignature: boolean;
prv: string | undefined;
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
allowNonSegwitSigningWithoutPrevTx: boolean;
pubs: string[] | undefined;
Expand All @@ -49,22 +46,6 @@ export async function signTransaction<TNumber extends number | bigint>(
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,
Expand All @@ -73,12 +54,10 @@ export async function signTransaction<TNumber extends number | bigint>(
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() };
Expand All @@ -99,7 +78,7 @@ export async function signTransaction<TNumber extends number | bigint>(
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));
Expand All @@ -117,12 +96,9 @@ export async function signTransaction<TNumber extends number | bigint>(
}
}

if (signerKeychain === undefined) {
signerKeychain = getSignerKeychain();
}

let signedTransaction: bitgo.UtxoTransaction<bigint> | bitgo.UtxoPsbt;
if (tx instanceof bitgo.UtxoPsbt) {
assert(signerKeychain);
signedTransaction = signAndVerifyPsbt(tx, signerKeychain, {
isLastSignature,
allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx,
Expand All @@ -140,6 +116,7 @@ export async function signTransaction<TNumber extends number | bigint>(
const cosignerPub = params.cosignerPub ?? params.pubs[2];
const cosignerKeychain = bip32.fromBase58(cosignerPub);

assert(signerKeychain);
const walletSigner = new bitgo.WalletUnspentSigner<RootWalletKeys>(keychains, signerKeychain, cosignerKeychain);
signedTransaction = signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, {
isLastSignature,
Expand Down
51 changes: 45 additions & 6 deletions modules/abstract-utxo/src/transaction/signTransaction.ts
Original file line number Diff line number Diff line change
@@ -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<TNumber extends number | bigint>(
coin: AbstractUtxoCoin,
bitgo: BitGoBase,
params: SignTransactionOptions<TNumber>
): Promise<{ txHex: string }> {
const txPrebuild = params.txPrebuild;
Expand All @@ -19,14 +43,29 @@ export async function signTransaction<TNumber extends number | bigint>(

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,
Expand Down
30 changes: 30 additions & 0 deletions modules/abstract-utxo/test/transaction/descriptor/sign.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});

0 comments on commit 1afb16d

Please sign in to comment.