Skip to content

Commit 42cbae3

Browse files
Merge pull request #7690 from BitGo/BTC-2806.signtx-utxo-wasm
feat(abstract-utxo): add WASM-based PSBT signing
2 parents bc295d8 + 2dc2ede commit 42cbae3

File tree

6 files changed

+323
-46
lines changed

6 files changed

+323
-46
lines changed

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ export { parseTransaction } from './parseTransaction';
44
export { CustomChangeOptions } from './parseOutput';
55
export { verifyTransaction } from './verifyTransaction';
66
export { signTransaction } from './signTransaction';
7-
export { Musig2Participant } from './signPsbt';
87
export * from './signLegacyTransaction';
98
export * from './SigningError';
109
export * from './replayProtection';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface Musig2Participant<T> {
2+
getMusig2Nonces(psbt: T, walletId: string): Promise<T>;
3+
}

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { bitgo } from '@bitgo/utxo-lib';
66
import debugLib from 'debug';
77

88
import { InputSigningError, TransactionSigningError } from './SigningError';
9+
import { Musig2Participant } from './musig2';
910

1011
const debug = debugLib('bitgo:v2:utxo');
1112

@@ -15,7 +16,11 @@ export type PsbtParsedScriptType =
1516
| 'p2shP2wsh'
1617
| 'p2shP2pk'
1718
| 'taprootKeyPathSpend'
18-
| 'taprootScriptPathSpend';
19+
| 'taprootScriptPathSpend'
20+
// wasm-utxo types
21+
| 'p2trLegacy'
22+
| 'p2trMusig2ScriptPath'
23+
| 'p2trMusig2KeyPath';
1924

