Skip to content

Commit 1afb16d

Browse files
Merge pull request #5291 from BitGo/BTC-1450.impl-descriptor-signTx
feat(abstract-utxo): implement signTx for descriptor wallets
2 parents 0ae87df + 24eaced commit 1afb16d

File tree

6 files changed

+130
-38
lines changed

6 files changed

+130
-38
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ type UtxoBaseSignTransactionOptions<TNumber extends number | bigint = number> =
296296
* transaction (nonWitnessUtxo)
297297
*/
298298
allowNonSegwitSigningWithoutPrevTx?: boolean;
299+
wallet?: UtxoWallet;
299300
};
300301

301302
export type SignTransactionOptions<TNumber extends number | bigint = number> = UtxoBaseSignTransactionOptions<TNumber> &
@@ -508,9 +509,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
508509
async postProcessPrebuild<TNumber extends number | bigint>(
509510
prebuild: TransactionPrebuild<TNumber>
510511
): Promise<TransactionPrebuild<TNumber>> {
511-
if (_.isUndefined(prebuild.txHex)) {
512-
throw new Error('missing required txPrebuild property txHex');
513-
}
514512
const tx = this.decodeTransactionFromPrebuild(prebuild);
515513
if (_.isUndefined(prebuild.blockHeight)) {
516514
prebuild.blockHeight = (await this.getLatestBlockHeight()) as number;
@@ -837,7 +835,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
837835
async signTransaction<TNumber extends number | bigint = number>(
838836
params: SignTransactionOptions<TNumber>
839837
): Promise<SignedTransaction | HalfSignedUtxoTransaction> {
840-
return signTransaction<TNumber>(this, params);
838+
return signTransaction<TNumber>(this, this.bitgo, params);
841839
}
842840

843841
/**

modules/abstract-utxo/src/transaction/descriptor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { explainPsbt } from './explainPsbt';
33
export { parse } from './parse';
44
export { parseToAmountType } from './parseToAmountType';
55
export { verifyTransaction } from './verifyTransaction';
6+
export { signPsbt } from './signPsbt';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { DescriptorMap } from '../../core/descriptor';
3+
import { findDescriptorForInput } from '../../core/descriptor/psbt/findDescriptors';
4+
5+
export class ErrorUnknownInput extends Error {
6+
constructor(public vin: number) {
7+
super(`missing descriptor for input ${vin}`);
8+
}
9+
}
10+
11+
/**
12+
* Sign a PSBT with the given keychain.
13+
*
14+
* Checks the descriptor map for each input in the PSBT. If the input is not
15+
* found in the descriptor map, the behavior is determined by the `onUnknownInput`
16+
* parameter.
17+
*
18+
*
19+
* @param tx - psbt to sign
20+
* @param descriptorMap - map of input index to descriptor
21+
* @param signerKeychain - key to sign with
22+
* @param params - onUnknownInput: 'throw' | 'skip' | 'sign'.
23+
* Determines what to do when an input is not found in the
24+
* descriptor map.
25+
*/
26+
export function signPsbt(
27+
tx: utxolib.Psbt,
28+
descriptorMap: DescriptorMap,
29+
signerKeychain: utxolib.BIP32Interface,
30+
params: {
31+
onUnknownInput: 'throw' | 'skip' | 'sign';
32+
}
33+
): void {
34+
for (const [vin, input] of tx.data.inputs.entries()) {
35+
if (!findDescriptorForInput(input, descriptorMap)) {
36+
switch (params.onUnknownInput) {
37+
case 'skip':
38+
continue;
39+
case 'throw':
40+
throw new ErrorUnknownInput(vin);
41+
case 'sign':
42+
break;
43+
}
44+
}
45+
tx.signInputHD(vin, signerKeychain);
46+
}
47+
}

modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@ import _ from 'lodash';
33
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
44
import * as utxolib from '@bitgo/utxo-lib';
55
import { isTriple, Triple } from '@bitgo/sdk-core';
6-
import buildDebug from 'debug';
76

87
import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from '../../sign';
98
import { AbstractUtxoCoin, DecodedTransaction, RootWalletKeys } from '../../abstractUtxoCoin';
109

11-
const debug = buildDebug('bitgo:abstract-utxo:signTransaction');
12-
1310
/**
1411
* Key Value: Unsigned tx id => PSBT
1512
* 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<string, utxolib.bitgo.UtxoPsbt>();
2320
export async function signTransaction<TNumber extends number | bigint>(
2421
coin: AbstractUtxoCoin,
2522
tx: DecodedTransaction<TNumber>,
23+
signerKeychain: BIP32Interface | undefined,
2624
params: {
2725
walletId: string | undefined;
2826
txInfo: { unspents?: utxolib.bitgo.Unspent<TNumber>[] } | undefined;
2927
isLastSignature: boolean;
30-
prv: string | undefined;
3128
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
3229
allowNonSegwitSigningWithoutPrevTx: boolean;
3330
pubs: string[] | undefined;
@@ -49,22 +46,6 @@ export async function signTransaction<TNumber extends number | bigint>(
4946
isLastSignature = params.isLastSignature;
5047
}
5148

52-
const getSignerKeychain = (): utxolib.BIP32Interface => {
53-
const userPrv = params.prv;
54-
if (_.isUndefined(userPrv) || !_.isString(userPrv)) {
55-
if (!_.isUndefined(userPrv)) {
56-
throw new Error(`prv must be a string, got type ${typeof userPrv}`);
57-
}
58-
throw new Error('missing prv parameter to sign transaction');
59-
}
60-
const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin);
61-
if (signerKeychain.isNeutered()) {
62-
throw new Error('expected user private key but received public key');
63-
}
64-
debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`);
65-
return signerKeychain;
66-
};
67-
6849
const setSignerMusigNonceWithOverride = (
6950
psbt: utxolib.bitgo.UtxoPsbt,
7051
signerKeychain: utxolib.BIP32Interface,
@@ -73,12 +54,10 @@ export async function signTransaction<TNumber extends number | bigint>(
7354
utxolib.bitgo.withUnsafeNonSegwit(psbt, () => psbt.setAllInputsMusig2NonceHD(signerKeychain), nonSegwitOverride);
7455
};
7556

76-
let signerKeychain: utxolib.BIP32Interface | undefined;
77-
7857
if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) {
7958
switch (params.signingStep) {
8059
case 'signerNonce':
81-
signerKeychain = getSignerKeychain();
60+
assert(signerKeychain);
8261
setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx);
8362
PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx);
8463
return { txHex: tx.toHex() };
@@ -99,7 +78,7 @@ export async function signTransaction<TNumber extends number | bigint>(
9978
default:
10079
// this instance is not an external signer
10180
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
102-
signerKeychain = getSignerKeychain();
81+
assert(signerKeychain);
10382
setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx);
10483
const response = await coin.signPsbt(tx.toHex(), params.walletId);
10584
tx.combine(bitgo.createPsbtFromHex(response.psbt, coin.network));
@@ -117,12 +96,9 @@ export async function signTransaction<TNumber extends number | bigint>(
11796
}
11897
}
11998

120-
if (signerKeychain === undefined) {
121-
signerKeychain = getSignerKeychain();
122-
}
123-
12499
let signedTransaction: bitgo.UtxoTransaction<bigint> | bitgo.UtxoPsbt;
125100
if (tx instanceof bitgo.UtxoPsbt) {
101+
assert(signerKeychain);
126102
signedTransaction = signAndVerifyPsbt(tx, signerKeychain, {
127103
isLastSignature,
128104
allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx,
@@ -140,6 +116,7 @@ export async function signTransaction<TNumber extends number | bigint>(
140116
const cosignerPub = params.cosignerPub ?? params.pubs[2];
141117
const cosignerKeychain = bip32.fromBase58(cosignerPub);
142118

119+
assert(signerKeychain);
143120
const walletSigner = new bitgo.WalletUnspentSigner<RootWalletKeys>(keychains, signerKeychain, cosignerKeychain);
144121
signedTransaction = signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, {
145122
isLastSignature,

modules/abstract-utxo/src/transaction/signTransaction.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import _ from 'lodash';
2+
import { BitGoBase } from '@bitgo/sdk-core';
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { bip32 } from '@bitgo/utxo-lib';
5+
import buildDebug from 'debug';
6+
27
import { AbstractUtxoCoin, SignTransactionOptions } from '../abstractUtxoCoin';
3-
import { isDescriptorWallet } from '../descriptor';
8+
import { getDescriptorMapFromWallet, getPolicyForEnv, isDescriptorWallet } from '../descriptor';
49
import * as fixedScript from './fixedScript';
5-
import { IWallet } from '@bitgo/sdk-core';
10+
import * as descriptor from './descriptor';
11+
import { fetchKeychains, toBip32Triple } from '../keychains';
12+
13+
const debug = buildDebug('bitgo:abstract-utxo:transaction:signTransaction');
14+
15+
function getSignerKeychain(userPrv: unknown): utxolib.BIP32Interface | undefined {
16+
if (userPrv === undefined) {
17+
return undefined;
18+
}
19+
if (typeof userPrv !== 'string') {
20+
throw new Error('expected user private key to be a string');
21+
}
22+
const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin);
23+
if (signerKeychain.isNeutered()) {
24+
throw new Error('expected user private key but received public key');
25+
}
26+
debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`);
27+
return signerKeychain;
28+
}
629

730
export async function signTransaction<TNumber extends number | bigint>(
831
coin: AbstractUtxoCoin,
32+
bitgo: BitGoBase,
933
params: SignTransactionOptions<TNumber>
1034
): Promise<{ txHex: string }> {
1135
const txPrebuild = params.txPrebuild;
@@ -19,14 +43,29 @@ export async function signTransaction<TNumber extends number | bigint>(
1943

2044
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
2145

22-
if (params.wallet && isDescriptorWallet(params.wallet as IWallet)) {
23-
throw new Error('Descriptor wallets are not supported');
46+
const signerKeychain = getSignerKeychain(params.prv);
47+
48+
const { wallet } = params;
49+
50+
if (wallet && isDescriptorWallet(wallet)) {
51+
if (!signerKeychain) {
52+
throw new Error('missing signer');
53+
}
54+
const walletKeys = toBip32Triple(await fetchKeychains(coin, wallet));
55+
const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(bitgo.env));
56+
if (tx instanceof utxolib.bitgo.UtxoPsbt) {
57+
descriptor.signPsbt(tx, descriptorMap, signerKeychain, {
58+
onUnknownInput: 'throw',
59+
});
60+
return { txHex: tx.toHex() };
61+
} else {
62+
throw new Error('expected a UtxoPsbt object');
63+
}
2464
} else {
25-
return fixedScript.signTransaction(coin, tx, {
65+
return fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), {
2666
walletId: params.txPrebuild.walletId,
2767
txInfo: params.txPrebuild.txInfo,
2868
isLastSignature: params.isLastSignature ?? false,
29-
prv: typeof params.prv === 'string' ? params.prv : undefined,
3069
signingStep: params.signingStep,
3170
allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx ?? false,
3271
pubs: params.pubs,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils';
2+
import { signPsbt } from '../../../src/transaction/descriptor';
3+
import { getKeyTriple } from '../../core/key.utils';
4+
import { getDescriptorMap } from '../../core/descriptor/descriptor.utils';
5+
import assert from 'assert';
6+
import { ErrorUnknownInput } from '../../../src/transaction/descriptor/signPsbt';
7+
8+
describe('sign', function () {
9+
const psbtUnsigned = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3');
10+
const keychain = getKeyTriple('a');
11+
const descriptorMap = getDescriptorMap('Wsh2Of3', keychain);
12+
const emptyDescriptorMap = new Map();
13+
14+
it('should sign a transaction', async function () {
15+
const psbt = psbtUnsigned.clone();
16+
signPsbt(psbt, descriptorMap, keychain[0], { onUnknownInput: 'throw' });
17+
assert(psbt.validateSignaturesOfAllInputs());
18+
});
19+
20+
it('should be sensitive to onUnknownInput', async function () {
21+
const psbt = psbtUnsigned.clone();
22+
assert.throws(() => {
23+
signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'throw' });
24+
}, new ErrorUnknownInput(0));
25+
signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'skip' });
26+
assert(psbt.data.inputs[0].partialSig === undefined);
27+
signPsbt(psbt, emptyDescriptorMap, keychain[0], { onUnknownInput: 'sign' });
28+
assert(psbt.validateSignaturesOfAllInputs());
29+
});
30+
});

0 commit comments

Comments
 (0)