diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 7cb4a8e..ff66e05 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -6,6 +6,120 @@ "description": "BitGo Enclaved Express - Secure enclave for BitGo signing operations with mTLS" }, "paths": { + "/api/{coin}/wallet/{walletId}/accelerate": { + "post": { + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "coin", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pubkey": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "user", + "backup" + ] + }, + "cpfpTxIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "cpfpFeeRate": { + "type": "number" + }, + "maxFee": { + "type": "number" + }, + "rbfTxIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "feeMultiplier": { + "type": "number" + } + }, + "required": [ + "pubkey", + "source" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "txid": { + "type": "string" + }, + "tx": { + "type": "string" + } + }, + "required": [ + "txid", + "tx" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "details": { + "type": "string" + } + }, + "required": [ + "error", + "details" + ] + } + } + } + } + } + } + }, "/api/{coin}/wallet/{walletId}/consolidate": { "post": { "parameters": [ @@ -54,6 +168,9 @@ "full", "lite" ] + }, + "commonKeychain": { + "type": "string" } }, "required": [ @@ -142,9 +259,6 @@ "backup" ] }, - "walletPassphrase": { - "type": "string" - }, "feeRate": { "type": "number" }, diff --git a/src/__tests__/api/master/consolidateMPC.test.ts b/src/__tests__/api/master/consolidateMPC.test.ts new file mode 100644 index 0000000..d1c2aa0 --- /dev/null +++ b/src/__tests__/api/master/consolidateMPC.test.ts @@ -0,0 +1,212 @@ +import 'should'; +import sinon from 'sinon'; +import * as request from 'supertest'; +import nock from 'nock'; +import { app as expressApp } from '../../../masterExpressApp'; +import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { Environments, Wallet } from '@bitgo/sdk-core'; +import * as eddsa from '../../../api/master/handlers/eddsa'; + +describe('POST /api/:coin/wallet/:walletId/consolidate (EDDSA MPC)', () => { + let agent: request.SuperAgentTest; + const coin = 'tsol'; + const walletId = 'test-wallet-id'; + const accessToken = 'test-access-token'; + const bitgoApiUrl = Environments.test.uri; + const enclavedExpressUrl = 'https://test-enclaved-express.com'; + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + const config: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 30000, + logFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + enclavedExpressUrl: enclavedExpressUrl, + enclavedExpressCert: 'test-cert', + tlsMode: TlsMode.DISABLED, + mtlsRequestCert: false, + allowSelfSigned: true, + }; + const app = expressApp(config); + agent = request.agent(app); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it('should consolidate using EDDSA MPC custom hooks', async () => { + // Mock wallet get request + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .reply(200, { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + }); + + // Mock keychain get request + const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, { + id: 'user-key-id', + commonKeychain: 'pubkey', + }); + + // Mock sendAccountConsolidations on Wallet prototype + const sendConsolidationsStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidations') + .resolves({ + success: [ + { + txid: 'mpc-txid-1', + status: 'signed', + }, + ], + failure: [], + }); + + // Spy on custom EDDSA hooks - these should return actual functions, not strings + const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' }); + const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' }); + const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' }); + + const commitmentSpy = sinon + .stub(eddsa, 'createCustomCommitmentGenerator') + .returns(mockCommitmentFn); + const rshareSpy = sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn); + const gshareSpy = sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'pubkey', + }); + + response.status.should.equal(200); + response.body.should.have.property('success'); + response.body.success.should.have.length(1); + response.body.success[0].should.have.property('txid', 'mpc-txid-1'); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(sendConsolidationsStub); + sinon.assert.calledOnce(commitmentSpy); + sinon.assert.calledOnce(rshareSpy); + sinon.assert.calledOnce(gshareSpy); + }); + + it('should handle partial failures (some success, some failure)', async () => { + // Mock wallet get request + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .reply(200, { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + }); + + // Mock keychain get request + const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, { + id: 'user-key-id', + commonKeychain: 'pubkey', + }); + + // Mock partial failure response + sinon.stub(Wallet.prototype, 'sendAccountConsolidations').resolves({ + success: [{ txid: 'success-txid', status: 'signed' }], + failure: [{ error: 'Insufficient funds', address: '0xfailed' }], + }); + + // Mock EDDSA hooks + const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' }); + const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' }); + const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' }); + + sinon.stub(eddsa, 'createCustomCommitmentGenerator').returns(mockCommitmentFn); + sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn); + sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'pubkey', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have + .property('details') + .which.match(/Consolidations failed: 1 and succeeded: 1/); + + walletGetNock.done(); + keychainGetNock.done(); + }); + + it('should handle total failures (all failed)', async () => { + // Mock wallet get request + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .reply(200, { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + }); + + // Mock keychain get request + const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, { + id: 'user-key-id', + commonKeychain: 'pubkey', + }); + + // Mock total failure response + sinon.stub(Wallet.prototype, 'sendAccountConsolidations').resolves({ + success: [], + failure: [ + { error: 'Insufficient funds', address: '0xfailed1' }, + { error: 'Invalid address', address: '0xfailed2' }, + ], + }); + + // Mock EDDSA hooks + const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' }); + const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' }); + const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' }); + + sinon.stub(eddsa, 'createCustomCommitmentGenerator').returns(mockCommitmentFn); + sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn); + sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'pubkey', + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.should.have.property('details').which.match(/All consolidations failed/); + + walletGetNock.done(); + keychainGetNock.done(); + }); +}); diff --git a/src/api/master/handlerUtils.ts b/src/api/master/handlerUtils.ts index 9c297f5..ed480dd 100644 --- a/src/api/master/handlerUtils.ts +++ b/src/api/master/handlerUtils.ts @@ -17,7 +17,7 @@ export async function getWalletAndSigningKeychain({ bitgo: BitGo; coin: string; walletId: string; - params: { source: 'user' | 'backup'; pubkey?: string }; + params: { source: 'user' | 'backup'; pubkey?: string; commonKeychain?: string }; reqId: RequestTracer; KeyIndices: { USER: number; BACKUP: number; BITGO: number }; }) { @@ -34,7 +34,7 @@ export async function getWalletAndSigningKeychain({ id: wallet.keyIds()[keyIdIndex], }); - if (!signingKeychain || !signingKeychain.pub) { + if (!signingKeychain) { throw new Error(`Signing keychain for ${params.source} not found`); } @@ -42,6 +42,12 @@ export async function getWalletAndSigningKeychain({ throw new Error(`Pub provided does not match the keychain on wallet for ${params.source}`); } + if (params.commonKeychain && signingKeychain.commonKeychain !== params.commonKeychain) { + throw new Error( + `Common keychain provided does not match the keychain on wallet for ${params.source}`, + ); + } + return { baseCoin, wallet, signingKeychain }; } /** diff --git a/src/api/master/handlers/eddsa.ts b/src/api/master/handlers/eddsa.ts index d6118a3..cca32f6 100644 --- a/src/api/master/handlers/eddsa.ts +++ b/src/api/master/handlers/eddsa.ts @@ -9,6 +9,9 @@ import { EddsaUtils, BaseCoin, ApiKeyShare, + CustomRShareGeneratingFunction, + CustomGShareGeneratingFunction, + CustomCommitmentGeneratingFunction, } from '@bitgo/sdk-core'; import { EnclavedExpressClient } from '../clients/enclavedExpressClient'; import { exchangeEddsaCommitments } from '@bitgo/sdk-core/dist/src/bitgo/tss/common'; @@ -198,3 +201,70 @@ export async function orchestrateEddsaKeyGen({ walletParams.keys = [userMpcKey.id, backupMpcKey.id, bitgoKeychain.id]; return { walletParams, keychains }; } + +// Commitment +export function createCustomCommitmentGenerator( + bitgo: BitGoBase, + wallet: Wallet, + enclavedExpressClient: EnclavedExpressClient, + source: 'user' | 'backup', + pub: string, +): CustomCommitmentGeneratingFunction { + return async function customCommitmentGeneratingFunction(params) { + const eddsaUtils = new EddsaUtils(bitgo, wallet.baseCoin); + const bitgoGpgKey = await eddsaUtils.getBitgoPublicGpgKey(); + const { txRequest } = params; + const response = await enclavedExpressClient.signMpcCommitment({ + txRequest, + bitgoGpgPubKey: bitgoGpgKey.armor(), + source, + pub, + }); + return { + ...response, + encryptedUserToBitgoRShare: { + ...response.encryptedUserToBitgoRShare, + encryptedDataKey: response.encryptedDataKey, + }, + }; + }; +} + +// RShare +export function createCustomRShareGenerator( + enclavedExpressClient: EnclavedExpressClient, + source: 'user' | 'backup', + pub: string, +): CustomRShareGeneratingFunction { + return async function customRShareGeneratingFunction(params) { + const { txRequest, encryptedUserToBitgoRShare } = params; + const encryptedDataKey = (encryptedUserToBitgoRShare as any).encryptedDataKey; + return await enclavedExpressClient.signMpcRShare({ + txRequest, + encryptedUserToBitgoRShare, + encryptedDataKey, + source, + pub, + }); + }; +} + +// GShare +export function createCustomGShareGenerator( + enclavedExpressClient: EnclavedExpressClient, + source: 'user' | 'backup', + pub: string, +): CustomGShareGeneratingFunction { + return async function customGShareGeneratingFunction(params) { + const { txRequest, bitgoToUserRShare, userToBitgoRShare, bitgoToUserCommitment } = params; + const response = await enclavedExpressClient.signMpcGShare({ + txRequest, + bitgoToUserRShare, + userToBitgoRShare, + bitgoToUserCommitment, + source, + pub, + }); + return response.gShare; + }; +} diff --git a/src/api/master/handlers/handleConsolidate.ts b/src/api/master/handlers/handleConsolidate.ts index 30940d4..f43f480 100644 --- a/src/api/master/handlers/handleConsolidate.ts +++ b/src/api/master/handlers/handleConsolidate.ts @@ -1,7 +1,17 @@ -import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; +import { + RequestTracer, + KeyIndices, + BuildConsolidationTransactionOptions, + MPCType, +} from '@bitgo/sdk-core'; import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../handlerUtils'; +import { + createCustomCommitmentGenerator, + createCustomRShareGenerator, + createCustomGShareGenerator, +} from './eddsa'; export async function handleConsolidate( req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>, @@ -33,20 +43,46 @@ export async function handleConsolidate( } try { - // Create custom signing function that delegates to EBE - const customSigningFunction = makeCustomSigningFunction({ - enclavedExpressClient, - source: params.source, - pub: signingKeychain.pub!, - }); - - // Prepare consolidation parameters - const consolidationParams = { + const consolidationParams: BuildConsolidationTransactionOptions = { ...params, - customSigningFunction, reqId, }; + // --- TSS/MPC support --- + if (wallet._wallet.multisigType === 'tss') { + // Always force apiVersion to 'full' for TSS/MPC + consolidationParams.apiVersion = 'full'; + + if (baseCoin.getMPCAlgorithm() === MPCType.EDDSA) { + consolidationParams.customCommitmentGeneratingFunction = createCustomCommitmentGenerator( + bitgo, + wallet, + enclavedExpressClient, + params.source, + signingKeychain.commonKeychain!, + ); + consolidationParams.customRShareGeneratingFunction = createCustomRShareGenerator( + enclavedExpressClient, + params.source, + signingKeychain.commonKeychain!, + ); + consolidationParams.customGShareGeneratingFunction = createCustomGShareGenerator( + enclavedExpressClient, + params.source, + signingKeychain.commonKeychain!, + ); + } else if (baseCoin.getMPCAlgorithm() === MPCType.ECDSA) { + throw new Error('ECDSA MPC consolidations not yet implemented'); + } + } else { + // Non-TSS: legacy custom signing function + consolidationParams.customSigningFunction = makeCustomSigningFunction({ + enclavedExpressClient, + source: params.source, + pub: signingKeychain.pub!, + }); + } + // Send account consolidations const result = await wallet.sendAccountConsolidations(consolidationParams); diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index a9548e5..a1ba1db 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -106,10 +106,11 @@ export const SendManyResponse: HttpResponse = { // Request type for /consolidate endpoint export const ConsolidateRequest = { - pubkey: t.string, + pubkey: t.union([t.undefined, t.string]), source: t.union([t.literal('user'), t.literal('backup')]), consolidateAddresses: t.union([t.undefined, t.array(t.string)]), apiVersion: t.union([t.undefined, t.literal('full'), t.literal('lite')]), + commonKeychain: t.union([t.undefined, t.string]), }; // Response type for /consolidate endpoint