From d46f7b22c7f6c5852c09c36c0f0ce67b916509f5 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Tue, 13 Oct 2026 17:10:38 +0530 Subject: [PATCH] feat: add eddsa mpcv2 signing orchestration and routing Add signAndSendEddsaMPCv2FromTxRequest() to master express eddsa.ts, mirroring the ECDSA MPCv2 orchestrator: instantiate EddsaMPCv2Utils, build round generators via createEddsaMPCv2CustomSigners(), and drive signEddsaMPCv2TssUsingExternalSigner with RequestType.tx. Route EdDSA signing in transactionRequests.ts: wallets with multisigTypeVersion MPCv2 dispatch to the new orchestrator; wallets without it keep the MPCv1 commitment/r/g flow. ECDSA path unchanged. Extend createMPCSendParamsWithCustomSigningFns() in handleSendMany.ts: the SDK's signTransaction dispatches by presence of the custom signing functions, so EdDSA MPCv2 wallets must receive the customEddsaMPCv2SigningRound1/2/3GenerationFunction set; MPCv1 wallets keep commitment/r/g. The helper now takes the wallet and is exported for unit testing. Ticket: WCI-926 --- .../api/master/eddsaMpcv2Signing.test.ts | 194 +++++++++++++++++- .../api/master/signAndSendTxRequest.test.ts | 76 +++++++ src/__tests__/api/master/testUtils.ts | 11 +- src/masterBitgoExpress/handlers/eddsa.ts | 30 +++ .../handlers/handleSendMany.ts | 24 ++- .../handlers/transactionRequests.ts | 30 ++- 6 files changed, 339 insertions(+), 26 deletions(-) diff --git a/src/__tests__/api/master/eddsaMpcv2Signing.test.ts b/src/__tests__/api/master/eddsaMpcv2Signing.test.ts index 93869c67..0511f17c 100644 --- a/src/__tests__/api/master/eddsaMpcv2Signing.test.ts +++ b/src/__tests__/api/master/eddsaMpcv2Signing.test.ts @@ -1,12 +1,32 @@ import 'should'; +import nock from 'nock'; +import * as sinon from 'sinon'; import { TxRequest } from '@bitgo-beta/sdk-core'; +import { + Environments, + IRequestTracer, + openpgpUtils, + RequestTracer, + Wallet, +} from '@bitgo-beta/sdk-core'; +import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { AdvancedWalletManagerClient, SignMpcV2Round1Response, SignMpcV2Round2Response, } from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; -import { createEddsaMPCv2CustomSigners } from '../../../masterBitgoExpress/handlers/eddsa'; +import { + createEddsaMPCv2CustomSigners, + signAndSendEddsaMPCv2FromTxRequest, +} from '../../../masterBitgoExpress/handlers/eddsa'; +import { createMPCSendParamsWithCustomSigningFns } from '../../../masterBitgoExpress/handlers/handleSendMany'; +import coinFactory from '../../../shared/coinFactory'; +import { + BitGoAPITestHarness, + buildEcdsaMpcv2TxRequest, + nockEcdsaMpcv2SigningFlow, +} from './testUtils'; describe('createEddsaMPCv2CustomSigners', () => { const source = 'user' as const; @@ -96,8 +116,8 @@ describe('createEddsaMPCv2CustomSigners', () => { const response = await customEddsaMPCv2Round2Generator({ txRequest: txRequestFixture, bitgoPublicGpgKey: 'mock-bitgo-gpg-key', - encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', - encryptedRound1Session: 'mock-encrypted-round1-session', + encryptedUserGpgPrvKey: 'caller-encrypted-user-gpg-prv-key', + encryptedRound1Session: 'caller-encrypted-round1-session', }); response.should.equal(mockRound2Response); @@ -130,14 +150,14 @@ describe('createEddsaMPCv2CustomSigners', () => { await customEddsaMPCv2Round2Generator({ txRequest: txRequestFixture, bitgoPublicGpgKey: 'mock-bitgo-gpg-key', - encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', - encryptedRound1Session: 'mock-encrypted-round1-session', + encryptedUserGpgPrvKey: 'caller-encrypted-user-gpg-prv-key', + encryptedRound1Session: 'caller-encrypted-round1-session', }); const response = await customEddsaMPCv2Round3Generator({ txRequest: txRequestFixture, bitgoPublicGpgKey: 'mock-bitgo-gpg-key', - encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', - encryptedRound2Session: 'mock-encrypted-round2-session', + encryptedUserGpgPrvKey: 'caller-encrypted-user-gpg-prv-key', + encryptedRound2Session: 'caller-encrypted-round2-session', }); response.should.equal(mockRound3Response); @@ -168,8 +188,8 @@ describe('createEddsaMPCv2CustomSigners', () => { await customEddsaMPCv2Round2Generator({ txRequest: txRequestFixture, bitgoPublicGpgKey: 'mock-bitgo-gpg-key', - encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', - encryptedRound1Session: 'mock-encrypted-round1-session', + encryptedUserGpgPrvKey: 'caller-encrypted-user-gpg-prv-key', + encryptedRound1Session: 'caller-encrypted-round1-session', }).should.be.rejectedWith('Round 1 must be completed before Round 2'); awmCalls.should.have.length(0); }); @@ -182,9 +202,161 @@ describe('createEddsaMPCv2CustomSigners', () => { await customEddsaMPCv2Round3Generator({ txRequest: txRequestFixture, bitgoPublicGpgKey: 'mock-bitgo-gpg-key', - encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', - encryptedRound2Session: 'mock-encrypted-round2-session', + encryptedUserGpgPrvKey: 'caller-encrypted-user-gpg-prv-key', + encryptedRound2Session: 'caller-encrypted-round2-session', }).should.be.rejectedWith('Round 2 must be completed before Round 3'); awmCalls.should.have.length(1); }); }); +describe('signAndSendEddsaMPCv2FromTxRequest', () => { + let bitgo: BitGoAPI; + let wallet: Wallet; + let awmClient: AdvancedWalletManagerClient; + let reqId: IRequestTracer; + const bitgoApiUrl = Environments.local.uri; + const advancedWalletManagerUrl = 'http://advancedwalletmanager.invalid'; + const coin = 'tsol'; // Use tsol for EdDSA testing + + before(() => { + nock.disableNetConnect(); + }); + + beforeEach(() => { + bitgo = new BitGoAPI({ env: 'local' }); + wallet = { + id: () => 'test-wallet-id', + baseCoin: { + getMPCAlgorithm: () => 'eddsa', + }, + multisigTypeVersion: () => 'MPCv2', + } as unknown as Wallet; + awmClient = new AdvancedWalletManagerClient( + { + advancedWalletManagerUrl, + awmServerCaCert: 'dummy-cert', + tlsMode: 'disabled', + clientCertAllowSelfSigned: true, + } as unknown as ConstructorParameters[0], + coin, + ); + reqId = new RequestTracer(); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + it('should successfully sign an EdDSA MPCv2 transaction', async () => { + const txRequestData = buildEcdsaMpcv2TxRequest('pendingUserSignature'); + const txRequest = txRequestData as TxRequest; + + const bitgoEddsaMpcv2Key = await openpgpUtils.generateGPGKeyPair('ed25519'); + + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { + constants: { + mpc: { + bitgoEddsaMpcv2PublicKey: bitgoEddsaMpcv2Key.publicKey, + bitgoPublicKey: bitgoEddsaMpcv2Key.publicKey, + }, + }, + }); + + const sendResponse = { ...txRequestData, state: 'signed' }; + const nocks = nockEcdsaMpcv2SigningFlow({ + coin, + bitgoApiUrl, + advancedWalletManagerUrl, + sendResponse, + expectedCurveType: 'eddsa', + }); + + const result = await signAndSendEddsaMPCv2FromTxRequest( + bitgo, + wallet, + txRequest, + awmClient, + 'user', + 'mock-common-keychain', + reqId, + ); + + result.should.eql(sendResponse); + + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); + }); +}); + +describe('createMPCSendParamsWithCustomSigningFns EdDSA dispatch', () => { + const coin = 'tsol'; + + function makeReq(decoded: Record) { + return { + params: { coin }, + decoded, + bitgo: {} as never, + } as unknown as Parameters[0]; + } + + afterEach(() => { + sinon.restore(); + }); + + it('returns MPCv2 signing functions for an EdDSA MPCv2 wallet', async () => { + sinon.stub(coinFactory, 'getCoin').resolves({ + getMPCAlgorithm: () => 'eddsa', + } as never); + const wallet = { multisigTypeVersion: () => 'MPCv2' } as unknown as Wallet; + const awmClientStub = {} as unknown as AdvancedWalletManagerClient; + + const params = await createMPCSendParamsWithCustomSigningFns( + makeReq({ recipients: [] }), + wallet, + awmClientStub, + { source: 'user', commonKeychain: 'mock-common-keychain' } as never, + ); + + params.should.have.property('customEddsaMPCv2SigningRound1GenerationFunction'); + params.should.have.property('customEddsaMPCv2SigningRound2GenerationFunction'); + params.should.have.property('customEddsaMPCv2SigningRound3GenerationFunction'); + params.should.not.have.property('customCommitmentGeneratingFunction'); + params.should.not.have.property('customRShareGeneratingFunction'); + params.should.not.have.property('customGShareGeneratingFunction'); + }); + + it('returns MPCv1 signing functions for an EdDSA MPCv1 wallet', async () => { + sinon.stub(coinFactory, 'getCoin').resolves({ + getMPCAlgorithm: () => 'eddsa', + } as never); + const wallet = {} as unknown as Wallet; + const awmClientStub = {} as unknown as AdvancedWalletManagerClient; + + const params = await createMPCSendParamsWithCustomSigningFns( + makeReq({ recipients: [] }), + wallet, + awmClientStub, + { source: 'user', commonKeychain: 'mock-common-keychain' } as never, + ); + + params.should.have.property('customCommitmentGeneratingFunction'); + params.should.have.property('customRShareGeneratingFunction'); + params.should.have.property('customGShareGeneratingFunction'); + params.should.not.have.property('customEddsaMPCv2SigningRound1GenerationFunction'); + params.should.not.have.property('customEddsaMPCv2SigningRound2GenerationFunction'); + params.should.not.have.property('customEddsaMPCv2SigningRound3GenerationFunction'); + }); +}); diff --git a/src/__tests__/api/master/signAndSendTxRequest.test.ts b/src/__tests__/api/master/signAndSendTxRequest.test.ts index 9fbbfc9c..6c5a308a 100644 --- a/src/__tests__/api/master/signAndSendTxRequest.test.ts +++ b/src/__tests__/api/master/signAndSendTxRequest.test.ts @@ -331,4 +331,80 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/txrequest/:txRequestId/sig signMpcGShareNockAwm.done(); }); }); + + describe('EdDSA MPCv2 Sign and Send:', () => { + const eddsaCoin = 'tsol'; // Use tsol for EdDSA testing + + it('should successfully sign and send EdDSA MPCv2 transaction with user key', async () => { + const walletNock = nock(bitgoApiUrl) + .get(`/api/v2/${eddsaCoin}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(200, { + id: walletId, + type: 'advanced', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + multisigTypeVersion: 'MPCv2', + coin: eddsaCoin, + }); + + const keychainNock = nock(bitgoApiUrl) + .get(`/api/v2/${eddsaCoin}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain: 'common-keychain-123', + source: 'user', + }); + + const getTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: txRequestId, latest: true }) + .matchHeader('any', () => true) + .reply(200, { txRequests: [buildEcdsaMpcv2TxRequest('pendingUserSignature')] }); + + const signedTxRequest = buildEcdsaMpcv2TxRequest('signed', { + extra: { + transactions: [ + { + unsignedTx: { derivationPath: 'm/0', signableHex: 'testMessage' }, + state: 'signed', + signedTx: { id: 'test-tx-id', tx: 'signed-transaction-hex' }, + }, + ], + }, + }); + const nocks = nockEcdsaMpcv2SigningFlow({ + coin: eddsaCoin, + bitgoApiUrl, + advancedWalletManagerUrl, + sendResponse: signedTxRequest, + includeBitgoKeychainNock: true, + expectedCurveType: 'eddsa', + }); + + const response = await agent + .post( + `/api/v1/${eddsaCoin}/advancedwallet/${walletId}/txrequest/${txRequestId}/signAndSend`, + ) + .set('Authorization', `Bearer ${accessToken}`) + .send({ source: 'user', commonKeychain: 'common-keychain-123' }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'test-tx-id'); + response.body.should.have.property('tx', 'signed-transaction-hex'); + + walletNock.done(); + keychainNock.done(); + getTxRequestNock.done(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); + }); + }); }); diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index e6f80d0f..725ffeab 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -318,6 +318,8 @@ export interface NockEcdsaMpcv2SigningFlowOptions { includeBitgoKeychainNock?: boolean; /** Base tx request for BitGo sign round replies. */ pendingTxRequest?: ReturnType; + /** When set, the AWM round nocks only match request bodies carrying this curveType. */ + expectedCurveType?: 'eddsa' | 'ecdsa'; } /** @@ -328,6 +330,9 @@ export function nockEcdsaMpcv2SigningFlow(options: NockEcdsaMpcv2SigningFlowOpti const txRequestId = options.txRequestId ?? DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID; const userGpgPubKey = options.userGpgPubKey ?? 'user-gpg-pub-key'; const commonKeychain = options.commonKeychain ?? 'common-keychain-123'; + const curveTypeFilter = options.expectedCurveType + ? (body: Record) => body.curveType === options.expectedCurveType + : undefined; const { round1SignatureShare, round2SignatureShare, round3SignatureShare } = createEcdsaMpcv2SignatureShares(); @@ -390,7 +395,7 @@ export function nockEcdsaMpcv2SigningFlow(options: NockEcdsaMpcv2SigningFlowOpti .reply(200, options.sendResponse); const awmRound1Nock = nock(options.advancedWalletManagerUrl) - .post(`/api/${options.coin}/mpc/sign/mpcv2round1`) + .post(`/api/${options.coin}/mpc/sign/mpcv2round1`, curveTypeFilter) .reply(200, { signatureShareRound1: round1SignatureShare, userGpgPubKey, @@ -400,14 +405,14 @@ export function nockEcdsaMpcv2SigningFlow(options: NockEcdsaMpcv2SigningFlowOpti }); const awmRound2Nock = nock(options.advancedWalletManagerUrl) - .post(`/api/${options.coin}/mpc/sign/mpcv2round2`) + .post(`/api/${options.coin}/mpc/sign/mpcv2round2`, curveTypeFilter) .reply(200, { signatureShareRound2: round2SignatureShare, encryptedRound2Session: 'encrypted-round2-session', }); const awmRound3Nock = nock(options.advancedWalletManagerUrl) - .post(`/api/${options.coin}/mpc/sign/mpcv2round3`) + .post(`/api/${options.coin}/mpc/sign/mpcv2round3`, curveTypeFilter) .reply(200, { signatureShareRound3: round3SignatureShare }); return { diff --git a/src/masterBitgoExpress/handlers/eddsa.ts b/src/masterBitgoExpress/handlers/eddsa.ts index 0a1006f7..73617acc 100644 --- a/src/masterBitgoExpress/handlers/eddsa.ts +++ b/src/masterBitgoExpress/handlers/eddsa.ts @@ -1,5 +1,7 @@ import { BitGoBase, + EddsaMPCv2Utils, + RequestType, Wallet, IRequestTracer, EddsaUtils, @@ -199,3 +201,31 @@ export async function handleEddsaSigning( reqId, ); } + +export async function signAndSendEddsaMPCv2FromTxRequest( + bitgo: BitGoBase, + wallet: Wallet, + txRequest: TxRequest, + awmClient: AdvancedWalletManagerClient, + source: 'user' | 'backup', + commonKeychain: string, + reqId: IRequestTracer, +): Promise { + const eddsaMPCv2Utils = new EddsaMPCv2Utils(bitgo, wallet.baseCoin, wallet); + + // Use the shared custom signing functions + const { + customEddsaMPCv2Round1Generator, + customEddsaMPCv2Round2Generator, + customEddsaMPCv2Round3Generator, + } = createEddsaMPCv2CustomSigners(awmClient, source, commonKeychain); + + // This also sends the TxRequest for broadcast + return await eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest, reqId }, + customEddsaMPCv2Round1Generator, + customEddsaMPCv2Round2Generator, + customEddsaMPCv2Round3Generator, + RequestType.tx, + ); +} diff --git a/src/masterBitgoExpress/handlers/handleSendMany.ts b/src/masterBitgoExpress/handlers/handleSendMany.ts index b6835f7c..08bf3526 100644 --- a/src/masterBitgoExpress/handlers/handleSendMany.ts +++ b/src/masterBitgoExpress/handlers/handleSendMany.ts @@ -1,5 +1,6 @@ import { RequestTracer, + Wallet, PrebuildTransactionOptions, Memo, KeyIndices, @@ -10,7 +11,7 @@ import logger from '../../shared/logger'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; import { createEcdsaMPCv2CustomSigners } from './ecdsa'; import { AdvancedWalletManagerClient } from '../clients/advancedWalletManagerClient'; -import { createEddsaCustomSigningFunctions } from './eddsa'; +import { createEddsaCustomSigningFunctions, createEddsaMPCv2CustomSigners } from './eddsa'; import { BadRequestError, NotFoundError } from '../../shared/errors'; import coinFactory from '../../shared/coinFactory'; import { getWalletPubs } from './utils/utils'; @@ -32,10 +33,12 @@ interface Recipient { } /** - * Creates TSS send parameters for ECDSA MPCv2 signing with custom functions + * Creates TSS send parameters for MPC signing with custom functions (ECDSA MPCv2, + * EdDSA MPCv1 and EdDSA MPCv2 depending on the wallet's mpc algorithm and multisigTypeVersion) */ -async function createMPCSendParamsWithCustomSigningFns( +export async function createMPCSendParamsWithCustomSigningFns( req: MasterApiSpecRouteRequest<'v1.wallet.sendMany', 'post'>, + wallet: Wallet, awmClient: AdvancedWalletManagerClient, signingKeychain: Keychain, ): Promise { @@ -59,6 +62,20 @@ async function createMPCSendParamsWithCustomSigningFns( customMPCv2SigningRound3GenerationFunction: customMPCv2Round3Generator, }; } else if (mpcAlgorithm === 'eddsa') { + if (wallet.multisigTypeVersion?.() === 'MPCv2') { + const { + customEddsaMPCv2Round1Generator, + customEddsaMPCv2Round2Generator, + customEddsaMPCv2Round3Generator, + } = createEddsaMPCv2CustomSigners(awmClient, source, commonKeychain); + + return { + ...(req.decoded as SendManyOptions), + customEddsaMPCv2SigningRound1GenerationFunction: customEddsaMPCv2Round1Generator, + customEddsaMPCv2SigningRound2GenerationFunction: customEddsaMPCv2Round2Generator, + customEddsaMPCv2SigningRound3GenerationFunction: customEddsaMPCv2Round3Generator, + }; + } const { customCommitmentGenerator, customRShareGenerator, customGShareGenerator } = createEddsaCustomSigningFunctions(awmClient, source, commonKeychain); @@ -143,6 +160,7 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s } const mpcSendParams = await createMPCSendParamsWithCustomSigningFns( req, + wallet, awmClient, signingKeychain, ); diff --git a/src/masterBitgoExpress/handlers/transactionRequests.ts b/src/masterBitgoExpress/handlers/transactionRequests.ts index fbc50c68..808d533e 100644 --- a/src/masterBitgoExpress/handlers/transactionRequests.ts +++ b/src/masterBitgoExpress/handlers/transactionRequests.ts @@ -7,7 +7,7 @@ import { Wallet, } from '@bitgo-beta/sdk-core'; import { AdvancedWalletManagerClient } from '../clients/advancedWalletManagerClient'; -import { handleEddsaSigning } from './eddsa'; +import { handleEddsaSigning, signAndSendEddsaMPCv2FromTxRequest } from './eddsa'; import { signAndSendEcdsaMPCv2FromTxRequest } from './ecdsa'; /** @@ -36,14 +36,26 @@ export async function signAndSendTxRequests( const mpcAlgorithm = wallet.baseCoin.getMPCAlgorithm(); if (mpcAlgorithm === 'eddsa') { - signedTxRequest = await handleEddsaSigning( - bitgo, - wallet, - txRequest, - awmClient, - signingKeychain.commonKeychain, - reqId, - ); + if (wallet.multisigTypeVersion?.() === 'MPCv2') { + signedTxRequest = await signAndSendEddsaMPCv2FromTxRequest( + bitgo, + wallet, + txRequest, + awmClient, + signingKeychain.source as 'user' | 'backup', + signingKeychain.commonKeychain, + reqId, + ); + } else { + signedTxRequest = await handleEddsaSigning( + bitgo, + wallet, + txRequest, + awmClient, + signingKeychain.commonKeychain, + reqId, + ); + } } else if (mpcAlgorithm === 'ecdsa') { signedTxRequest = await signAndSendEcdsaMPCv2FromTxRequest( bitgo,