Skip to content

Commit ebed7be

Browse files
Merge pull request #7671 from BitGo/BTC-2806.add-signtx-tests
feat(utxo): refactor signing flow and improve MuSig2 support
2 parents e111914 + 1eee8fe commit ebed7be

File tree

8 files changed

+135
-31
lines changed

8 files changed

+135
-31
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { isReplayProtectionUnspent } from './transaction/fixedScript/replayProte
5959
import { supportedCrossChainRecoveries } from './config';
6060
import {
6161
assertValidTransactionRecipient,
62+
DecodedTransaction,
6263
explainTx,
6364
fromExtendedAddressFormat,
6465
isScriptRecipient,
@@ -178,9 +179,7 @@ function convertValidationErrorToTxIntentMismatch(
178179
return txIntentError;
179180
}
180181

181-
export type DecodedTransaction<TNumber extends number | bigint> =
182-
| utxolib.bitgo.UtxoTransaction<TNumber>
183-
| utxolib.bitgo.UtxoPsbt;
182+
export type { DecodedTransaction } from './transaction/types';
184183

185184
export type RootWalletKeys = bitgo.RootWalletKeys;
186185

@@ -548,6 +547,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
548547
}
549548
}
550549

550+
decodeTransactionAsPsbt(input: Buffer | string): utxolib.bitgo.UtxoPsbt {
551+
const decoded = this.decodeTransaction(input);
552+
if (!(decoded instanceof utxolib.bitgo.UtxoPsbt)) {
553+
throw new Error('expected psbt but got transaction');
554+
}
555+
return decoded;
556+
}
557+
551558
decodeTransactionFromPrebuild<TNumber extends number | bigint>(prebuild: {
552559
txHex?: string;
553560
txBase64?: string;
@@ -712,12 +719,23 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
712719
* @param psbtHex all MuSig2 inputs should contain user MuSig2 nonce
713720
* @param walletId
714721
*/
715-
async signPsbt(psbtHex: string, walletId: string): Promise<SignPsbtResponse> {
716-
const params: SignPsbtRequest = { psbt: psbtHex };
717-
return await this.bitgo
722+
async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt> {
723+
const params: SignPsbtRequest = { psbt: psbt.toHex() };
724+
const response = await this.bitgo
718725
.post(this.url('/wallet/' + walletId + '/tx/signpsbt'))
719726
.send(params)
720727
.result();
728+
return this.decodeTransactionAsPsbt(response.psbt);
729+
}
730+
731+
/**
732+
* @deprecated Use getMusig2Nonces instead
733+
* @returns input psbt added with deterministic MuSig2 nonce for bitgo key for each MuSig2 inputs.
734+
* @param psbtHex all MuSig2 inputs should contain user MuSig2 nonce
735+
* @param walletId
736+
*/
737+
async signPsbt(psbtHex: string, walletId: string): Promise<SignPsbtResponse> {
738+
return { psbt: (await this.getMusig2Nonces(this.decodeTransactionAsPsbt(psbtHex), walletId)).toHex() };
721739
}
722740

723741
/**
@@ -727,9 +745,11 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
727745
async signPsbtFromOVC(ovcJson: Record<string, unknown>): Promise<Record<string, unknown>> {
728746
assert(ovcJson['psbtHex'], 'ovcJson must contain psbtHex');
729747
assert(ovcJson['walletId'], 'ovcJson must contain walletId');
730-
const psbt = (await this.signPsbt(ovcJson['psbtHex'] as string, ovcJson['walletId'] as string)).psbt;
731-
assert(psbt, 'psbt not found');
732-
return _.extend(ovcJson, { txHex: psbt });
748+
const psbt = await this.getMusig2Nonces(
749+
this.decodeTransactionAsPsbt(ovcJson['psbtHex'] as string),
750+
ovcJson['walletId'] as string
751+
);
752+
return _.extend(ovcJson, { txHex: psbt.toHex() });
733753
}
734754

735755
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export { explainPsbtWasm } from './explainPsbtWasm';
33
export { parseTransaction } from './parseTransaction';
44
export { CustomChangeOptions } from './parseOutput';
55
export { verifyTransaction } from './verifyTransaction';
6-
export { signTransaction } from './signTransaction';
6+
export { signTransaction, Musig2Participant } from './signTransaction';
77
export * from './sign';
88
export * from './replayProtection';

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type Unspent<TNumber extends number | bigint = number> = utxolib.bitgo.Unspent<T
1111

1212
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
1313

14-
type PsbtParsedScriptTypes =
14+
type PsbtParsedScriptType =
1515
| 'p2sh'
1616
| 'p2wsh'
1717
| 'p2shP2wsh'
@@ -22,17 +22,24 @@ type PsbtParsedScriptTypes =
2222
export class InputSigningError<TNumber extends number | bigint = number> extends Error {
2323
static expectedWalletUnspent<TNumber extends number | bigint>(
2424
inputIndex: number,
25+
inputType: PsbtParsedScriptType | null, // null for legacy transaction format
2526
unspent: Unspent<TNumber> | { id: string }
2627
): InputSigningError<TNumber> {
27-
return new InputSigningError(inputIndex, unspent, `not a wallet unspent, not a replay protection unspent`);
28+
return new InputSigningError(
29+
inputIndex,
30+
inputType,
31+
unspent,
32+
`not a wallet unspent, not a replay protection unspent`
33+
);
2834
}
2935

3036
constructor(
3137
public inputIndex: number,
38+
public inputType: PsbtParsedScriptType | null, // null for legacy transaction format
3239
public unspent: Unspent<TNumber> | { id: string },
3340
public reason: Error | string
3441
) {
35-
super(`signing error at input ${inputIndex}: unspentId=${unspent.id}: ${reason}`);
42+
super(`signing error at input ${inputIndex}: type=${inputType} unspentId=${unspent.id}: ${reason}`);
3643
}
3744
}
3845

@@ -70,7 +77,7 @@ export function signAndVerifyPsbt(
7077
): utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint> {
7178
const txInputs = psbt.txInputs;
7279
const outputIds: string[] = [];
73-
const scriptTypes: PsbtParsedScriptTypes[] = [];
80+
const scriptTypes: PsbtParsedScriptType[] = [];
7481

7582
const signErrors: InputSigningError<bigint>[] = psbt.data.inputs
7683
.map((input, inputIndex: number) => {
@@ -89,7 +96,7 @@ export function signAndVerifyPsbt(
8996
psbt.signInputHD(inputIndex, signerKeychain);
9097
debug('Successfully signed input %d of %d', inputIndex + 1, psbt.data.inputs.length);
9198
} catch (e) {
92-
return new InputSigningError<bigint>(inputIndex, { id: outputId }, e);
99+
return new InputSigningError<bigint>(inputIndex, scriptType, { id: outputId }, e);
93100
}
94101
})
95102
.filter((e): e is InputSigningError<bigint> => e !== undefined);
@@ -109,11 +116,11 @@ export function signAndVerifyPsbt(
109116
const outputId = outputIds[inputIndex];
110117
try {
111118
if (!psbt.validateSignaturesOfInputHD(inputIndex, signerKeychain)) {
112-
return new InputSigningError(inputIndex, { id: outputId }, new Error(`invalid signature`));
119+
return new InputSigningError(inputIndex, scriptType, { id: outputId }, new Error(`invalid signature`));
113120
}
114121
} catch (e) {
115122
debug('Invalid signature');
116-
return new InputSigningError<bigint>(inputIndex, { id: outputId }, e);
123+
return new InputSigningError<bigint>(inputIndex, scriptType, { id: outputId }, e);
117124
}
118125
})
119126
.filter((e): e is InputSigningError<bigint> => e !== undefined);
@@ -178,13 +185,13 @@ export function signAndVerifyWalletTransaction<TNumber extends number | bigint>(
178185
return;
179186
}
180187
if (!isWalletUnspent<TNumber>(unspent)) {
181-
return InputSigningError.expectedWalletUnspent<TNumber>(inputIndex, unspent);
188+
return InputSigningError.expectedWalletUnspent<TNumber>(inputIndex, null, unspent);
182189
}
183190
try {
184191
signInputWithUnspent<TNumber>(txBuilder, inputIndex, unspent, walletSigner);
185192
debug('Successfully signed input %d of %d', inputIndex + 1, unspents.length);
186193
} catch (e) {
187-
return new InputSigningError<TNumber>(inputIndex, unspent, e);
194+
return new InputSigningError<TNumber>(inputIndex, null, unspent, e);
188195
}
189196
})
190197
.filter((e): e is InputSigningError<TNumber> => e !== undefined);
@@ -203,18 +210,18 @@ export function signAndVerifyWalletTransaction<TNumber extends number | bigint>(
203210
return;
204211
}
205212
if (!isWalletUnspent<TNumber>(unspent)) {
206-
return InputSigningError.expectedWalletUnspent<TNumber>(inputIndex, unspent);
213+
return InputSigningError.expectedWalletUnspent<TNumber>(inputIndex, null, unspent);
207214
}
208215
try {
209216
const publicKey = walletSigner.deriveForChainAndIndex(unspent.chain, unspent.index).signer.publicKey;
210217
if (
211218
!utxolib.bitgo.verifySignatureWithPublicKey<TNumber>(signedTransaction, inputIndex, prevOutputs, publicKey)
212219
) {
213-
return new InputSigningError(inputIndex, unspent, new Error(`invalid signature`));
220+
return new InputSigningError(inputIndex, null, unspent, new Error(`invalid signature`));
214221
}
215222
} catch (e) {
216223
debug('Invalid signature');
217-
return new InputSigningError<TNumber>(inputIndex, unspent, e);
224+
return new InputSigningError<TNumber>(inputIndex, null, unspent, e);
218225
}
219226
})
220227
.filter((e): e is InputSigningError<TNumber> => e !== undefined);

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ import { bitgo } from '@bitgo/utxo-lib';
66
import * as utxolib from '@bitgo/utxo-lib';
77
import { isTriple, Triple } from '@bitgo/sdk-core';
88

9-
import { AbstractUtxoCoin, DecodedTransaction, RootWalletKeys } from '../../abstractUtxoCoin';
9+
import { DecodedTransaction } from '../types';
1010

1111
import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign';
1212

13+
type RootWalletKeys = bitgo.RootWalletKeys;
14+
15+
export interface Musig2Participant {
16+
getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt>;
17+
}
18+
1319
/**
1420
* Key Value: Unsigned tx id => PSBT
1521
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
@@ -21,9 +27,10 @@ import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign';
2127
const PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();
2228

2329
export async function signTransaction<TNumber extends number | bigint>(
24-
coin: AbstractUtxoCoin,
30+
coin: Musig2Participant,
2531
tx: DecodedTransaction<TNumber>,
2632
signerKeychain: BIP32Interface | undefined,
33+
network: utxolib.Network,
2734
params: {
2835
walletId: string | undefined;
2936
txInfo: { unspents?: utxolib.bitgo.Unspent<TNumber>[] } | undefined;
@@ -59,7 +66,7 @@ export async function signTransaction<TNumber extends number | bigint>(
5966
return { txHex: tx.toHex() };
6067
case 'cosignerNonce':
6168
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
62-
return { txHex: (await coin.signPsbt(tx.toHex(), params.walletId)).psbt };
69+
return { txHex: (await coin.getMusig2Nonces(tx, params.walletId)).toHex() };
6370
case 'signerSignature':
6471
const txId = tx.getUnsignedTx().getId();
6572
const psbt = PSBT_CACHE.get(txId);
@@ -76,8 +83,8 @@ export async function signTransaction<TNumber extends number | bigint>(
7683
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
7784
assert(signerKeychain);
7885
tx.setAllInputsMusig2NonceHD(signerKeychain);
79-
const response = await coin.signPsbt(tx.toHex(), params.walletId);
80-
tx.combine(bitgo.createPsbtFromHex(response.psbt, coin.network));
86+
const response = await coin.getMusig2Nonces(tx, params.walletId);
87+
tx = tx.combine(response);
8188
break;
8289
}
8390
} else {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export async function signTransaction<TNumber extends number | bigint>(
6363
throw new Error('expected a UtxoPsbt object');
6464
}
6565
} else {
66-
return fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), {
66+
return fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.network, {
6767
walletId: params.txPrebuild.walletId,
6868
txInfo: params.txPrebuild.txInfo,
6969
isLastSignature: params.isLastSignature ?? false,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
13
import type { UtxoNamedKeychains } from '../keychains';
24

35
import type { CustomChangeOptions } from './fixedScript';
46

7+
export type DecodedTransaction<TNumber extends number | bigint> =
8+
| utxolib.bitgo.UtxoTransaction<TNumber>
9+
| utxolib.bitgo.UtxoPsbt;
10+
511
export interface BaseOutput<TAmount = string | number> {
612
address: string;
713
amount: TAmount;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from 'node:assert/strict';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
5+
import { signAndVerifyPsbt } from '../../../../src/transaction/fixedScript/sign';
6+
7+
function describeSignAndVerifyPsbt(acidTest: utxolib.testutil.AcidTest) {
8+
describe(`${acidTest.name}`, function () {
9+
it('should sign unsigned psbt to halfsigned', function () {
10+
// Create unsigned PSBT
11+
const psbt = acidTest.createPsbt();
12+
13+
// Set musig2 nonces for taproot inputs before signing
14+
const sessionId = Buffer.alloc(32);
15+
psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.user, { sessionId });
16+
psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.bitgo, { deterministic: true });
17+
18+
// Sign with user key
19+
const result = signAndVerifyPsbt(psbt, acidTest.rootWalletKeys.user, {
20+
isLastSignature: false,
21+
});
22+
23+
// Result should be a PSBT (not finalized)
24+
assert(result instanceof utxolib.bitgo.UtxoPsbt, 'should return UtxoPsbt when not last signature');
25+
26+
// Verify that all wallet inputs have been signed by user key
27+
result.data.inputs.forEach((input, inputIndex) => {
28+
const { scriptType } = utxolib.bitgo.parsePsbtInput(input);
29+
30+
// Skip replay protection inputs (p2shP2pk)
31+
if (scriptType === 'p2shP2pk') {
32+
return;
33+
}
34+
35+
// Verify user signature is present
36+
const isValid = result.validateSignaturesOfInputHD(inputIndex, acidTest.rootWalletKeys.user);
37+
assert(isValid, `input ${inputIndex} should have valid user signature`);
38+
});
39+
});
40+
});
41+
}
42+
43+
describe('signAndVerifyPsbt', function () {
44+
// Create test suite with includeP2trMusig2ScriptPath set to false
45+
// p2trMusig2 script path inputs are signed by user and backup keys,
46+
// which is not the typical signing pattern and makes testing more complex
47+
utxolib.testutil.AcidTest.suite({ includeP2trMusig2ScriptPath: false })
48+
.filter((test) => test.signStage === 'unsigned')
49+
.forEach((test) => {
50+
describeSignAndVerifyPsbt(test);
51+
});
52+
});

modules/utxo-lib/src/testutil/psbt.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ export function constructPsbt(
258258
export const txFormats = ['psbt', 'psbt-lite'] as const;
259259
export type TxFormat = (typeof txFormats)[number];
260260

261+
type SuiteConfig = {
262+
/**
263+
* By default, we include p2trMusig2 script path in the inputs.
264+
* This input is a bit of a weirdo because it is signed by the user and the
265+
* backup key, which usually is not mixed with other inputs and outputs.
266+
*
267+
* This option allows to exclude this input from the inputs.
268+
*/
269+
includeP2trMusig2ScriptPath?: boolean;
270+
};
271+
261272
/**
262273
* Creates a valid PSBT with as many features as possible.
263274
*
@@ -297,7 +308,7 @@ export class AcidTest {
297308
this.outputs = outputs;
298309
}
299310

300-
static withDefaults(network: Network, signStage: SignStage, txFormat: TxFormat): AcidTest {
311+
static withConfig(network: Network, signStage: SignStage, txFormat: TxFormat, suiteConfig: SuiteConfig): AcidTest {
301312
const rootWalletKeys = getDefaultWalletKeys();
302313

303314
const otherWalletKeys = getWalletKeysForSeed('too many secrets');
@@ -307,6 +318,7 @@ export class AcidTest {
307318
? isSupportedScriptType(network, 'p2trMusig2')
308319
: isSupportedScriptType(network, scriptType)
309320
)
321+
.filter((scriptType) => (suiteConfig.includeP2trMusig2ScriptPath ?? true) || scriptType !== 'p2trMusig2')
310322
.map((scriptType) => ({ scriptType, value: BigInt(2000) }));
311323

312324
const outputs: Output[] = outputScriptTypes
@@ -345,12 +357,12 @@ export class AcidTest {
345357
return psbt;
346358
}
347359

348-
static suite(): AcidTest[] {
360+
static suite(suiteConfig: SuiteConfig = {}): AcidTest[] {
349361
return getNetworkList()
350362
.filter((network) => isMainnet(network) && network !== networks.bitcoinsv)
351363
.flatMap((network) =>
352364
signStages.flatMap((signStage) =>
353-
txFormats.flatMap((txFormat) => AcidTest.withDefaults(network, signStage, txFormat))
365+
txFormats.flatMap((txFormat) => AcidTest.withConfig(network, signStage, txFormat, suiteConfig))
354366
)
355367
);
356368
}

0 commit comments

Comments
 (0)