From 2dc2ede4b93024a20aa04af9d53905502b6ae1ca Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 2 Dec 2025 15:25:19 +0100 Subject: [PATCH] feat(abstract-utxo): add WASM-based PSBT signing for MuSig2 Implement WASM-based version of PSBT signing with MuSig2 support: - Create shared Musig2Participant interface in a separate file - Add signPsbtWasm.ts for WASM-based signing implementation - Support p2tr legacy, MuSig2 script path, and MuSig2 key path inputs - Maintain separate nonce caches for each backend Issue: BTC-2806 Co-authored-by: llm-git --- .../src/transaction/fixedScript/index.ts | 1 - .../src/transaction/fixedScript/musig2.ts | 3 + .../src/transaction/fixedScript/signPsbt.ts | 13 +- .../transaction/fixedScript/signPsbtWasm.ts | 168 ++++++++++++++++ .../fixedScript/signTransaction.ts | 5 +- .../unit/transaction/fixedScript/signPsbt.ts | 179 ++++++++++++++---- 6 files changed, 323 insertions(+), 46 deletions(-) create mode 100644 modules/abstract-utxo/src/transaction/fixedScript/musig2.ts create mode 100644 modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts diff --git a/modules/abstract-utxo/src/transaction/fixedScript/index.ts b/modules/abstract-utxo/src/transaction/fixedScript/index.ts index d8c161bb44..db46d13099 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/index.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/index.ts @@ -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'; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/musig2.ts b/modules/abstract-utxo/src/transaction/fixedScript/musig2.ts new file mode 100644 index 0000000000..373c02ec1e --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/musig2.ts @@ -0,0 +1,3 @@ +export interface Musig2Participant { + getMusig2Nonces(psbt: T, walletId: string): Promise; +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts b/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts index 0f55cf2442..416083ad38 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signPsbt.ts @@ -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'); @@ -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. @@ -102,10 +107,6 @@ export function signAndVerifyPsbt( return psbt; } -export interface Musig2Participant { - getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise; -} - /** * Key Value: Unsigned tx id => PSBT * 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 { const PSBT_CACHE = new Map(); export async function signPsbtWithMusig2Participant( - coin: Musig2Participant, + coin: Musig2Participant, tx: utxolib.bitgo.UtxoPsbt, signerKeychain: BIP32Interface | undefined, params: { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts new file mode 100644 index 0000000000..4d240e705f --- /dev/null +++ b/modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts @@ -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(); + +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[] = []; + const verifyErrors: InputSigningError[] = []; + + // 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(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(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, + tx: fixedScriptWallet.BitGoPsbt, + signerKeychain: BIP32Interface | undefined, + rootWalletKeys: fixedScriptWallet.IWalletKeys, + replayProtection: ReplayProtectionKeys, + params: { + isLastSignature: boolean; + signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined; + walletId: string | undefined; + } +): Promise { + 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, + }); +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts index 9fe194ac8c..10c20561a0 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts @@ -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, tx: DecodedTransaction, signerKeychain: BIP32Interface | undefined, network: utxolib.Network, diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts index b19201e79e..47c08d717f 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts @@ -1,46 +1,148 @@ import assert from 'node:assert/strict'; import * as utxolib from '@bitgo/utxo-lib'; +import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { Musig2Participant, signPsbtWithMusig2Participant } from '../../../../src/transaction/fixedScript/signPsbt'; +import { decodePsbtWith } from '../../../../src/transaction/decode'; +import { Musig2Participant } from '../../../../src/transaction/fixedScript/musig2'; +import { signPsbtWithMusig2Participant } from '../../../../src/transaction/fixedScript/signPsbt'; +import { + ReplayProtectionKeys, + signPsbtWithMusig2ParticipantWasm, +} from '../../../../src/transaction/fixedScript/signPsbtWasm'; +import { SdkBackend } from '../../../../src/transaction/types'; -function describeSignPsbtWithMusig2Participant(acidTest: utxolib.testutil.AcidTest) { - describe(`${acidTest.name}`, function () { +import { hasWasmUtxoSupport } from './util'; + +function getMockCoinUtxolib(keys: utxolib.bitgo.RootWalletKeys): Musig2Participant { + return { + async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise { + psbt.setAllInputsMusig2NonceHD(keys.bitgo, { deterministic: true }); + return psbt; + }, + }; +} + +function getMockCoinWasm( + keys: utxolib.bitgo.RootWalletKeys, + network: utxolib.Network +): Musig2Participant { + // Convert utxolib RootWalletKeys to wasm BIP32 using base58 string + // This ensures the private key is properly transferred + const bitgoXprv = keys.bitgo.toBase58(); + const bitgoKey = BIP32.fromBase58(bitgoXprv); + const networkName = utxolib.getNetworkName(network); + assert(networkName, 'network name is required'); + return { + async getMusig2Nonces(psbt: fixedScriptWallet.BitGoPsbt, walletId: string): Promise { + // Generate nonces using the bitgo key + psbt.generateMusig2Nonces(bitgoKey); + // Serialize and deserialize to simulate remote response + // This creates a new object so we don't get "recursive use of an object" error + return fixedScriptWallet.BitGoPsbt.fromBytes(psbt.serialize(), networkName); + }, + }; +} + +function assertSignedUtxolib(psbt: utxolib.bitgo.UtxoPsbt, userKey: utxolib.BIP32Interface): void { + // Verify that all wallet inputs have been signed by user key + psbt.data.inputs.forEach((input, inputIndex) => { + const { scriptType } = utxolib.bitgo.parsePsbtInput(input); + + // Skip replay protection inputs (p2shP2pk) + if (scriptType === 'p2shP2pk') { + return; + } + + // Verify user signature is present + const isValid = psbt.validateSignaturesOfInputHD(inputIndex, userKey); + assert(isValid, `input ${inputIndex} should have valid user signature`); + }); +} + +function assertSignedWasm( + psbt: fixedScriptWallet.BitGoPsbt, + userKey: utxolib.BIP32Interface, + rootWalletKeys: fixedScriptWallet.IWalletKeys, + replayProtection: ReplayProtectionKeys +): void { + const wasmUserKey = BIP32.from(userKey); + const parsed = psbt.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection); + + // Verify that all wallet inputs have been signed by user key + parsed.inputs.forEach((input, inputIndex) => { + // Skip replay protection inputs (p2shP2pk) + if (input.scriptType === 'p2shP2pk') { + return; + } + + // Verify user signature is present + const isValid = psbt.verifySignature(inputIndex, wasmUserKey); + assert(isValid, `input ${inputIndex} should have valid user signature (scriptType=${input.scriptType})`); + }); +} + +function toWasmWalletKeys(keys: utxolib.bitgo.RootWalletKeys): fixedScriptWallet.IWalletKeys { + return { + triple: [keys.user, keys.backup, keys.bitgo], + derivationPrefixes: keys.derivationPrefixes, + }; +} + +function getReplayProtectionKeys(keys: utxolib.bitgo.RootWalletKeys): ReplayProtectionKeys { + // Replay protection inputs use the underived user public key + return { + publicKeys: [keys.user.publicKey], + }; +} + +function describeSignPsbtWithMusig2Participant( + acidTest: utxolib.testutil.AcidTest, + { decodeWith }: { decodeWith: SdkBackend } +) { + describe(`${acidTest.name} ${decodeWith}`, function () { it('should sign unsigned psbt to halfsigned', async function () { // Create unsigned PSBT - const psbt = acidTest.createPsbt(); - - // Create mock Musig2Participant that sets BitGo nonces - const mockCoin: Musig2Participant = { - async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise { - psbt.setAllInputsMusig2NonceHD(acidTest.rootWalletKeys.bitgo, { deterministic: true }); - return psbt; - }, - }; - - // Sign with user key through signPsbtWithMusig2Participant - const result = await signPsbtWithMusig2Participant(mockCoin, psbt, acidTest.rootWalletKeys.user, { - isLastSignature: false, - signingStep: undefined, - walletId: 'test-wallet-id', - }); - - // Result should be a PSBT (not finalized) - assert(result instanceof utxolib.bitgo.UtxoPsbt, 'should return UtxoPsbt when not last signature'); - - // Verify that all wallet inputs have been signed by user key - result.data.inputs.forEach((input, inputIndex) => { - const { scriptType } = utxolib.bitgo.parsePsbtInput(input); - - // Skip replay protection inputs (p2shP2pk) - if (scriptType === 'p2shP2pk') { - return; - } - - // Verify user signature is present - const isValid = result.validateSignaturesOfInputHD(inputIndex, acidTest.rootWalletKeys.user); - assert(isValid, `input ${inputIndex} should have valid user signature`); - }); + const psbt = decodePsbtWith(acidTest.createPsbt().toBuffer(), acidTest.network, decodeWith); + + let result; + if (decodeWith === 'utxolib') { + assert(psbt instanceof utxolib.bitgo.UtxoPsbt, 'psbt should be a UtxoPsbt'); + result = await signPsbtWithMusig2Participant( + getMockCoinUtxolib(acidTest.rootWalletKeys), + psbt, + acidTest.rootWalletKeys.user, + { + isLastSignature: false, + signingStep: undefined, + walletId: 'test-wallet-id', + } + ); + // Result should be a PSBT (not finalized) + assert(result instanceof utxolib.bitgo.UtxoPsbt, 'should return UtxoPsbt when not last signature'); + + assertSignedUtxolib(result, acidTest.rootWalletKeys.user); + } else { + assert(psbt instanceof fixedScriptWallet.BitGoPsbt, 'psbt should be a BitGoPsbt'); + const wasmWalletKeys = toWasmWalletKeys(acidTest.rootWalletKeys); + const replayProtection = getReplayProtectionKeys(acidTest.rootWalletKeys); + result = await signPsbtWithMusig2ParticipantWasm( + getMockCoinWasm(acidTest.rootWalletKeys, acidTest.network), + psbt, + acidTest.rootWalletKeys.user, + wasmWalletKeys, + replayProtection, + { + isLastSignature: false, + signingStep: undefined, + walletId: 'test-wallet-id', + } + ); + // Result should be a PSBT (not finalized) + assert(result instanceof fixedScriptWallet.BitGoPsbt, 'should return BitGoPsbt when not last signature'); + + assertSignedWasm(result, acidTest.rootWalletKeys.user, wasmWalletKeys, replayProtection); + } }); }); } @@ -52,6 +154,9 @@ describe('signPsbtWithMusig2Participant', function () { utxolib.testutil.AcidTest.suite({ includeP2trMusig2ScriptPath: false }) .filter((test) => test.signStage === 'unsigned') .forEach((test) => { - describeSignPsbtWithMusig2Participant(test); + describeSignPsbtWithMusig2Participant(test, { decodeWith: 'utxolib' }); + if (hasWasmUtxoSupport(test.network)) { + describeSignPsbtWithMusig2Participant(test, { decodeWith: 'wasm-utxo' }); + } }); });