Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export { parseTransaction } from './parseTransaction';
export { CustomChangeOptions } from './parseOutput';
export { verifyTransaction } from './verifyTransaction';
export { signTransaction } from './signTransaction';
export { Musig2Participant } from './signPsbt';
export * from './signLegacyTransaction';
export * from './SigningError';
export * from './replayProtection';
3 changes: 3 additions & 0 deletions modules/abstract-utxo/src/transaction/fixedScript/musig2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Musig2Participant<T> {
getMusig2Nonces(psbt: T, walletId: string): Promise<T>;
}
13 changes: 7 additions & 6 deletions modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { bitgo } from '@bitgo/utxo-lib';
import debugLib from 'debug';

import { InputSigningError, TransactionSigningError } from './SigningError';
import { Musig2Participant } from './musig2';

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

Expand All @@ -15,7 +16,11 @@ export type PsbtParsedScriptType =
| 'p2shP2wsh'
| 'p2shP2pk'
| 'taprootKeyPathSpend'
| 'taprootScriptPathSpend';
| 'taprootScriptPathSpend'
// wasm-utxo types
| 'p2trLegacy'
| 'p2trMusig2ScriptPath'
| 'p2trMusig2KeyPath';

/**
* Sign all inputs of a psbt and verify signatures after signing.
Expand Down Expand Up @@ -102,10 +107,6 @@ export function signAndVerifyPsbt(
return psbt;
}

export interface Musig2Participant {
getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise<utxolib.bitgo.UtxoPsbt>;
}

/**
* 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 @@ -117,7 +118,7 @@ export interface Musig2Participant {
const PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();

export async function signPsbtWithMusig2Participant(
coin: Musig2Participant,
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt>,
tx: utxolib.bitgo.UtxoPsbt,
signerKeychain: BIP32Interface | undefined,
params: {
Expand Down
168 changes: 168 additions & 0 deletions modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import assert from 'assert';

import { BIP32Interface } from '@bitgo/utxo-lib';
import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo';

import { InputSigningError, TransactionSigningError } from './SigningError';
import { Musig2Participant } from './musig2';

export type ReplayProtectionKeys = {
publicKeys: (Uint8Array | ECPair)[];
};

/**
* Key Value: Unsigned tx id => PSBT
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
* Reason: MuSig2 signer secure nonce is cached in the BitGoPsbt object. It will be required during the signing step.
* For more info, check SignTransactionOptions.signingStep
*/
const PSBT_CACHE_WASM = new Map<string, fixedScriptWallet.BitGoPsbt>();

function hasKeyPathSpendInput(
tx: fixedScriptWallet.BitGoPsbt,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
replayProtection: ReplayProtectionKeys
): boolean {
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);
return parsed.inputs.some((input) => input.scriptType === 'p2trMusig2KeyPath');
}

/**
* Sign all inputs of a PSBT and verify signatures after signing.
* Collects and logs signing errors and verification errors, throws error in the end if any of them failed.
*
* If it is the last signature, finalize and extract the transaction from the psbt.
*/
export function signAndVerifyPsbtWasm(
tx: fixedScriptWallet.BitGoPsbt,
signerKeychain: BIP32Interface,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
replayProtection: ReplayProtectionKeys,
{ isLastSignature }: { isLastSignature: boolean }
): fixedScriptWallet.BitGoPsbt | Uint8Array {
const wasmSigner = toWasmBIP32(signerKeychain);
const parsed = tx.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection);

const signErrors: InputSigningError<bigint>[] = [];
const verifyErrors: InputSigningError<bigint>[] = [];

// Sign all inputs (skipping replay protection inputs)
parsed.inputs.forEach((input, inputIndex) => {
if (input.scriptType === 'p2shP2pk') {
// Skip replay protection inputs - they are platform signed only
return;
}

const outputId = `${input.previousOutput.txid}:${input.previousOutput.vout}`;
try {
tx.sign(inputIndex, wasmSigner);
} catch (e) {
signErrors.push(new InputSigningError<bigint>(inputIndex, input.scriptType, { id: outputId }, e));
}
});

