diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 7cb4a8e2..ff66e051 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/consolidate.test.ts b/src/__tests__/api/master/consolidate.test.ts index 352a9269..2e983b7a 100644 --- a/src/__tests__/api/master/consolidate.test.ts +++ b/src/__tests__/api/master/consolidate.test.ts @@ -6,8 +6,10 @@ import { app as expressApp } from '../../../masterExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { Environments, Wallet } from '@bitgo/sdk-core'; import { Hteth } from '@bitgo/sdk-coin-eth'; +import * as transactionRequests from '../../../api/master/handlers/transactionRequests'; +import * as handlerUtils from '../../../api/master/handlerUtils'; -describe('POST /api/:coin/wallet/:walletId/consolidate', () => { +describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { let agent: request.SuperAgentTest; const coin = 'hteth'; const walletId = 'test-wallet-id'; @@ -21,7 +23,7 @@ describe('POST /api/:coin/wallet/:walletId/consolidate', () => { const config: MasterExpressConfig = { appMode: AppMode.MASTER_EXPRESS, - port: 0, // Let OS assign a free port + port: 0, bind: 'localhost', timeout: 30000, logFile: '', @@ -44,9 +46,9 @@ describe('POST /api/:coin/wallet/:walletId/consolidate', () => { sinon.restore(); }); - it('should consolidate account addresses by calling the enclaved express service', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) + // Helper functions to reduce duplication + const mockWalletGet = (multisigType: 'onchain' | 'tss') => { + return nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('any', () => true) .reply(200, { @@ -54,236 +56,374 @@ describe('POST /api/:coin/wallet/:walletId/consolidate', () => { type: 'cold', subType: 'onPrem', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType, }); + }; - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) + const mockKeychainGet = (commonKeychain?: string) => { + return nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('any', () => true) .reply(200, { id: 'user-key-id', pub: 'xpub_user', + ...(commonKeychain && { commonKeychain }), }); + }; - // Mock sendAccountConsolidations - const sendConsolidationsStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidations') - .resolves({ - success: [ - { - txid: 'consolidation-tx-1', - status: 'signed', - }, - ], - failure: [], - }); - - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], - }); - - 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', 'consolidation-tx-1'); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(sendConsolidationsStub); - }); - - it('should handle partial consolidation failures', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) + const mockTxRequest = (txRequestId: string) => { + return nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: txRequestId, latest: 'true' }) .matchHeader('any', () => true) .reply(200, { - id: walletId, - type: 'cold', - subType: 'onPrem', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - }); - - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - }); - - // Mock sendAccountConsolidations with partial failures - const sendConsolidationsStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidations') - .resolves({ - success: [ - { - txid: 'consolidation-tx-1', - status: 'signed', - }, - ], - failure: [ + txRequests: [ { - error: 'Insufficient funds', - address: '0xfedcba0987654321', + txRequestId, + version: 1, + latest: true, + state: 'pendingUserSignature', + transactions: [], + walletId: walletId, + walletType: 'cold', + date: new Date().toISOString(), + userId: 'test-user-id', + enterpriseId: 'test-enterprise-id', + intent: { intentType: 'payment' }, + txHashes: [], + policiesChecked: false, + unsignedTxs: [], }, ], }); + }; - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - 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(); - sinon.assert.calledOnce(sendConsolidationsStub); + const createMultisigBuild = (index: number) => ({ + walletId, + txHex: `unsigned-tx-hex-${index}`, + txInfo: { unspents: [] }, + feeInfo: { fee: 1000 + index * 500 }, }); - it('should throw error when all consolidations fail', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'cold', - subType: 'onPrem', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - }); - - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - }); - - // Mock sendAccountConsolidations with all failures - const sendConsolidationsStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidations') - .resolves({ - success: [], - failure: [ - { - error: 'All consolidations failed', - address: '0x1234567890abcdef', - }, - { - error: 'All consolidations failed', - address: '0xfedcba0987654321', - }, - ], - }); - - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], - }); - - 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(); - sinon.assert.calledOnce(sendConsolidationsStub); + const createMpcBuild = (index: number) => ({ + walletId, + txHex: `unsigned-mpc-tx-hex-${index}`, + txInfo: { unspents: [] }, + feeInfo: { fee: 2000 + index * 500 }, + txRequestId: `mpc-tx-request-${index}`, }); - it('should throw error when coin does not support account consolidations', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'cold', - subType: 'onPrem', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - }); - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', + describe('Multisig Wallets (onchain)', () => { + it('should consolidate multisig wallet addresses successfully', async () => { + // Mock wallet and keychain requests + const walletGetNock = mockWalletGet('onchain'); + const keychainGetNock = mockKeychainGet(); + + // Mock buildAccountConsolidations + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMultisigBuild(1), createMultisigBuild(2)]); + + // Mock sendAccountConsolidation for multisig wallets + const sendAccountConsolidationStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidation') + .resolves({ + txid: 'consolidation-tx-1', + status: 'signed', + }); + + // Mock makeCustomSigningFunction + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); + + response.status.should.equal(200); + response.body.should.have.property('success'); + response.body.success.should.have.length(2); // Two successful builds + response.body.should.have.property('failure'); + response.body.failure.should.have.length(0); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledTwice(sendAccountConsolidationStub); // Called for each build + sinon.assert.calledTwice(makeCustomSigningFunctionStub); + }); + + it('should handle partial multisig consolidation failures', async () => { + // Mock wallet and keychain requests + const walletGetNock = mockWalletGet('onchain'); + const keychainGetNock = mockKeychainGet(); + + // Mock buildAccountConsolidations with multiple builds + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMultisigBuild(1), createMultisigBuild(2)]); + + // Mock sendAccountConsolidation - first succeeds, second fails + const sendAccountConsolidationStub = sinon.stub(Wallet.prototype, 'sendAccountConsolidation'); + sendAccountConsolidationStub.onFirstCall().resolves({ + txid: 'consolidation-tx-1', + status: 'signed', }); - - // Mock allowsAccountConsolidations to return false - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(false); - - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - }); - - response.status.should.equal(500); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(allowsConsolidationsStub); + sendAccountConsolidationStub.onSecondCall().rejects(new Error('Insufficient funds')); + + // Mock makeCustomSigningFunction + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + 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(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledTwice(sendAccountConsolidationStub); + sinon.assert.calledTwice(makeCustomSigningFunctionStub); + }); + + it('should throw error when all multisig consolidations fail', async () => { + // Mock wallet and keychain requests + const walletGetNock = mockWalletGet('onchain'); + const keychainGetNock = mockKeychainGet(); + + // Mock buildAccountConsolidations with multiple builds + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMultisigBuild(1), createMultisigBuild(2)]); + + // Mock sendAccountConsolidation to always fail + const sendAccountConsolidationStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidation') + .rejects(new Error('All consolidations failed')); + + // Mock makeCustomSigningFunction + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); + + 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(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledTwice(sendAccountConsolidationStub); + sinon.assert.calledTwice(makeCustomSigningFunctionStub); + }); }); - it('should throw error when provided pubkey does not match wallet keychain', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'cold', - subType: 'onPrem', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - }); - - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - }); - - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'wrong_pubkey', + describe('MPC Wallets (tss)', () => { + it('should consolidate MPC wallet using signAndSendTxRequests', async () => { + // Mock wallet and keychain requests for MPC wallet + const walletGetNock = mockWalletGet('tss'); + const keychainGetNock = mockKeychainGet('user-common-key'); + + // Mock buildAccountConsolidations for MPC + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMpcBuild(1)]); + + // Mock the HTTP request for getTxRequest + const getTxRequestNock = mockTxRequest('mpc-tx-request-1'); + + // Mock signAndSendTxRequests for MPC wallets + const signAndSendTxRequestsStub = sinon + .stub(transactionRequests, 'signAndSendTxRequests') + .resolves({ + txid: 'mpc-consolidation-tx-1', + status: 'signed', + state: 'signed', + }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'user-common-key', + consolidateAddresses: ['0x1234567890abcdef'], + }); + + 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-consolidation-tx-1'); + response.body.should.have.property('failure'); + response.body.failure.should.have.length(0); + + walletGetNock.done(); + keychainGetNock.done(); + getTxRequestNock.done(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledOnce(signAndSendTxRequestsStub); + + // Verify MPC-specific parameters + sinon.assert.calledWith(buildConsolidationsStub, sinon.match.hasNested('apiVersion', 'full')); + }); + + it('should handle partial MPC consolidation failures', async () => { + // Mock wallet and keychain requests for MPC wallet + const walletGetNock = mockWalletGet('tss'); + const keychainGetNock = mockKeychainGet('user-common-key'); + + // Mock buildAccountConsolidations with multiple builds for MPC + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMpcBuild(1), createMpcBuild(2)]); + + // Mock the HTTP requests for getTxRequest (both tx requests) + const getTxRequestNock1 = mockTxRequest('mpc-tx-request-1'); + const getTxRequestNock2 = mockTxRequest('mpc-tx-request-2'); + + // Mock signAndSendTxRequests - first succeeds, second fails + const signAndSendTxRequestsStub = sinon.stub(transactionRequests, 'signAndSendTxRequests'); + signAndSendTxRequestsStub.onFirstCall().resolves({ + txid: 'mpc-consolidation-tx-1', + status: 'signed', + state: 'signed', }); + signAndSendTxRequestsStub.onSecondCall().rejects(new Error('MPC signing failed')); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'user-common-key', + 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(); + getTxRequestNock1.done(); + getTxRequestNock2.done(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledTwice(signAndSendTxRequestsStub); + }); + + it('should throw error when all MPC consolidations fail', async () => { + // Mock wallet and keychain requests for MPC wallet + const walletGetNock = mockWalletGet('tss'); + const keychainGetNock = mockKeychainGet('user-common-key'); + + // Mock buildAccountConsolidations with multiple builds for MPC + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([createMpcBuild(1), createMpcBuild(2)]); + + // Mock the HTTP requests for getTxRequest (both tx requests) + const getTxRequestNock1 = mockTxRequest('mpc-tx-request-1'); + const getTxRequestNock2 = mockTxRequest('mpc-tx-request-2'); + + // Mock signAndSendTxRequests to always fail for MPC + const signAndSendTxRequestsStub = sinon + .stub(transactionRequests, 'signAndSendTxRequests') + .rejects(new Error('All MPC consolidations failed')); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'user-common-key', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); + + 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(); + getTxRequestNock1.done(); + getTxRequestNock2.done(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledTwice(signAndSendTxRequestsStub); + }); + }); - response.status.should.equal(500); - - walletGetNock.done(); - keychainGetNock.done(); + describe('Common Error Cases', () => { + it('should throw error when coin does not support account consolidations', async () => { + // Mock wallet and keychain requests + const walletGetNock = mockWalletGet('onchain'); + const keychainGetNock = mockKeychainGet(); + + // Mock allowsAccountConsolidations to return false + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(false); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + }); + + response.status.should.equal(500); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(allowsConsolidationsStub); + }); + + it('should throw error when provided pubkey does not match wallet keychain', async () => { + // Mock wallet and keychain requests + const walletGetNock = mockWalletGet('onchain'); + const keychainGetNock = mockKeychainGet(); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'wrong_pubkey', + }); + + response.status.should.equal(500); + + walletGetNock.done(); + keychainGetNock.done(); + }); }); }); diff --git a/src/api/master/handlerUtils.ts b/src/api/master/handlerUtils.ts index 9c297f57..ed480dd5 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/handleConsolidate.ts b/src/api/master/handlers/handleConsolidate.ts index 30940d4d..bd4f9311 100644 --- a/src/api/master/handlers/handleConsolidate.ts +++ b/src/api/master/handlers/handleConsolidate.ts @@ -1,7 +1,13 @@ -import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; +import { + RequestTracer, + KeyIndices, + BuildConsolidationTransactionOptions, + getTxRequest, +} from '@bitgo/sdk-core'; import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../handlerUtils'; +import { signAndSendTxRequests } from './transactionRequests'; export async function handleConsolidate( req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>, @@ -32,46 +38,92 @@ export async function handleConsolidate( throw new Error('consolidateAddresses must be an array of addresses'); } + const isMPC = wallet.multisigType() === 'tss'; + 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, }; - // Send account consolidations - const result = await wallet.sendAccountConsolidations(consolidationParams); + isMPC && (consolidationParams.apiVersion = 'full'); + + const successfulTxs: any[] = []; + const failedTxs = new Array(); + + const unsignedBuilds = await wallet.buildAccountConsolidations(consolidationParams); + + logger.debug( + `Consolidation request for wallet ${walletId} with ${unsignedBuilds.length} unsigned builds`, + ); + + if (unsignedBuilds && unsignedBuilds.length > 0) { + for (const unsignedBuild of unsignedBuilds) { + try { + const result = isMPC + ? await signAndSendTxRequests( + bitgo, + wallet, + await getTxRequest( + bitgo, + wallet.id(), + (() => { + if (!unsignedBuild.txRequestId) { + throw new Error('Missing txRequestId in unsigned build'); + } + return unsignedBuild.txRequestId; + })(), + reqId, + ), + enclavedExpressClient, + signingKeychain, + reqId, + ) + : await wallet.sendAccountConsolidation({ + ...consolidationParams, + prebuildTx: unsignedBuild, + customSigningFunction: makeCustomSigningFunction({ + enclavedExpressClient, + source: params.source, + pub: signingKeychain.pub!, + }), + }); + + successfulTxs.push(result); + } catch (e) { + logger.error('Error during account consolidation: %s', (e as Error).message, e); + failedTxs.push(e as any); + } + } + } // Handle failures - if (result.failure && result.failure.length > 0) { - logger.debug('Consolidation result: %s', JSON.stringify(result, null, 2)); + if (failedTxs.length > 0) { let msg = ''; let status = 202; - if (result.success && result.success.length > 0) { + if (successfulTxs.length > 0) { // Some succeeded, some failed - msg = `Consolidations failed: ${result.failure.length} and succeeded: ${result.success.length}`; + msg = `Consolidations failed: ${failedTxs.length} and succeeded: ${successfulTxs.length}`; } else { // All failed - status = 400; + status = 500; msg = 'All consolidations failed'; } const error = new Error(msg); (error as any).status = status; - (error as any).result = result; + (error as any).result = { + success: successfulTxs, + failure: failedTxs, + }; throw error; } - return result; + return { + success: successfulTxs, + failure: failedTxs, + }; } catch (error) { const err = error as Error; logger.error('Failed to consolidate account: %s', err.message); diff --git a/src/api/master/handlers/handleSendMany.ts b/src/api/master/handlers/handleSendMany.ts index 46fdc5b8..31299dea 100644 --- a/src/api/master/handlers/handleSendMany.ts +++ b/src/api/master/handlers/handleSendMany.ts @@ -192,7 +192,7 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s } } -async function signAndSendMultisig( +export async function signAndSendMultisig( wallet: Wallet, source: 'user' | 'backup', txPrebuilt: PrebuildTransactionResult, diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index a9548e50..a1ba1dbc 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