2025
/**
2126
* Sign all inputs of a psbt and verify signatures after signing.
@@ -102,10 +107,6 @@ export function signAndVerifyPsbt(
102107
return psbt;
103108
}
104109

105-
export interface Musig2Participant {
106-
getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt>;
107-
}
108-
109110
/**
110111
* Key Value: Unsigned tx id => PSBT
111112
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
@@ -117,7 +118,7 @@ export interface Musig2Participant {
117118
const PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();
118119

119120
export async function signPsbtWithMusig2Participant(
120-
coin: Musig2Participant,
121+
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt>,
121122
tx: utxolib.bitgo.UtxoPsbt,
122123
signerKeychain: BIP32Interface | undefined,
123124
params: {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import assert from 'assert';
2+
3+
import { BIP32Interface } from '@bitgo/utxo-lib';
4+
import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo';
5+
6+
import { InputSigningError, TransactionSigningError } from './SigningError';
7+
import { Musig2Participant } from './musig2';
8+
9+
export type ReplayProtectionKeys = {
10+
publicKeys: (Uint8Array | ECPair)[];
11+
};
12+
13+
/**
14+
* Key Value: Unsigned tx id => PSBT
15+
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
16+
* Reason: MuSig2 signer secure nonce is cached in the BitGoPsbt object. It will be required during the signing step.
17+
* For more info, check SignTransactionOptions.signingStep
18+
*/
19+
const PSBT_CACHE_WASM = new Map<string, fixedScriptWallet.BitGoPsbt>();
20+
21+
function hasKeyPathSpendInput(
22+
tx: fixedScriptWallet.BitGoPsbt,
23+
rootWalletKeys: fixedScriptWallet.IWalletKeys,
24+
replayProtection: ReplayProtectionKeys
25+
): boolean {
26+
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);
27+
return parsed.inputs.some((input) => input.scriptType === 'p2trMusig2KeyPath');
28+
}
29+
30+
/**
31+
* Sign all inputs of a PSBT and verify signatures after signing.
32+
* Collects and logs signing errors and verification errors, throws error in the end if any of them failed.
33+
*
34+
* If it is the last signature, finalize and extract the transaction from the psbt.
35+
*/
36+
export function signAndVerifyPsbtWasm(
37+
tx: fixedScriptWallet.BitGoPsbt,
38+
signerKeychain: BIP32Interface,
39+
rootWalletKeys: fixedScriptWallet.IWalletKeys,
40+
replayProtection: ReplayProtectionKeys,
41+
{ isLastSignature }: { isLastSignature: boolean }
42+
): fixedScriptWallet.BitGoPsbt | Uint8Array {
43+
const wasmSigner = toWasmBIP32(signerKeychain);
44+
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);
45+
46+
const signErrors: InputSigningError<bigint>[] = [];
47+
const verifyErrors: InputSigningError<bigint>[] = [];
48+
49+
// Sign all inputs (skipping replay protection inputs)
50+
parsed.inputs.forEach((input, inputIndex) => {
51+
if (input.scriptType === 'p2shP2pk') {
52+
// Skip replay protection inputs - they are platform signed only
53+
return;
54+
}
55+
56+
const outputId = `${input.previousOutput.txid}:${input.previousOutput.vout}`;
57+
try {
58+
tx.sign(inputIndex, wasmSigner);
59+
} catch (e) {
60+
signErrors.push(new InputSigningError<bigint>(inputIndex, input.scriptType, { id: outputId }, e));
61+
}
62+
});
63+
64+
// Verify signatures for all signed inputs
65+
parsed.inputs.forEach((input, inputIndex) => {
66+
if (input.scriptType === 'p2shP2pk') {
67+
return;
68+
}
69+
70+
const outputId = `${input.previousOutput.txid}:${input.previousOutput.vout}`;
71+
try {
72+
if (!tx.verifySignature(inputIndex, wasmSigner)) {
73+
verifyErrors.push(
74+
new InputSigningError(inputIndex, input.scriptType, { id: outputId }, new Error('invalid signature'))
75+
);
76+
}
77+
} catch (e) {
78+
verifyErrors.push(new InputSigningError<bigint>(inputIndex, input.scriptType, { id: outputId }, e));
79+
}
80+
});
81+
82+
if (signErrors.length || verifyErrors.length) {
83+
throw new TransactionSigningError(signErrors, verifyErrors);
84+
}
85+
86+
if (isLastSignature) {
87+
tx.finalizeAllInputs();
88+
return tx.extractTransaction();
89+
}
90+
91+
return tx;
92+
}
93+
94+
function toWasmBIP32(key: BIP32Interface): BIP32 {
95+
// Convert using base58 string to ensure private key is properly transferred
96+
return BIP32.fromBase58(key.toBase58());
97+
}
98+
99+
export async function signPsbtWithMusig2ParticipantWasm(
100+
coin: Musig2Participant<fixedScriptWallet.BitGoPsbt>,
101+
tx: fixedScriptWallet.BitGoPsbt,
102+
signerKeychain: BIP32Interface | undefined,
103+
rootWalletKeys: fixedScriptWallet.IWalletKeys,
104+
replayProtection: ReplayProtectionKeys,
105+
params: {
106+
isLastSignature: boolean;
107+
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
108+
walletId: string | undefined;
109+
}
110+
): Promise<fixedScriptWallet.BitGoPsbt | Uint8Array> {
111+
const wasmSigner = signerKeychain ? toWasmBIP32(signerKeychain) : undefined;
112+
113+
if (hasKeyPathSpendInput(tx, rootWalletKeys, replayProtection)) {
114+
// We can only be the first signature on a transaction with taproot key path spend inputs because
115+
// we require the secret nonce in the cache of the first signer, which is impossible to retrieve if
116+
// deserialized from a hex.
117+
if (params.isLastSignature) {
118+
throw new Error('Cannot be last signature on a transaction with key path spend inputs');
119+
}
120+
121+
switch (params.signingStep) {
122+
case 'signerNonce':
123+
assert(wasmSigner);
124+
tx.generateMusig2Nonces(wasmSigner);
125+
PSBT_CACHE_WASM.set(tx.unsignedTxid(), tx);
126+
return tx;
127+
case 'cosignerNonce':
128+
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
129+
return await coin.getMusig2Nonces(tx, params.walletId);
130+
case 'signerSignature': {
131+
const txId = tx.unsignedTxid();
132+
const cachedPsbt = PSBT_CACHE_WASM.get(txId);
133+
assert(
134+
cachedPsbt,
135+
`Psbt is missing from txCache (cache size ${PSBT_CACHE_WASM.size}).
136+
This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.`
137+
);
138+
PSBT_CACHE_WASM.delete(txId);
139+
cachedPsbt.combineMusig2Nonces(tx);
140+
tx = cachedPsbt;
141+
break;
142+
}
143+
default:
144+
// this instance is not an external signer
145+
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
146+
assert(wasmSigner);
147+
tx.generateMusig2Nonces(wasmSigner);
148+
const response = await coin.getMusig2Nonces(tx, params.walletId);
149+
tx.combineMusig2Nonces(response);
150+
break;
151+
}
152+
} else {
153+
switch (params.signingStep) {
154+
case 'signerNonce':
155+
case 'cosignerNonce':
156+
/**
157+
* In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s).
158+
* Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence.
159+
*/
160+
return tx;
161+
}
162+
}
163+
164+
assert(signerKeychain);
165+
return signAndVerifyPsbtWasm(tx, signerKeychain, rootWalletKeys, replayProtection, {
166+
isLastSignature: params.isLastSignature,
167+
});
168+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import * as utxolib from '@bitgo/utxo-lib';
55

66
import { DecodedTransaction } from '../types';
77

8+
import { Musig2Participant } from './musig2';
89
import { signLegacyTransaction } from './signLegacyTransaction';
9-
import { Musig2Participant, signPsbtWithMusig2Participant } from './signPsbt';
10+
import { signPsbtWithMusig2Participant } from './signPsbt';
1011

1112
export async function signTransaction(
12-
coin: Musig2Participant,
13+
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt>,
1314
tx: DecodedTransaction<bigint | number>,
1415
signerKeychain: BIP32Interface | undefined,
1516
network: utxolib.Network,

0 commit comments

Comments
 (0)