// Verify signatures for all signed inputs
parsed.inputs.forEach((input, inputIndex) => {
if (input.scriptType === 'p2shP2pk') {
return;
}

const outputId = `${input.previousOutput.txid}:${input.previousOutput.vout}`;
try {
if (!tx.verifySignature(inputIndex, wasmSigner)) {
verifyErrors.push(
new InputSigningError(inputIndex, input.scriptType, { id: outputId }, new Error('invalid signature'))
);
}
} catch (e) {
verifyErrors.push(new InputSigningError<bigint>(inputIndex, input.scriptType, { id: outputId }, e));
}
});

if (signErrors.length || verifyErrors.length) {
throw new TransactionSigningError(signErrors, verifyErrors);
}

if (isLastSignature) {
tx.finalizeAllInputs();
return tx.extractTransaction();
}

return tx;
}

function toWasmBIP32(key: BIP32Interface): BIP32 {
// Convert using base58 string to ensure private key is properly transferred
return BIP32.fromBase58(key.toBase58());
}

export async function signPsbtWithMusig2ParticipantWasm(
coin: Musig2Participant<fixedScriptWallet.BitGoPsbt>,
tx: fixedScriptWallet.BitGoPsbt,
signerKeychain: BIP32Interface | undefined,
rootWalletKeys: fixedScriptWallet.IWalletKeys,
replayProtection: ReplayProtectionKeys,
params: {
isLastSignature: boolean;
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
walletId: string | undefined;
}
): Promise<fixedScriptWallet.BitGoPsbt | Uint8Array> {
const wasmSigner = signerKeychain ? toWasmBIP32(signerKeychain) : undefined;

if (hasKeyPathSpendInput(tx, rootWalletKeys, replayProtection)) {
// We can only be the first signature on a transaction with taproot key path spend inputs because
// we require the secret nonce in the cache of the first signer, which is impossible to retrieve if
// deserialized from a hex.
if (params.isLastSignature) {
throw new Error('Cannot be last signature on a transaction with key path spend inputs');
}

switch (params.signingStep) {
case 'signerNonce':
assert(wasmSigner);
tx.generateMusig2Nonces(wasmSigner);
PSBT_CACHE_WASM.set(tx.unsignedTxid(), tx);
return tx;
case 'cosignerNonce':
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
return await coin.getMusig2Nonces(tx, params.walletId);
case 'signerSignature': {
const txId = tx.unsignedTxid();
const cachedPsbt = PSBT_CACHE_WASM.get(txId);
assert(
cachedPsbt,
`Psbt is missing from txCache (cache size ${PSBT_CACHE_WASM.size}).
This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.`
);
PSBT_CACHE_WASM.delete(txId);
cachedPsbt.combineMusig2Nonces(tx);
tx = cachedPsbt;
break;
}
default:
// this instance is not an external signer
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
assert(wasmSigner);
tx.generateMusig2Nonces(wasmSigner);
const response = await coin.getMusig2Nonces(tx, params.walletId);
tx.combineMusig2Nonces(response);
break;
}
} else {
switch (params.signingStep) {
case 'signerNonce':
case 'cosignerNonce':
/**
* In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s).
* Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence.
*/
return tx;
}
}

assert(signerKeychain);
return signAndVerifyPsbtWasm(tx, signerKeychain, rootWalletKeys, replayProtection, {
isLastSignature: params.isLastSignature,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import * as utxolib from '@bitgo/utxo-lib';

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

import { Musig2Participant } from './musig2';
import { signLegacyTransaction } from './signLegacyTransaction';
import { Musig2Participant, signPsbtWithMusig2Participant } from './signPsbt';
import { signPsbtWithMusig2Participant } from './signPsbt';

export async function signTransaction(
coin: Musig2Participant,
coin: Musig2Participant<utxolib.bitgo.UtxoPsbt>,
tx: DecodedTransaction<bigint | number>,
signerKeychain: BIP32Interface | undefined,
network: utxolib.Network,
Expand Down
Loading