diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts index f070832b..d23443d7 100644 --- a/src/__tests__/api/master/accelerate.test.ts +++ b/src/__tests__/api/master/accelerate.test.ts @@ -14,6 +14,27 @@ describe('POST /api/:coin/wallet/:walletId/accelerate', () => { const bitgoApiUrl = Environments.test.uri; const enclavedExpressUrl = 'https://test-enclaved-express.com'; + const mockWalletData = { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coin: coin, + label: 'Test Wallet', + }; + + const mockUserKeychain = { + id: 'user-key-id', + pub: 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp', + type: 'independent', + }; + + const mockBackupKeychain = { + id: 'backup-key-id', + pub: 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW', + type: 'independent', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -43,244 +64,348 @@ describe('POST /api/:coin/wallet/:walletId/accelerate', () => { sinon.restore(); }); - it('should accelerate transaction by calling the enclaved express service', async () => { - // Mock wallet get request + it('should succeed in accelerating transaction with CPFP using user key', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); - // 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', - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); - // Mock accelerateTransaction const accelerateTransactionStub = sinon .stub(Wallet.prototype, 'accelerateTransaction') .resolves({ - txid: 'accelerated-tx-id', - tx: 'accerated-transaction-hex', + txid: 'accelerated-tx-id-123', + tx: '0100000001abcdef...', status: 'signed', + hash: 'accelerated-tx-id-123', }); + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + maxFee: 10000, + }; + const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], - cpfpFeeRate: 50, - maxFee: 10000, - }); + .send(requestPayload); response.status.should.equal(200); - response.body.should.have.property('txid', 'accelerated-tx-id'); - response.body.should.have.property('status', 'signed'); + response.body.should.have.property('txid', 'accelerated-tx-id-123'); + response.body.should.have.property('tx', '0100000001abcdef...'); walletGetNock.done(); keychainGetNock.done(); sinon.assert.calledOnce(accelerateTransactionStub); + + const callArgs = accelerateTransactionStub.firstCall.args[0]; + callArgs!.should.have.property('cpfpTxIds'); + callArgs!.should.have.property('cpfpFeeRate', 50); + callArgs!.should.have.property('maxFee', 10000); + callArgs!.should.have.property('customSigningFunction'); + callArgs!.should.have.property('reqId'); }); - it('should handle acceleration with backup key signing', async () => { - // Mock wallet get request + it('should succeed in accelerating transaction with RBF using backup key', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); - // Mock keychain get request for backup key const keychainGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'backup-key-id', - pub: 'xpub_backup', - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBackupKeychain); - // Mock accelerateTransaction const accelerateTransactionStub = sinon .stub(Wallet.prototype, 'accelerateTransaction') .resolves({ - txid: 'accelerated-tx-id', - status: 'signed', - tx: 'accelerated-transaction-hex', + txid: 'rbf-accelerated-tx-id', + tx: '0100000001fedcba...', }); + const requestPayload = { + pubkey: mockBackupKeychain.pub, + source: 'backup' as const, + rbfTxIds: ['a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890'], + feeMultiplier: 1.5, + maxFee: 15000, + }; + const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'backup', - pubkey: 'xpub_backup', - rbfTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], - feeMultiplier: 1.5, + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'rbf-accelerated-tx-id'); + response.body.should.have.property('tx', '0100000001fedcba...'); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(accelerateTransactionStub); + }); + + it('should succeed in accelerating transaction with all optional parameters', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const accelerateTransactionStub = sinon + .stub(Wallet.prototype, 'accelerateTransaction') + .resolves({ + txid: 'accelerated-with-all-params', + tx: '0100000001abcdef123...', }); + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + cpfpTxIds: ['tx1', 'tx2'], + cpfpFeeRate: 100, + maxFee: 20000, + rbfTxIds: ['tx3', 'tx4'], + feeMultiplier: 2.0, + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + response.status.should.equal(200); - response.body.should.have.property('txid', 'accelerated-tx-id'); + response.body.should.have.property('txid', 'accelerated-with-all-params'); + response.body.should.have.property('tx', '0100000001abcdef123...'); walletGetNock.done(); keychainGetNock.done(); sinon.assert.calledOnce(accelerateTransactionStub); }); - it('should throw error when wallet not found', async () => { - // Mock wallet get request to return 404 + it('should fail when wallet is not found', async () => { const walletGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(404, { error: 'Wallet not found' }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Wallet not found', name: 'WalletNotFoundError' }); const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) .send({ + pubkey: mockUserKeychain.pub, source: 'user', - pubkey: 'xpub_user', - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpTxIds: ['test-tx-id'], }); response.status.should.equal(404); response.body.should.have.property('error'); - walletGetNock.done(); }); - it('should throw error when signing keychain not found', async () => { - // Mock wallet get request + it('should fail when signing keychain is not found', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); - // Mock keychain get request to return 404 const keychainGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(404, { error: 'Keychain not found' }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Keychain not found', name: 'KeychainNotFoundError' }); const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) .send({ + pubkey: mockUserKeychain.pub, source: 'user', - pubkey: 'xpub_user', - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpTxIds: ['test-tx-id'], }); response.status.should.equal(404); - response.body.should.have.property('error'); - walletGetNock.done(); keychainGetNock.done(); }); - it('should throw error when provided pubkey does not match wallet keychain', async () => { - // Mock wallet get request + it('should fail when provided pubkey does not match wallet keychain', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); - // 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', - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) .send({ + pubkey: 'xpub661MyMwAqRbcWRONG_PUBKEY_THAT_DOES_NOT_MATCH', source: 'user', - pubkey: 'wrong_pubkey', - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpTxIds: ['test-tx-id'], }); response.status.should.equal(500); response.body.should.have.property('error'); - walletGetNock.done(); keychainGetNock.done(); }); - it('should handle acceleration with additional parameters', async () => { - // Mock wallet get request + it('should fail when required pubkey parameter is missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + cpfpTxIds: ['test-tx-id'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when required source parameter is missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + cpfpTxIds: ['test-tx-id'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when source parameter has invalid value', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'invalid_source', + cpfpTxIds: ['test-tx-id'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when authorization header is missing', async () => { + const response = await agent.post(`/api/${coin}/wallet/${walletId}/accelerate`).send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: ['test-tx-id'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); + }); + + it('should fail when accelerateTransaction throws an error', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); - // 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', - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); - // Mock accelerateTransaction const accelerateTransactionStub = sinon .stub(Wallet.prototype, 'accelerateTransaction') - .resolves({ - txid: 'accelerated-tx-id', - status: 'signed', - tx: 'accelerated-transaction-hex', - }); + .rejects(new Error('Insufficient funds for acceleration')); const response = await agent .post(`/api/${coin}/wallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) .send({ + pubkey: mockUserKeychain.pub, source: 'user', - pubkey: 'xpub_user', - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpTxIds: ['test-tx-id'], cpfpFeeRate: 100, - maxFee: 20000, - feeMultiplier: 2.0, }); - response.status.should.equal(200); - response.body.should.have.property('txid', 'accelerated-tx-id'); + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('name', 'Error'); + response.body.should.have.property('details', 'Insufficient funds for acceleration'); walletGetNock.done(); keychainGetNock.done(); sinon.assert.calledOnce(accelerateTransactionStub); }); + + it('should fail when cpfpTxIds parameter is not an array', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: 'not-an-array', + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when rbfTxIds parameter is not an array', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + rbfTxIds: 'not-an-array', + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when pubkey parameter is not a string', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: 12345, + source: 'user', + cpfpTxIds: ['test-tx-id'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when both cpfpTxIds and rbfTxIds are missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); + }); }); diff --git a/src/__tests__/api/master/consolidate.test.ts b/src/__tests__/api/master/consolidate.test.ts index 6c5c19bd..08ee12f6 100644 --- a/src/__tests__/api/master/consolidate.test.ts +++ b/src/__tests__/api/master/consolidate.test.ts @@ -9,7 +9,7 @@ import { Hteth } from '@bitgo-beta/sdk-coin-eth'; import * as transactionRequests from '../../../api/master/handlers/transactionRequests'; import * as handlerUtils from '../../../api/master/handlerUtils'; -describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { +describe('POST /api/:coin/wallet/:walletId/consolidate', () => { let agent: request.SuperAgentTest; const coin = 'hteth'; const walletId = 'test-wallet-id'; @@ -17,6 +17,28 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { const bitgoApiUrl = Environments.test.uri; const enclavedExpressUrl = 'https://test-enclaved-express.com'; + const mockWalletData = (multisigType: 'onchain' | 'tss') => ({ + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coin: coin, + label: 'Test Wallet', + multisigType, + }); + + const mockUserKeychain = { + id: 'user-key-id', + pub: 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp', + type: 'independent', + }; + + const mockBackupKeychain = { + id: 'backup-key-id', + pub: 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW', + type: 'independent', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -46,40 +68,107 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { sinon.restore(); }); - // Helper functions to reduce duplication - const mockWalletGet = (multisigType: 'onchain' | 'tss') => { - return nock(bitgoApiUrl) + it('should succeed in consolidating multisig wallet addresses', async () => { + 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'], - multisigType, - }); - }; + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); - const mockKeychainGet = (commonKeychain?: string) => { - return nock(bitgoApiUrl) + const keychainGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - ...(commonKeychain && { commonKeychain }), + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const mockBuilds = [ + { + walletId, + txHex: 'unsigned-tx-hex-1', + txInfo: { unspents: [] }, + feeInfo: { fee: 1000 }, + }, + { + walletId, + txHex: 'unsigned-tx-hex-2', + txInfo: { unspents: [] }, + feeInfo: { fee: 1500 }, + }, + ]; + + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves(mockBuilds); + + const sendAccountConsolidationStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidation') + .resolves({ + txid: 'consolidation-tx-1', + status: 'signed', }); - }; - const mockTxRequest = (txRequestId: string) => { - return nock(bitgoApiUrl) + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(true); + + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('success'); + response.body.success.should.have.length(2); + 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); + sinon.assert.calledTwice(makeCustomSigningFunctionStub); + sinon.assert.calledOnce(allowsConsolidationsStub); + }); + + it('should succeed in consolidating MPC wallet using signAndSendTxRequests', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('tss')); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { ...mockUserKeychain, commonKeychain: 'user-common-key' }); + + const mockMpcBuild = { + walletId, + txHex: 'unsigned-mpc-tx-hex-1', + txInfo: { unspents: [] }, + feeInfo: { fee: 2000 }, + txRequestId: 'mpc-tx-request-1', + }; + + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([mockMpcBuild]); + + const getTxRequestNock = nock(bitgoApiUrl) .get(`/api/v2/wallet/${walletId}/txrequests`) - .query({ txRequestIds: txRequestId, latest: 'true' }) - .matchHeader('any', () => true) + .query({ txRequestIds: 'mpc-tx-request-1', latest: 'true' }) + .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, { txRequests: [ { - txRequestId, + txRequestId: 'mpc-tx-request-1', version: 1, latest: true, state: 'pendingUserSignature', @@ -96,334 +185,407 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { }, ], }); - }; - const createMultisigBuild = (index: number) => ({ - walletId, - txHex: `unsigned-tx-hex-${index}`, - txInfo: { unspents: [] }, - feeInfo: { fee: 1000 + index * 500 }, + const signAndSendTxRequestsStub = sinon + .stub(transactionRequests, 'signAndSendTxRequests') + .resolves({ + txid: 'mpc-consolidation-tx-1', + status: 'signed', + state: 'signed', + }); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(true); + + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + commonKeychain: 'user-common-key', + consolidateAddresses: ['0x1234567890abcdef'], + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + 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); + sinon.assert.calledOnce(allowsConsolidationsStub); }); - 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 succeed in consolidating with backup key', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBackupKeychain); + + const mockBuild = { + walletId, + txHex: 'unsigned-tx-hex-backup', + txInfo: { unspents: [] }, + feeInfo: { fee: 1200 }, + }; + + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves([mockBuild]); + + const sendAccountConsolidationStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidation') + .resolves({ + txid: 'backup-consolidation-tx', + status: 'signed', + }); + + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(true); + + const requestPayload = { + pubkey: mockBackupKeychain.pub, + source: 'backup' as const, + consolidateAddresses: ['0x1234567890abcdef'], + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('success'); + response.body.success.should.have.length(1); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(buildConsolidationsStub); + sinon.assert.calledOnce(sendAccountConsolidationStub); + sinon.assert.calledOnce(makeCustomSigningFunctionStub); + sinon.assert.calledOnce(allowsConsolidationsStub); }); - 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 fail when wallet is not found', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Wallet not found', name: 'WalletNotFoundError' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], + }); + + response.status.should.equal(404); + response.body.should.have.property('error'); + walletGetNock.done(); + }); - it('should handle partial multisig consolidation failures', async () => { - // Mock wallet and keychain requests - const walletGetNock = mockWalletGet('onchain'); - const keychainGetNock = mockKeychainGet(); + it('should fail when signing keychain is not found', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); - // Mock buildAccountConsolidations with multiple builds - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves([createMultisigBuild(1), createMultisigBuild(2)]); + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Keychain not found', name: 'KeychainNotFoundError' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], + }); - // Mock sendAccountConsolidation - first succeeds, second fails - const sendAccountConsolidationStub = sinon.stub(Wallet.prototype, 'sendAccountConsolidation'); - sendAccountConsolidationStub.onFirstCall().resolves({ - txid: 'consolidation-tx-1', - status: 'signed', + response.status.should.equal(404); + walletGetNock.done(); + keychainGetNock.done(); + }); + + it('should fail when provided pubkey does not match wallet keychain', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: 'xpub661MyMwAqRbcWRONG_PUBKEY_THAT_DOES_NOT_MATCH', + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], }); - 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); - }); + response.status.should.equal(500); + response.body.should.have.property('error'); + walletGetNock.done(); + keychainGetNock.done(); }); - 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 fail when coin does not support account consolidations', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); - 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'); + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(false); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], + }); - // Mock buildAccountConsolidations with multiple builds for MPC - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves([createMpcBuild(1), createMpcBuild(2)]); + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property( + 'details', + 'Invalid coin selected - account consolidations not supported', + ); - // Mock the HTTP requests for getTxRequest (both tx requests) - const getTxRequestNock1 = mockTxRequest('mpc-tx-request-1'); - const getTxRequestNock2 = mockTxRequest('mpc-tx-request-2'); + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(allowsConsolidationsStub); + }); - // Mock signAndSendTxRequests - first succeeds, second fails - const signAndSendTxRequestsStub = sinon.stub(transactionRequests, 'signAndSendTxRequests'); - signAndSendTxRequestsStub.onFirstCall().resolves({ - txid: 'mpc-consolidation-tx-1', - status: 'signed', - state: 'signed', + it('should fail when required pubkey parameter is missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); + }); + + it('should fail when required source parameter is missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + consolidateAddresses: ['0x1234567890abcdef'], }); - 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(400); + response.body.should.have.property('error'); + response.body.error.should.match(/Invalid value undefined supplied/); + }); + + it('should fail when source parameter has invalid value', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'invalid_source', + consolidateAddresses: ['0x1234567890abcdef'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + response.body.error.should.match(/Invalid value "invalid_source"/); + }); + + it('should fail when authorization header is missing', async () => { + const response = await agent.post(`/api/${coin}/wallet/${walletId}/consolidate`).send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef'], }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); }); - 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 fail when partial multisig consolidation failures occur', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const mockBuilds = [ + { walletId, txHex: 'unsigned-tx-hex-1' }, + { walletId, txHex: 'unsigned-tx-hex-2' }, + ]; + + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves(mockBuilds); + + const sendAccountConsolidationStub = sinon.stub(Wallet.prototype, 'sendAccountConsolidation'); + sendAccountConsolidationStub.onFirstCall().resolves({ + txid: 'consolidation-tx-1', + status: 'signed', }); + sendAccountConsolidationStub.onSecondCall().rejects(new Error('Insufficient funds')); + + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(true); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); - 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(); + 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); + sinon.assert.calledOnce(allowsConsolidationsStub); + }); - const response = await agent - .post(`/api/${coin}/wallet/${walletId}/consolidate`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'wrong_pubkey', - }); + it('should fail when all consolidations fail', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData('onchain')); - response.status.should.equal(500); + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const mockBuilds = [ + { walletId, txHex: 'unsigned-tx-hex-1' }, + { walletId, txHex: 'unsigned-tx-hex-2' }, + ]; + + const buildConsolidationsStub = sinon + .stub(Wallet.prototype, 'buildAccountConsolidations') + .resolves(mockBuilds); + + const sendAccountConsolidationStub = sinon + .stub(Wallet.prototype, 'sendAccountConsolidation') + .rejects(new Error('All consolidations failed')); + + const makeCustomSigningFunctionStub = sinon + .stub(handlerUtils, 'makeCustomSigningFunction') + .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + + const allowsConsolidationsStub = sinon + .stub(Hteth.prototype, 'allowsAccountConsolidations') + .returns(true); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'], + }); - walletGetNock.done(); - keychainGetNock.done(); - }); + 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); + sinon.assert.calledOnce(allowsConsolidationsStub); + }); + + it('should fail when consolidateAddresses parameter is not an array', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + consolidateAddresses: 'not-an-array', + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + response.body.error.should.match(/Invalid value "not-an-array"/); + }); + + it('should fail when apiVersion parameter has invalid value', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + apiVersion: 'invalid_version', + consolidateAddresses: ['0x1234567890abcdef'], + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + response.body.error.should.match(/Invalid value "invalid_version"/); }); }); diff --git a/src/__tests__/api/master/consolidateUnspents.test.ts b/src/__tests__/api/master/consolidateUnspents.test.ts index 61e4acc8..6b0e4b4a 100644 --- a/src/__tests__/api/master/consolidateUnspents.test.ts +++ b/src/__tests__/api/master/consolidateUnspents.test.ts @@ -14,6 +14,27 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { const bitgoApiUrl = Environments.test.uri; const enclavedExpressUrl = 'https://test-enclaved-express.com'; + const mockWalletData = { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coin: coin, + label: 'Test Wallet', + }; + + const mockUserKeychain = { + id: 'user-key-id', + pub: 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp', + type: 'independent', + }; + + const mockBackupKeychain = { + id: 'backup-key-id', + pub: 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW', + type: 'independent', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -43,24 +64,16 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { sinon.restore(); }); - it('should return transfer, txid, tx, and status on success', async () => { + it('should succeed in consolidating unspents with user key', async () => { 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'], - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); const keychainGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - }); + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); const mockResult = { transfer: { @@ -84,14 +97,18 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { .stub(Wallet.prototype, 'consolidateUnspents') .resolves(mockResult); + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + feeRate: 1000, + maxFeeRate: 2000, + minValue: 1000, + }; + const response = await agent .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - pubkey: 'xpub_user', - feeRate: 1000, - }); + .send(requestPayload); response.status.should.equal(200); response.body.should.have.property('transfer'); @@ -105,39 +122,281 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { walletGetNock.done(); keychainGetNock.done(); sinon.assert.calledOnce(consolidateUnspentsStub); + + const callArgs = consolidateUnspentsStub.firstCall.args[0]; + callArgs!.should.have.property('feeRate', 1000); + callArgs!.should.have.property('maxFeeRate', 2000); + callArgs!.should.have.property('minValue', 1000); + callArgs!.should.have.property('customSigningFunction'); + callArgs!.should.have.property('reqId'); + }); + + it('should succeed in consolidating unspents with backup key', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBackupKeychain); + + const mockResult = { + txid: 'backup-consolidation-tx-id', + tx: '01000000000102backup...', + status: 'signed', + }; + + const consolidateUnspentsStub = sinon + .stub(Wallet.prototype, 'consolidateUnspents') + .resolves(mockResult); + + const requestPayload = { + pubkey: mockBackupKeychain.pub, + source: 'backup' as const, + feeRate: 1500, + bulk: true, + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('txid', mockResult.txid); + response.body.should.have.property('tx', mockResult.tx); + response.body.should.have.property('status', mockResult.status); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(consolidateUnspentsStub); + }); + + it('should succeed in consolidating unspents with all optional parameters', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const mockResult = { + txid: 'full-params-consolidation-tx-id', + tx: '01000000000102full...', + status: 'signed', + }; + + const consolidateUnspentsStub = sinon + .stub(Wallet.prototype, 'consolidateUnspents') + .resolves(mockResult); + + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + feeRate: 1000, + maxFeeRate: 2000, + maxFeePercentage: 10, + feeTxConfirmTarget: 6, + bulk: true, + minValue: 1000, + maxValue: 50000, + minHeight: 100000, + minConfirms: 3, + enforceMinConfirmsForChange: true, + limit: 100, + numUnspentsToMake: 10, + targetAddress: 'tb1q...', + txFormat: 'psbt' as const, + }; + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('txid', mockResult.txid); + response.body.should.have.property('tx', mockResult.tx); + response.body.should.have.property('status', mockResult.status); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(consolidateUnspentsStub); + }); + + it('should fail when wallet is not found', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Wallet not found', name: 'WalletNotFoundError' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(404); + response.body.should.have.property('error'); + walletGetNock.done(); }); - it('should throw error when provided pubkey does not match wallet keychain', async () => { + it('should fail when signing keychain is not found', async () => { 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'], + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(404, { error: 'Keychain not found', name: 'KeychainNotFoundError' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, }); + response.status.should.equal(404); + walletGetNock.done(); + keychainGetNock.done(); + }); + + it('should fail when provided pubkey does not match wallet keychain', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + const keychainGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: 'xpub661MyMwAqRbcWRONG_PUBKEY_THAT_DOES_NOT_MATCH', + source: 'user', + feeRate: 1000, }); + response.status.should.equal(500); + response.body.should.have.property('error'); + walletGetNock.done(); + keychainGetNock.done(); + }); + + it('should fail when required pubkey parameter is missing', async () => { const response = await agent .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) .set('Authorization', `Bearer ${accessToken}`) .send({ source: 'user', - pubkey: 'wrong_pubkey', feeRate: 1000, }); + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when required source parameter is missing', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + feeRate: 1000, + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when source parameter has invalid value', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'invalid_source', + feeRate: 1000, + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when authorization header is missing', async () => { + const response = await agent.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`).send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); + }); + + it('should fail when consolidateUnspents throws an error', async () => { + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const consolidateUnspentsStub = sinon + .stub(Wallet.prototype, 'consolidateUnspents') + .rejects(new Error('No unspents available for consolidation')); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('name', 'Error'); + response.body.should.have.property('details', 'No unspents available for consolidation'); walletGetNock.done(); keychainGetNock.done(); + sinon.assert.calledOnce(consolidateUnspentsStub); + }); + + it('should fail when pubkey parameter is not a string', async () => { + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: 12345, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); }); }); diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index c10e199e..443dd372 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -11,8 +11,16 @@ import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpre describe('POST /api/:coin/wallet/recoveryconsolidations', () => { let agent: request.SuperAgentTest; - const enclavedExpressUrl = 'http://enclaved.invalid'; - const accessToken = 'test-token'; + const enclavedExpressUrl = 'https://test-enclaved-express.com'; + const accessToken = 'test-access-token'; + + const mockUserPub = + 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp'; + const mockBackupPub = + 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW'; + const mockBitgoPub = + 'xpub661MyMwAqRbcF1cvdJUvQ8MV6a7R5hF5cBmVxA1zS1k7RH7NKj3X7K8fgR4kS2qY6jW9cF7L'; + const mockCommonKeychain = 'common-keychain-123'; before(() => { nock.disableNetConnect(); @@ -27,7 +35,7 @@ describe('POST /api/:coin/wallet/recoveryconsolidations', () => { disableEnvCheck: true, authVersion: 2, enclavedExpressUrl, - enclavedExpressCert: 'dummy-cert', + enclavedExpressCert: 'test-cert', tlsMode: TlsMode.DISABLED, mtlsRequestCert: false, allowSelfSigned: true, @@ -41,271 +49,407 @@ describe('POST /api/:coin/wallet/recoveryconsolidations', () => { sinon.restore(); }); - describe('Non-MPC Wallets (multisigType: onchain)', () => { - it('should handle TRON consolidation recovery for onchain wallet', async () => { - const mockTransactions = [ - { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, - { txHex: 'unsigned-tx-2', serializedTx: 'serialized-unsigned-tx-2' }, - ]; - - const recoverConsolidationsStub = sinon - .stub(Trx.prototype, 'recoverConsolidations') - .resolves({ - transactions: mockTransactions, - }); - - const recoveryMultisigStub = sinon - .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') - .resolves({ txHex: 'signed-tx' }); - - const response = await agent - .post(`/api/trx/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'onchain', - userPub: 'user-xpub', - backupPub: 'backup-xpub', - bitgoPub: 'bitgo-xpub', - tokenContractAddress: 'tron-token', - startingScanIndex: 1, - endingScanIndex: 3, - }); - - response.status.should.equal(200); - response.body.should.have.property('signedTxs'); - response.body.signedTxs.should.have.length(2); - - sinon.assert.calledOnce(recoverConsolidationsStub); - sinon.assert.calledTwice(recoveryMultisigStub); - - const callArgs = recoverConsolidationsStub.firstCall.args[0]; - callArgs.tokenContractAddress!.should.equal('tron-token'); - callArgs.userKey!.should.equal('user-xpub'); - callArgs.backupKey!.should.equal('backup-xpub'); - callArgs.bitgoKey.should.equal('bitgo-xpub'); + it('should succeed in handling TRON consolidation recovery for onchain wallet', async () => { + const mockTransactions = [ + { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, + { txHex: 'unsigned-tx-2', serializedTx: 'serialized-unsigned-tx-2' }, + ]; + + const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({ + transactions: mockTransactions, }); - it('should handle Solana consolidation recovery for onchain wallet', async () => { - const mockTransactions = [ - { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, - ]; - - const recoverConsolidationsStub = sinon - .stub(Sol.prototype, 'recoverConsolidations') - .resolves({ - transactions: mockTransactions, - }); - - const recoveryMultisigStub = sinon - .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') - .resolves({ txHex: 'signed-tx' }); - - const response = await agent - .post(`/api/sol/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'onchain', - userPub: 'user-xpub', - backupPub: 'backup-xpub', - bitgoPub: 'bitgo-xpub', - durableNonces: { - publicKeys: ['sol-pubkey-1', 'sol-pubkey-2'], - secretKey: 'sol-secret', - }, - }); - - response.status.should.equal(200); - response.body.should.have.property('signedTxs'); - sinon.assert.calledOnce(recoverConsolidationsStub); - sinon.assert.calledOnce(recoveryMultisigStub); - - const callArgs = recoverConsolidationsStub.firstCall.args[0]; - callArgs.durableNonces.should.have.property('publicKeys').which.is.an.Array(); - callArgs.durableNonces.should.have.property('secretKey', 'sol-secret'); - callArgs.userKey!.should.equal('user-xpub'); - callArgs.backupKey!.should.equal('backup-xpub'); - callArgs.bitgoKey.should.equal('bitgo-xpub'); + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .resolves({ txHex: 'signed-tx' }); + + const requestPayload = { + multisigType: 'onchain' as const, + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + tokenContractAddress: 'tron-token-address', + startingScanIndex: 1, + endingScanIndex: 3, + }; + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(2); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledTwice(recoveryMultisigStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.should.have.property('tokenContractAddress', 'tron-token-address'); + callArgs.should.have.property('userKey', mockUserPub); + callArgs.should.have.property('backupKey', mockBackupPub); + callArgs.should.have.property('bitgoKey', mockBitgoPub); + }); + + it('should succeed in handling Solana consolidation recovery for onchain wallet', async () => { + const mockTransactions = [{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }]; + + const recoverConsolidationsStub = sinon.stub(Sol.prototype, 'recoverConsolidations').resolves({ + transactions: mockTransactions, }); + + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .resolves({ txHex: 'signed-tx' }); + + const requestPayload = { + multisigType: 'onchain' as const, + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + durableNonces: { + publicKeys: ['sol-pubkey-1', 'sol-pubkey-2'], + secretKey: 'sol-secret-key', + }, + }; + + const response = await agent + .post(`/api/sol/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(1); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMultisigStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.should.have.property('durableNonces'); + callArgs.durableNonces.should.have.property('publicKeys').which.is.an.Array(); + callArgs.durableNonces.should.have.property('secretKey', 'sol-secret-key'); + callArgs.should.have.property('userKey', mockUserPub); + callArgs.should.have.property('backupKey', mockBackupPub); + callArgs.should.have.property('bitgoKey', mockBitgoPub); }); - describe('MPC Wallets (multisigType: tss)', () => { - it('should handle MPC consolidation recovery with commonKeychain', async () => { - const mockTxRequests = [ - { - walletCoin: 'tsui', - transactions: [ - { - unsignedTx: { - txHex: 'unsigned-mpc-tx-1', - serializedTx: 'serialized-unsigned-mpc-tx-1', - }, - signatureShares: [], + it('should succeed in handling MPC consolidation recovery with commonKeychain', async () => { + const mockTxRequests = [ + { + walletCoin: 'tsui', + transactions: [ + { + unsignedTx: { + txHex: 'unsigned-mpc-tx-1', + serializedTx: 'serialized-unsigned-mpc-tx-1', }, - ], - }, - ] as any; - - const recoverConsolidationsStub = sinon - .stub(Sui.prototype, 'recoverConsolidations') - .resolves({ - txRequests: mockTxRequests, - }); - - const recoveryMPCStub = sinon - .stub(EnclavedExpressClient.prototype, 'recoveryMPC') - .resolves({ txHex: 'signed-mpc-tx' }); - - const response = await agent - .post(`/api/tsui/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'tss', - commonKeychain: 'common-keychain-key', - apiKey: 'test-api-key', - startingScanIndex: 0, - endingScanIndex: 5, - }); - - response.status.should.equal(200); - response.body.should.have.property('signedTxs'); - response.body.signedTxs.should.have.length(1); - - sinon.assert.calledOnce(recoverConsolidationsStub); - sinon.assert.calledOnce(recoveryMPCStub); - - const callArgs = recoverConsolidationsStub.firstCall.args[0]; - callArgs.userKey!.should.equal(''); - callArgs.backupKey!.should.equal(''); - callArgs.bitgoKey.should.equal('common-keychain-key'); - - const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; - mpcCallArgs.userPub.should.equal('common-keychain-key'); - mpcCallArgs.backupPub.should.equal('common-keychain-key'); - mpcCallArgs.apiKey.should.equal('test-api-key'); + signatureShares: [], + }, + ], + }, + ] as any; + + const recoverConsolidationsStub = sinon.stub(Sui.prototype, 'recoverConsolidations').resolves({ + txRequests: mockTxRequests, }); - it('should handle SOL MPC consolidation recovery', async () => { - const mockTransactions = [ - { txHex: 'unsigned-mpc-tx-1', serializedTx: 'serialized-mpc-tx-1' }, - ]; - - const recoverConsolidationsStub = sinon - .stub(Sol.prototype, 'recoverConsolidations') - .resolves({ - transactions: mockTransactions, - }); - - const recoveryMPCStub = sinon - .stub(EnclavedExpressClient.prototype, 'recoveryMPC') - .resolves({ txHex: 'signed-mpc-tx' }); - - const response = await agent - .post(`/api/sol/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'tss', - commonKeychain: 'sol-common-key', - apiKey: 'sol-api-key', - durableNonces: { - publicKeys: ['sol-pubkey-1'], - secretKey: 'sol-secret', - }, - }); + const recoveryMPCStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMPC') + .resolves({ txHex: 'signed-mpc-tx' }); - response.status.should.equal(200); - response.body.should.have.property('signedTxs'); - sinon.assert.calledOnce(recoverConsolidationsStub); - sinon.assert.calledOnce(recoveryMPCStub); + const requestPayload = { + multisigType: 'tss' as const, + commonKeychain: mockCommonKeychain, + apiKey: 'test-api-key', + startingScanIndex: 0, + endingScanIndex: 5, + }; - const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; - mpcCallArgs.userPub.should.equal('sol-common-key'); - mpcCallArgs.backupPub.should.equal('sol-common-key'); - mpcCallArgs.apiKey.should.equal('sol-api-key'); - }); + const response = await agent + .post(`/api/tsui/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(1); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMPCStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.should.have.property('userKey', ''); + callArgs.should.have.property('backupKey', ''); + callArgs.should.have.property('bitgoKey', mockCommonKeychain); + + const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; + mpcCallArgs.should.have.property('userPub', mockCommonKeychain); + mpcCallArgs.should.have.property('backupPub', mockCommonKeychain); + mpcCallArgs.should.have.property('apiKey', 'test-api-key'); }); - describe('Error Cases', () => { - it('should throw error when commonKeychain is missing for MPC wallet', async () => { - const response = await agent - .post(`/api/tsui/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'tss', - // Missing commonKeychain - apiKey: 'test-api-key', - }); - - response.status.should.equal(500); - response.body.should.have.property('error'); - response.body.should.have - .property('details') - .which.match(/Missing required key: commonKeychain/); + it('should succeed in handling SOL MPC consolidation recovery', async () => { + const mockTransactions = [{ txHex: 'unsigned-mpc-tx-1', serializedTx: 'serialized-mpc-tx-1' }]; + + const recoverConsolidationsStub = sinon.stub(Sol.prototype, 'recoverConsolidations').resolves({ + transactions: mockTransactions, }); - it('should throw error when required keys are missing for onchain wallet', async () => { - const response = await agent - .post(`/api/trx/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'onchain', - userPub: 'user-xpub', - // Missing backupPub and bitgoPub - }); - - response.status.should.equal(500); - response.body.should.have.property('error'); - response.body.should.have.property('details').which.match(/Missing required keys/); + const recoveryMPCStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMPC') + .resolves({ txHex: 'signed-mpc-tx' }); + + const requestPayload = { + multisigType: 'tss' as const, + commonKeychain: mockCommonKeychain, + apiKey: 'sol-api-key', + durableNonces: { + publicKeys: ['sol-pubkey-1'], + secretKey: 'sol-secret', + }, + }; + + const response = await agent + .post(`/api/sol/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(1); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMPCStub); + + const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; + mpcCallArgs.should.have.property('userPub', mockCommonKeychain); + mpcCallArgs.should.have.property('backupPub', mockCommonKeychain); + mpcCallArgs.should.have.property('apiKey', 'sol-api-key'); + }); + + it('should succeed in handling multiple recovery consolidations', async () => { + const mockTransactions = [ + { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, + { txHex: 'unsigned-tx-2', serializedTx: 'serialized-unsigned-tx-2' }, + { txHex: 'unsigned-tx-3', serializedTx: 'serialized-unsigned-tx-3' }, + ]; + + const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({ + transactions: mockTransactions, }); - it('should handle empty recovery consolidations result', async () => { - const recoverConsolidationsStub = sinon - .stub(Trx.prototype, 'recoverConsolidations') - .resolves({ - transactions: [], // Empty result - } as any); - - const response = await agent - .post(`/api/trx/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'onchain', - userPub: 'user-xpub', - backupPub: 'backup-xpub', - bitgoPub: 'bitgo-xpub', - }); - - response.status.should.equal(200); - response.body.should.have.property('signedTxs'); - response.body.signedTxs.should.have.length(0); // Empty array - - sinon.assert.calledOnce(recoverConsolidationsStub); + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .resolves({ txHex: 'signed-tx' }); + + const requestPayload = { + multisigType: 'onchain' as const, + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + startingScanIndex: 0, + endingScanIndex: 10, + }; + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(3); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledThrice(recoveryMultisigStub); + }); + + it('should fail when commonKeychain is missing for MPC wallet', async () => { + const response = await agent + .post(`/api/tsui/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'tss', + apiKey: 'test-api-key', + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details', 'Missing required key: commonKeychain'); + }); + + it('should fail when required keys are missing for onchain wallet', async () => { + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property( + 'details', + 'Missing required keys: userPub, backupPub, bitgoPub', + ); + }); + + it('should fail when required multisigType parameter is missing', async () => { + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when multisigType parameter has invalid value', async () => { + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'invalid_type', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); + }); + + it('should fail when authorization header is missing', async () => { + const response = await agent.post(`/api/trx/wallet/recoveryconsolidations`).send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, }); - it('should throw error when recoverConsolidations returns unexpected result structure', async () => { - const recoverConsolidationsStub = sinon - .stub(Trx.prototype, 'recoverConsolidations') - .resolves({ - // Missing both transactions and txRequests properties - someOtherProperty: 'value', - } as any); - - const response = await agent - .post(`/api/trx/wallet/recoveryconsolidations`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multisigType: 'onchain', - userPub: 'user-xpub', - backupPub: 'backup-xpub', - bitgoPub: 'bitgo-xpub', - }); - - response.status.should.equal(500); - response.body.should.have.property('error'); - response.body.should.have - .property('details') - .which.match(/recoverConsolidations did not return expected transactions/); - - sinon.assert.calledOnce(recoverConsolidationsStub); + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details'); + }); + + it('should succeed in handling empty recovery consolidations result', async () => { + const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({ + transactions: [], + } as any); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(0); + + sinon.assert.calledOnce(recoverConsolidationsStub); + }); + + it('should fail when recoverConsolidations returns unexpected result structure', async () => { + const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({ + someOtherProperty: 'value', + } as any); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property( + 'details', + 'recoverConsolidations did not return expected transactions', + ); + + sinon.assert.calledOnce(recoverConsolidationsStub); + }); + + it('should fail when recoverConsolidations throws an error', async () => { + const recoverConsolidationsStub = sinon + .stub(Trx.prototype, 'recoverConsolidations') + .rejects(new Error('Failed to recover consolidations')); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details', 'Failed to recover consolidations'); + + sinon.assert.calledOnce(recoverConsolidationsStub); + }); + + it('should fail when enclavedExpressClient throws an error', async () => { + const mockTransactions = [{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }]; + + const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({ + transactions: mockTransactions, }); + + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .rejects(new Error('Enclaved Express signing failed')); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', 'Internal Server Error'); + response.body.should.have.property('details', 'Enclaved Express signing failed'); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMultisigStub); + }); + + it('should fail when durableNonces parameter is not correctly structured', async () => { + const response = await agent + .post(`/api/sol/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + durableNonces: 'invalid-structure', + }); + + response.status.should.equal(400); + response.body.should.have.property('error'); }); });