diff --git a/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts b/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts new file mode 100644 index 00000000..e57c935e --- /dev/null +++ b/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts @@ -0,0 +1,306 @@ +import { AppMode, AdvancedWalletManagerConfig, TlsMode, SigningMode } from '../../../initConfig'; +import { app as advancedWalletManagerApp } from '../../../advancedWalletManagerApp'; + +import express from 'express'; +import nock from 'nock'; +import 'should'; +import * as request from 'supertest'; +import * as sinon from 'sinon'; +import * as configModule from '../../../initConfig'; +import { EddsaMPCv2Utils } from '@bitgo-beta/sdk-core'; +import { MPCv2PartiesEnum } from '@bitgo-beta/sdk-core/dist/src/bitgo/utils/tss/ecdsa'; + +describe('EdDSA MPCv2 key generation (v1.mpcv2.* routes)', () => { + let cfg: AdvancedWalletManagerConfig; + let agent: request.SuperAgentTest; + + const keyProviderUrl = 'http://key-provider.invalid'; + const coin = 'tsol'; + const accessToken = 'test-token'; + const sandbox = sinon.createSandbox(); // initConfig, for the whole suite + const stubs = sinon.createSandbox(); // per-test SDK stubs + + const signedMsg = (tag: string) => ({ + message: Buffer.from(tag).toString('base64'), + signature: `sig-${tag}`, + }); + const bitgoGpgPub = 'bitgo-gpg-pub'; + const counterPartyGpgPub = 'counterparty-gpg-pub'; + + let round1Stub: sinon.SinonStub; + let round2Stub: sinon.SinonStub; + let finalizeStub: sinon.SinonStub; + let postKeyBody: Record | undefined; + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + cfg = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + keyProviderUrl, + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + sandbox.stub(configModule, 'initConfig').returns(cfg); + agent = request.agent(advancedWalletManagerApp(cfg) as express.Application); + }); + + beforeEach(() => { + postKeyBody = undefined; + nock(keyProviderUrl) + .post('/generateDataKey') + .reply(200, { plaintextKey: 'test-plaintext-key', encryptedKey: 'test-encrypted-key' }) + .persist(); + nock(keyProviderUrl) + .post('/decryptDataKey') + .reply(200, { plaintextKey: 'test-plaintext-key' }) + .persist(); + nock(keyProviderUrl) + .post('/key') + .reply(200, (_uri, body) => { + postKeyBody = body as Record; + return { pub: postKeyBody.pub, coin, source: postKeyBody.source, type: 'tss' }; + }) + .persist(); + + round1Stub = stubs + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenRound1Share') + .resolves({ signedMsg1: signedMsg('r1'), encryptedRound1Session: 'enc-round1-session' }); + round2Stub = stubs + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenRound2Share') + .resolves({ signedMsg2: signedMsg('r2'), encryptedRound2Session: 'enc-round2-session' }); + finalizeStub = stubs + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenFinalizeShare') + .resolves({ commonKeychain: 'common-keychain', keyShare: 'key-share' }); + }); + + afterEach(() => { + nock.cleanAll(); + stubs.restore(); + }); + + after(() => { + sandbox.restore(); + }); + + const post = (path: string, body: object, c = coin) => + agent.post(`/api/${c}/mpcv2/${path}`).set('Authorization', `Bearer ${accessToken}`).send(body); + + const initialize = async (source: 'user' | 'backup' = 'user') => { + const res = await post('initialize', { source }); + res.status.should.equal(200); + return res.body; + }; + + const round1 = async (source: 'user' | 'backup' = 'user') => { + const init = await initialize(source); + const res = await post('round', { + source, + encryptedData: init.encryptedData, + encryptedDataKey: init.encryptedDataKey, + round: 1, + bitgoGpgPub, + counterPartyGpgPub, + }); + res.status.should.equal(200); + return res.body; + }; + + describe('initialize', () => { + it('returns an ed25519 GPG public key and encrypted state', async () => { + const body = await initialize(); + body.gpgPub.should.containEql('BEGIN PGP PUBLIC KEY BLOCK'); + body.encryptedData.should.be.a.String(); + body.encryptedDataKey.should.equal('test-encrypted-key'); + }); + }); + + describe('round', () => { + it('round 1 delegates to createOfflineKeyGenRound1Share and returns the signed message', async () => { + const body = await round1(); + body.round.should.equal(2); + body.signedMsg.should.deepEqual(signedMsg('r1')); + body.should.not.have.property('broadcastMessage'); + + const args = round1Stub.firstCall.args[0]; + args.walletPassphrase.should.equal('test-plaintext-key'); + args.bitgoGpgPubKey.should.equal(bitgoGpgPub); + args.counterPartyGpgPubKey.should.equal(counterPartyGpgPub); + args.encryptedUserGpgPrvKey.should.be.a.String(); + args.partyId.should.equal(MPCv2PartiesEnum.USER); + }); + + it('derives the DKG party id from the source', async () => { + await round1('backup'); + round1Stub.firstCall.args[0].partyId.should.equal(MPCv2PartiesEnum.BACKUP); + }); + + it('round 2 restores state from round 1 and delegates to createOfflineKeyGenRound2Share', async () => { + const r1 = await round1(); + const res = await post('round', { + source: 'user', + encryptedData: r1.encryptedData, + encryptedDataKey: r1.encryptedDataKey, + round: 2, + bitgoMsg: signedMsg('bitgo1'), + counterPartyMsg: signedMsg('counterparty1'), + }); + res.status.should.equal(200); + res.body.round.should.equal(3); + res.body.signedMsg.should.deepEqual(signedMsg('r2')); + + const args = round2Stub.firstCall.args[0]; + args.encryptedRound1Session.should.equal('enc-round1-session'); + args.bitgoGpgPubKey.should.equal(bitgoGpgPub); + args.counterPartyGpgPubKey.should.equal(counterPartyGpgPub); + args.bitgoMsg1.should.deepEqual(signedMsg('bitgo1')); + args.counterPartyMsg1.should.deepEqual(signedMsg('counterparty1')); + args.partyId.should.equal(MPCv2PartiesEnum.USER); + }); + + it('rejects round 1 without GPG public keys', async () => { + const init = await initialize(); + const res = await post('round', { + source: 'user', + encryptedData: init.encryptedData, + encryptedDataKey: init.encryptedDataKey, + round: 1, + }); + res.status.should.equal(400); + round1Stub.called.should.be.false(); + }); + + it('rejects round 2 without the signed messages', async () => { + const r1 = await round1(); + const res = await post('round', { + source: 'user', + encryptedData: r1.encryptedData, + encryptedDataKey: r1.encryptedDataKey, + round: 2, + bitgoMsg: signedMsg('bitgo1'), + }); + res.status.should.equal(400); + round2Stub.called.should.be.false(); + }); + + it('rejects rounds outside 1-2 for EdDSA', async () => { + const r1 = await round1(); + const res = await post('round', { + source: 'user', + encryptedData: r1.encryptedData, + encryptedDataKey: r1.encryptedDataKey, + round: 3, + bitgoMsg: signedMsg('bitgo1'), + counterPartyMsg: signedMsg('counterparty1'), + }); + res.status.should.equal(400); + }); + + it('rejects round 2 when round 1 was skipped', async () => { + const init = await initialize(); + const res = await post('round', { + source: 'user', + encryptedData: init.encryptedData, + encryptedDataKey: init.encryptedDataKey, + round: 2, + bitgoMsg: signedMsg('bitgo1'), + counterPartyMsg: signedMsg('counterparty1'), + }); + res.status.should.equal(400); + round2Stub.called.should.be.false(); + }); + }); + + describe('finalize', () => { + const runToFinalize = async (source: 'user' | 'backup' = 'user') => { + const r1 = await round1(source); + const r2 = await post('round', { + source, + encryptedData: r1.encryptedData, + encryptedDataKey: r1.encryptedDataKey, + round: 2, + bitgoMsg: signedMsg('bitgo1'), + counterPartyMsg: signedMsg('counterparty1'), + }); + r2.status.should.equal(200); + return r2.body; + }; + + it('delegates to createOfflineKeyGenFinalizeShare and stores the key share', async () => { + const r2 = await runToFinalize(); + const res = await post('finalize', { + source: 'user', + encryptedData: r2.encryptedData, + encryptedDataKey: r2.encryptedDataKey, + bitgoCommonKeychain: 'bitgo-common-keychain', + bitgoMsg: signedMsg('bitgo2'), + counterPartyMsg: signedMsg('counterparty2'), + }); + res.status.should.equal(200); + res.body.should.deepEqual({ source: 'user', commonKeychain: 'common-keychain' }); + + const args = finalizeStub.firstCall.args[0]; + args.encryptedRound2Session.should.equal('enc-round2-session'); + args.bitgoCommonKeychain.should.equal('bitgo-common-keychain'); + args.bitgoMsg2.should.deepEqual(signedMsg('bitgo2')); + args.counterPartyMsg2.should.deepEqual(signedMsg('counterparty2')); + + (postKeyBody as Record).should.containEql({ + pub: 'common-keychain', + prv: 'key-share', + coin, + source: 'user', + type: 'tss', + }); + }); + + it('rejects finalize without the signed messages', async () => { + const r2 = await runToFinalize(); + const res = await post('finalize', { + source: 'user', + encryptedData: r2.encryptedData, + encryptedDataKey: r2.encryptedDataKey, + bitgoCommonKeychain: 'bitgo-common-keychain', + }); + res.status.should.equal(400); + finalizeStub.called.should.be.false(); + (postKeyBody === undefined).should.be.true(); + }); + + it('rejects finalize when round 2 was skipped', async () => { + const r1 = await round1(); + const res = await post('finalize', { + source: 'user', + encryptedData: r1.encryptedData, + encryptedDataKey: r1.encryptedDataKey, + bitgoCommonKeychain: 'bitgo-common-keychain', + bitgoMsg: signedMsg('bitgo2'), + counterPartyMsg: signedMsg('counterparty2'), + }); + res.status.should.equal(400); + finalizeStub.called.should.be.false(); + }); + }); + + describe('ECDSA routing is unchanged', () => { + it('rejects ECDSA finalize without broadcastMessages', async () => { + const res = await post( + 'finalize', + { + source: 'user', + encryptedData: 'x', + encryptedDataKey: 'y', + bitgoCommonKeychain: 'z', + }, + 'hteth', + ); + res.status.should.equal(400); + res.body.details.should.containEql('broadcastMessages'); + }); + }); +}); diff --git a/src/advancedWalletManager/handlers/ecdsaMPCV2WalletGenerationFinalize.ts b/src/advancedWalletManager/handlers/ecdsaMPCV2WalletGenerationFinalize.ts index 320d93e8..610bee18 100644 --- a/src/advancedWalletManager/handlers/ecdsaMPCV2WalletGenerationFinalize.ts +++ b/src/advancedWalletManager/handlers/ecdsaMPCV2WalletGenerationFinalize.ts @@ -6,6 +6,7 @@ import { } from '../routers/advancedWalletManagerApiSpec'; import { KeyProviderClient } from '../keyProviderClient/keyProviderClient'; import assert from 'assert'; +import { BadRequestError } from '../../shared/errors'; export async function ecdsaMPCv2Finalize( req: AwmApiSpecRouteRequest<'v1.mpcv2.finalize', 'post'>, @@ -13,6 +14,10 @@ export async function ecdsaMPCv2Finalize( const { source, encryptedData, encryptedDataKey, broadcastMessages, bitgoCommonKeychain } = req.decoded; + if (!broadcastMessages) { + throw new BadRequestError('broadcastMessages are required for ECDSA MPCv2 finalize'); + } + // setup clients const keyProvider = new KeyProviderClient(req.config); diff --git a/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts b/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts new file mode 100644 index 00000000..e78571d7 --- /dev/null +++ b/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts @@ -0,0 +1,177 @@ +import * as t from 'io-ts'; +import { isLeft } from 'fp-ts/lib/Either'; +import * as PathReporter from 'io-ts/lib/PathReporter'; +import { EddsaMPCv2Utils, generateGPGKeyPair } from '@bitgo-beta/sdk-core'; +import { MPCv2PartiesEnum } from '@bitgo-beta/sdk-core/dist/src/bitgo/utils/tss/ecdsa'; +import { + AwmApiSpecRouteRequest, + EddsaMpcV2FinalizeBody, + EddsaMpcV2RoundBody, + MpcV2FinalizeResponseType, + MpcV2InitializeResponseType, + MpcV2RoundResponseType, +} from '../routers/advancedWalletManagerApiSpec'; +import { KeyProviderClient } from '../keyProviderClient/keyProviderClient'; +import coinFactory from '../../shared/coinFactory'; +import { BadRequestError } from '../../shared/errors'; + +/** + * EdDSA MPCv2 DKG handlers. They serve the same v1.mpcv2.* routes as ECDSA and are thin + * wrappers over EddsaMPCv2Utils.createOfflineKeyGen*Share (the external signer methods): the SDK + * owns all DKG/GPG state, AWM only wraps it with key-provider encryption. The SDK blobs travel + * between rounds inside `encryptedData`, the opaque state carrier shared with ECDSA. + */ +type EddsaMPCv2KeyGenState = { + encryptedUserGpgPrvKey: string; + bitgoGpgPub?: string; + counterPartyGpgPub?: string; + encryptedRound1Session?: string; + encryptedRound2Session?: string; +}; + +function decode(codec: t.Type, body: unknown): A { + const result = codec.decode(body); + if (isLeft(result)) { + throw new BadRequestError(PathReporter.failure(result.left).join('\n')); + } + return result.right; +} + +function partyIdFor(source: 'user' | 'backup') { + return source === 'backup' ? MPCv2PartiesEnum.BACKUP : MPCv2PartiesEnum.USER; +} + +async function loadState( + req: AwmApiSpecRouteRequest<'v1.mpcv2.round' | 'v1.mpcv2.finalize', 'post'>, + encryptedData: string, + encryptedDataKey: string, +) { + const keyProvider = new KeyProviderClient(req.config); + const { plaintextKey } = await keyProvider.decryptDataKey({ encryptedKey: encryptedDataKey }); + const state: EddsaMPCv2KeyGenState = JSON.parse( + await req.bitgo.decrypt({ input: encryptedData, password: plaintextKey }), + ); + return { keyProvider, plaintextKey, state }; +} + +async function saveState( + bitgo: { encrypt(p: { input: string; password: string }): Promise }, + state: EddsaMPCv2KeyGenState, + plaintextKey: string, +): Promise { + return bitgo.encrypt({ input: JSON.stringify(state), password: plaintextKey }); +} + +export async function eddsaMPCv2Initialize( + req: AwmApiSpecRouteRequest<'v1.mpcv2.initialize', 'post'>, +): Promise { + const keyProvider = new KeyProviderClient(req.config); + const { plaintextKey, encryptedKey } = await keyProvider.generateDataKey({ keyType: 'AES-256' }); + const gpgKeyPair = await generateGPGKeyPair('ed25519'); + const encryptedUserGpgPrvKey = await req.bitgo.encrypt({ + input: gpgKeyPair.privateKey, + password: plaintextKey, + }); + return { + gpgPub: gpgKeyPair.publicKey, + encryptedDataKey: encryptedKey, + encryptedData: await saveState(req.bitgo, { encryptedUserGpgPrvKey }, plaintextKey), + }; +} + +export async function eddsaMPCv2Round( + req: AwmApiSpecRouteRequest<'v1.mpcv2.round', 'post'>, +): Promise { + const body = decode(EddsaMpcV2RoundBody, req.body); + const { source, encryptedData, encryptedDataKey } = req.decoded; + const coin = await coinFactory.getCoin(req.decoded.coin, req.bitgo); + const eddsaMPCv2Utils = new EddsaMPCv2Utils(req.bitgo, coin); + const partyId = partyIdFor(source); + const { plaintextKey, state } = await loadState(req, encryptedData, encryptedDataKey); + + if (body.round === 1) { + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase: plaintextKey, + encryptedUserGpgPrvKey: state.encryptedUserGpgPrvKey, + bitgoGpgPubKey: body.bitgoGpgPub, + counterPartyGpgPubKey: body.counterPartyGpgPub, + partyId, + }); + return { + round: 2, + encryptedDataKey, + encryptedData: await saveState( + req.bitgo, + { + ...state, + bitgoGpgPub: body.bitgoGpgPub, + counterPartyGpgPub: body.counterPartyGpgPub, + encryptedRound1Session: result.encryptedRound1Session, + }, + plaintextKey, + ), + signedMsg: result.signedMsg1, + }; + } + + if (!state.encryptedRound1Session || !state.bitgoGpgPub || !state.counterPartyGpgPub) { + throw new BadRequestError('Round 1 has not been completed for this EdDSA MPCv2 state'); + } + const result = await eddsaMPCv2Utils.createOfflineKeyGenRound2Share({ + walletPassphrase: plaintextKey, + encryptedUserGpgPrvKey: state.encryptedUserGpgPrvKey, + encryptedRound1Session: state.encryptedRound1Session, + bitgoGpgPubKey: state.bitgoGpgPub, + counterPartyGpgPubKey: state.counterPartyGpgPub, + bitgoMsg1: body.bitgoMsg, + counterPartyMsg1: body.counterPartyMsg, + partyId, + }); + return { + round: 3, + encryptedDataKey, + encryptedData: await saveState( + req.bitgo, + { ...state, encryptedRound2Session: result.encryptedRound2Session }, + plaintextKey, + ), + signedMsg: result.signedMsg2, + }; +} + +export async function eddsaMPCv2Finalize( + req: AwmApiSpecRouteRequest<'v1.mpcv2.finalize', 'post'>, +): Promise { + const { bitgoMsg, counterPartyMsg } = decode(EddsaMpcV2FinalizeBody, req.body); + const { source, encryptedData, encryptedDataKey, bitgoCommonKeychain } = req.decoded; + const coin = await coinFactory.getCoin(req.decoded.coin, req.bitgo); + const eddsaMPCv2Utils = new EddsaMPCv2Utils(req.bitgo, coin); + const { keyProvider, plaintextKey, state } = await loadState( + req, + encryptedData, + encryptedDataKey, + ); + + if (!state.encryptedRound2Session || !state.bitgoGpgPub || !state.counterPartyGpgPub) { + throw new BadRequestError('Round 2 has not been completed for this EdDSA MPCv2 state'); + } + const result = await eddsaMPCv2Utils.createOfflineKeyGenFinalizeShare({ + walletPassphrase: plaintextKey, + encryptedRound2Session: state.encryptedRound2Session, + bitgoGpgPubKey: state.bitgoGpgPub, + counterPartyGpgPubKey: state.counterPartyGpgPub, + bitgoMsg2: bitgoMsg, + counterPartyMsg2: counterPartyMsg, + bitgoCommonKeychain, + partyId: partyIdFor(source), + }); + + await keyProvider.postKey({ + coin: req.decoded.coin, + source, + pub: result.commonKeychain, + prv: result.keyShare, + type: 'tss', + }); + return { source, commonKeychain: result.commonKeychain }; +} diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 958ce036..9458093a 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -34,6 +34,11 @@ import { ecdsaMPCv2Round } from '../handlers/ecdsaMPCV2WalletGenerationRound'; import { ecdsaMPCv2Finalize } from '../handlers/ecdsaMPCV2WalletGenerationFinalize'; import { ecdsaMPCv2Recovery } from '../handlers/ecdsaMPCV2Recovery'; import { signEddsaRecoveryTransaction } from '../handlers/eddsaMPCRecovery'; +import { + eddsaMPCv2Initialize, + eddsaMPCv2Round, + eddsaMPCv2Finalize, +} from '../handlers/eddsaMPCV2WalletGeneration'; import { isEddsaCoin } from '../../shared/coinUtils'; import { customDecodeErrorFormatter } from '../../shared/errorFormatters'; @@ -266,6 +271,8 @@ const MpcV2RoundMessage = t.type({ bitgo: t.any, counterParty: t.any, }); +const MPSSignedMessage = t.type({ message: t.string, signature: t.string }); + const MpcV2RoundRequest = { source: t.union([t.literal('user'), t.literal('backup')]), encryptedData: t.string, @@ -275,7 +282,15 @@ const MpcV2RoundRequest = { counterPartyGpgPub: optional(t.string), broadcastMessages: optional(MpcV2RoundMessage), p2pMessages: optional(MpcV2RoundMessage), + // EdDSA only + bitgoMsg: optional(MPSSignedMessage), + counterPartyMsg: optional(MPSSignedMessage), }; +// Strict EdDSA shapes, decoded by the EdDSA handlers (the route props above keep these optional). +export const EddsaMpcV2RoundBody = t.union([ + t.type({ round: t.literal(1), bitgoGpgPub: t.string, counterPartyGpgPub: t.string }), + t.type({ round: t.literal(2), bitgoMsg: MPSSignedMessage, counterPartyMsg: MPSSignedMessage }), +]); const MpcV2RoundRequestType = t.type(MpcV2RoundRequest); export type MpcV2RoundRequestType = t.TypeOf; @@ -285,6 +300,7 @@ const MpcV2RoundResponse = { round: t.number, broadcastMessage: optional(t.any), p2pMessages: optional(MpcV2RoundMessage), + signedMsg: optional(MPSSignedMessage), // EdDSA only }; const MpcV2RoundResponseType = t.exact(optionalized(MpcV2RoundResponse)); export type MpcV2RoundResponseType = t.TypeOf; @@ -293,9 +309,16 @@ const MpcV2FinalizeRequest = { source: t.union([t.literal('user'), t.literal('backup')]), encryptedData: t.string, encryptedDataKey: t.string, - broadcastMessages: MpcV2RoundMessage, + broadcastMessages: optional(MpcV2RoundMessage), // ECDSA only bitgoCommonKeychain: t.string, + // EdDSA only + bitgoMsg: optional(MPSSignedMessage), + counterPartyMsg: optional(MPSSignedMessage), }; +export const EddsaMpcV2FinalizeBody = t.type({ + bitgoMsg: MPSSignedMessage, + counterPartyMsg: MPSSignedMessage, +}); const MpcV2FinalizeRequestType = t.type(MpcV2FinalizeRequest); export type MpcV2FinalizeRequestType = t.TypeOf; @@ -594,7 +617,10 @@ export function createKeyGenRouter( router.post('v1.mpcv2.initialize', [ responseHandler(async (req) => { const typedReq = req as AwmApiSpecRouteRequest<'v1.mpcv2.initialize', 'post'>; - const result = await ecdsaMPCv2Initialize(typedReq); + const coin = await coinFactory.getCoin(typedReq.decoded.coin, typedReq.bitgo); + const result = isEddsaCoin(coin) + ? await eddsaMPCv2Initialize(typedReq) + : await ecdsaMPCv2Initialize(typedReq); return Response.ok(result); }), ]); @@ -602,7 +628,10 @@ export function createKeyGenRouter( router.post('v1.mpcv2.round', [ responseHandler(async (req) => { const typedReq = req as AwmApiSpecRouteRequest<'v1.mpcv2.round', 'post'>; - const result = await ecdsaMPCv2Round(typedReq); + const coin = await coinFactory.getCoin(typedReq.decoded.coin, typedReq.bitgo); + const result = isEddsaCoin(coin) + ? await eddsaMPCv2Round(typedReq) + : await ecdsaMPCv2Round(typedReq); return Response.ok(result); }), ]); @@ -610,7 +639,10 @@ export function createKeyGenRouter( router.post('v1.mpcv2.finalize', [ responseHandler(async (req) => { const typedReq = req as AwmApiSpecRouteRequest<'v1.mpcv2.finalize', 'post'>; - const result = await ecdsaMPCv2Finalize(typedReq); + const coin = await coinFactory.getCoin(typedReq.decoded.coin, typedReq.bitgo); + const result = isEddsaCoin(coin) + ? await eddsaMPCv2Finalize(typedReq) + : await ecdsaMPCv2Finalize(typedReq); return Response.ok(result); }), ]);