diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index eeaa537bd5..7c130fcf8f 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -767,9 +767,11 @@ async function handleV2ConsolidateUnspents( * * @param req */ -export async function handleV2ConsolidateAccount(req: express.Request) { +export async function handleV2ConsolidateAccount( + req: ExpressApiRouteRequest<'express.v2.wallet.consolidateaccount', 'post'> +) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); + const coin = bitgo.coin(req.decoded.coin); if (req.body.consolidateAddresses && !_.isArray(req.body.consolidateAddresses)) { throw new Error('consolidate address must be an array of addresses'); @@ -779,7 +781,7 @@ export async function handleV2ConsolidateAccount(req: express.Request) { throw new Error('invalid coin selected'); } - const wallet = await coin.wallets().get({ id: req.params.id }); + const wallet = await coin.wallets().get({ id: req.decoded.id }); let result: any; try { @@ -1676,12 +1678,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ); // account-based - app.post( - '/api/v2/:coin/wallet/:id/consolidateAccount', - parseBody, + router.post('express.v2.wallet.consolidateaccount', [ prepareBitGo(config), - promiseWrapper(handleV2ConsolidateAccount) - ); + typedPromiseWrapper(handleV2ConsolidateAccount), + ]); // Miscellaneous app.post('/api/v2/:coin/canonicaladdress', parseBody, prepareBitGo(config), promiseWrapper(handleCanonicalAddress)); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index b275c9466b..329b3870c9 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -44,6 +44,7 @@ import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload'; import { PostLightningWalletPayment } from './v2/lightningPayment'; import { PostLightningWalletWithdraw } from './v2/lightningWithdraw'; import { PutV2PendingApproval } from './v2/pendingApproval'; +import { PostConsolidateAccount } from './v2/consolidateAccount'; // Too large types can cause the following error // @@ -156,6 +157,12 @@ export const ExpressWalletConsolidateUnspentsApiSpec = apiSpec({ }, }); +export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({ + 'express.v2.wallet.consolidateaccount': { + post: PostConsolidateAccount, + }, +}); + export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({ 'express.v1.wallet.fanoutunspents': { put: PutFanoutUnspents, @@ -292,6 +299,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV1KeychainLocalApiSpec & typeof ExpressV1PendingApprovalConstructTxApiSpec & typeof ExpressWalletConsolidateUnspentsApiSpec & + typeof ExpressV2WalletConsolidateAccountApiSpec & typeof ExpressWalletFanoutUnspentsApiSpec & typeof ExpressV2WalletCreateAddressApiSpec & typeof ExpressKeychainLocalApiSpec & @@ -329,6 +337,7 @@ export const ExpressApi: ExpressApi = { ...ExpressWalletConsolidateUnspentsApiSpec, ...ExpressWalletFanoutUnspentsApiSpec, ...ExpressV2WalletCreateAddressApiSpec, + ...ExpressV2WalletConsolidateAccountApiSpec, ...ExpressKeychainLocalApiSpec, ...ExpressKeychainChangePasswordApiSpec, ...ExpressLightningWalletPaymentApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/consolidateAccount.ts b/modules/express/src/typedRoutes/api/v2/consolidateAccount.ts new file mode 100644 index 0000000000..7e42582117 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/consolidateAccount.ts @@ -0,0 +1,282 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for consolidate account endpoint + */ +export const ConsolidateAccountParams = { + /** Coin identifier (e.g., 'algo', 'sol', 'xtz') */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * Request body for consolidating account balances + * Based on BuildConsolidationTransactionOptions which extends: + * - PrebuildTransactionOptions (iWallet.ts lines 90-221) + * - WalletSignTransactionOptions (iWallet.ts lines 265-289) + */ +export const ConsolidateAccountRequestBody = { + /** On-chain receive addresses to consolidate from (BuildConsolidationTransactionOptions) */ + consolidateAddresses: optional(t.array(t.string)), + + /** Wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + /** Extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + /** One-time password for 2FA */ + otp: optional(t.string), + + /** Transaction recipients */ + recipients: optional( + t.array( + t.type({ + address: t.string, + amount: t.union([t.string, t.number]), + }) + ) + ), + /** Estimate fees to aim for confirmation within this number of blocks */ + numBlocks: optional(t.number), + /** Maximum fee rate limit */ + maxFeeRate: optional(t.number), + /** Minimum number of confirmations needed */ + minConfirms: optional(t.number), + /** If true, minConfirms also applies to change outputs */ + enforceMinConfirmsForChange: optional(t.boolean), + /** Target number of unspents in wallet after consolidation */ + targetWalletUnspents: optional(t.number), + /** Minimum value of balances to use (in base units) */ + minValue: optional(t.union([t.number, t.string])), + /** Maximum value of balances to use (in base units) */ + maxValue: optional(t.union([t.number, t.string])), + /** Sequence ID for transaction tracking */ + sequenceId: optional(t.string), + /** Last ledger sequence (for Stellar/XRP) */ + lastLedgerSequence: optional(t.number), + /** Ledger sequence delta (for Stellar/XRP) */ + ledgerSequenceDelta: optional(t.number), + /** Gas price for Ethereum-like chains */ + gasPrice: optional(t.number), + /** If true, does not split change output */ + noSplitChange: optional(t.boolean), + /** Array of specific unspents to use in transaction */ + unspents: optional(t.array(t.string)), + /** Receive address from which funds will be withdrawn (for ADA) */ + senderAddress: optional(t.string), + /** Sender wallet ID when different from current wallet */ + senderWalletId: optional(t.string), + /** Messages to attach to outputs */ + messages: optional( + t.array( + t.type({ + address: t.string, + message: t.string, + }) + ) + ), + /** Change address for the transaction */ + changeAddress: optional(t.string), + /** Allow using external change address */ + allowExternalChangeAddress: optional(t.boolean), + /** Transaction type */ + type: optional(t.string), + /** Close remainder to this address (for Algorand) */ + closeRemainderTo: optional(t.string), + /** Non-participation flag (for Algorand) */ + nonParticipation: optional(t.boolean), + /** Valid from block number */ + validFromBlock: optional(t.number), + /** Valid to block number */ + validToBlock: optional(t.number), + /** If true, creates instant transaction */ + instant: optional(t.boolean), + /** Transaction memo */ + memo: optional(t.intersection([t.type({ value: t.string }), t.partial({ type: t.string })])), + /** Address type to use */ + addressType: optional(t.string), + /** Change address type to use */ + changeAddressType: optional(t.string), + /** If true, enables hop transaction */ + hop: optional(t.boolean), + /** Unspent reservation details */ + reservation: optional( + t.partial({ + expireTime: t.string, + pendingApprovalId: t.string, + }) + ), + /** If true, performs offline verification */ + offlineVerification: optional(t.boolean), + /** Wallet contract address */ + walletContractAddress: optional(t.string), + /** IDF signed timestamp */ + idfSignedTimestamp: optional(t.string), + /** IDF user ID */ + idfUserId: optional(t.string), + /** IDF version */ + idfVersion: optional(t.number), + /** Comment to attach to the transaction */ + comment: optional(t.string), + /** Token name for token operations */ + tokenName: optional(t.string), + /** NFT collection ID */ + nftCollectionId: optional(t.string), + /** NFT ID */ + nftId: optional(t.string), + /** Tokens to enable */ + enableTokens: optional(t.array(t.intersection([t.type({ name: t.string }), t.partial({ address: t.string })]))), + /** Nonce for account-based coins */ + nonce: optional(t.string), + /** If true, previews the transaction without sending */ + preview: optional(t.boolean), + /** EIP-1559 fee parameters for Ethereum */ + eip1559: optional( + t.type({ + maxFeePerGas: t.string, + maxPriorityFeePerGas: t.string, + }) + ), + /** Gas limit for Ethereum-like chains */ + gasLimit: optional(t.number), + /** Low fee transaction ID for RBF */ + lowFeeTxid: optional(t.string), + /** Receive address for specific operations */ + receiveAddress: optional(t.string), + /** If true, indicates TSS transaction */ + isTss: optional(t.boolean), + /** Custodian transaction ID */ + custodianTransactionId: optional(t.string), + /** API version ('lite' or 'full') */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), + /** If false, sweep all funds including minimums */ + keepAlive: optional(t.boolean), + /** Transaction format type */ + txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])), + /** Custom Solana instructions to include in the transaction */ + solInstructions: optional( + t.array( + t.type({ + programId: t.string, + keys: t.array( + t.type({ + pubkey: t.string, + isSigner: t.boolean, + isWritable: t.boolean, + }) + ), + data: t.string, + }) + ) + ), + /** Solana versioned transaction data for Address Lookup Tables */ + solVersionedTransactionData: optional( + t.partial({ + versionedInstructions: t.array( + t.type({ + programIdIndex: t.number, + accountKeyIndexes: t.array(t.number), + data: t.string, + }) + ), + addressLookupTables: t.array( + t.type({ + accountKey: t.string, + writableIndexes: t.array(t.number), + readonlyIndexes: t.array(t.number), + }) + ), + staticAccountKeys: t.array(t.string), + messageHeader: t.type({ + numRequiredSignatures: t.number, + numReadonlySignedAccounts: t.number, + numReadonlyUnsignedAccounts: t.number, + }), + recentBlockhash: t.string, + }) + ), + /** Aptos custom transaction parameters for entry function calls */ + aptosCustomTransactionParams: optional( + t.intersection([ + t.type({ + moduleName: t.string, + functionName: t.string, + }), + t.partial({ + typeArguments: t.array(t.string), + functionArguments: t.array(t.any), + abi: t.any, + }), + ]) + ), + /** Transaction request ID */ + txRequestId: optional(t.string), + /** If true, marks as test transaction */ + isTestTransaction: optional(t.boolean), + + /** Private key for signing (from WalletSignBaseOptions) */ + prv: optional(t.string), + /** Array of public keys */ + pubs: optional(t.array(t.string)), + /** Cosigner public key */ + cosignerPub: optional(t.string), + /** If true, this is the last signature */ + isLastSignature: optional(t.boolean), + + /** Transaction prebuild object (from WalletSignTransactionOptions) */ + txPrebuild: optional(t.any), + /** Multisig type version */ + multisigTypeVersion: optional(t.literal('MPCv2')), + /** Transaction verification parameters */ + verifyTxParams: optional(t.any), +} as const; + +/** + * Response for consolidate account operation + * Returns arrays of successful and failed consolidation transactions + */ +export const ConsolidateAccountResponse = t.type({ + /** Array of successfully sent consolidation transactions */ + success: t.array(t.unknown), + /** Array of errors from failed consolidation transactions */ + failure: t.array(t.unknown), +}); + +/** + * Response for partial success or failure cases (202/400) + * Includes both the transaction results and error metadata + */ +export const ConsolidateAccountErrorResponse = t.intersection([ConsolidateAccountResponse, BitgoExpressError]); + +/** + * Consolidate Account Balances + * + * This endpoint consolidates account balances by moving funds from receive addresses + * to the root wallet address. This is useful for account-based coins where balances + * are spread across multiple addresses and need to be consolidated for spending. + * + * Supported coins: Algorand (algo), Solana (sol), Tezos (xtz), Tron (trx), Stellar (xlm), etc. + * + * The API may return partial success (status 202) if some consolidations succeed but others fail. + * + * @operationId express.v2.wallet.consolidateaccount + * @tag express + */ +export const PostConsolidateAccount = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/consolidateAccount', + method: 'POST', + request: httpRequest({ + params: ConsolidateAccountParams, + body: ConsolidateAccountRequestBody, + }), + response: { + /** Successfully consolidated accounts */ + 200: ConsolidateAccountResponse, + /** Partial success - some succeeded, others failed (includes error metadata) */ + 202: ConsolidateAccountErrorResponse, + /** All consolidations failed (includes error metadata) */ + 400: ConsolidateAccountErrorResponse, + }, +}); diff --git a/modules/express/test/unit/clientRoutes/consolidateAccount.ts b/modules/express/test/unit/clientRoutes/consolidateAccount.ts index 2ef7553f75..df3278a509 100644 --- a/modules/express/test/unit/clientRoutes/consolidateAccount.ts +++ b/modules/express/test/unit/clientRoutes/consolidateAccount.ts @@ -17,7 +17,7 @@ describe('Consolidate account', () => { const mockRequest = { bitgo: stubBitgo, - params: { + decoded: { coin: 'tbtc', id: '23423423423423', }, @@ -37,7 +37,7 @@ describe('Consolidate account', () => { const mockRequest = { bitgo: bitgoStub, - params: { + decoded: { coin: 'txtz', id: '23423423423423', }, @@ -57,7 +57,7 @@ describe('Consolidate account', () => { const mockRequest = { bitgo: stubBitgo, - params: { + decoded: { coin: 'talgo', id: '23423423423423', }, @@ -91,8 +91,9 @@ describe('Consolidate account', () => { const { bitgoStub, consolidationStub } = createConsolidateMocks(result, true); const mockRequest = { bitgo: bitgoStub, - params: { + decoded: { coin: 'talgo', + id: '23423423423423', }, body, }; @@ -110,7 +111,7 @@ describe('Consolidate account', () => { const { bitgoStub, consolidationStub } = createConsolidateMocks(result, true, true); const mockRequest = { bitgo: bitgoStub, - params: { + decoded: { coin: 'tsol', id: '23423423423423', }, @@ -129,8 +130,9 @@ describe('Consolidate account', () => { const { bitgoStub, consolidationStub } = createConsolidateMocks(result, true); const mockRequest = { bitgo: bitgoStub, - params: { + decoded: { coin: 'talgo', + id: '23423423423423', }, body, }; @@ -148,8 +150,9 @@ describe('Consolidate account', () => { const { bitgoStub, consolidationStub } = createConsolidateMocks(result, true); const mockRequest = { bitgo: bitgoStub, - params: { + decoded: { coin: 'talgo', + id: '23423423423423', }, body, }; diff --git a/modules/express/test/unit/typedRoutes/consolidateAccount.ts b/modules/express/test/unit/typedRoutes/consolidateAccount.ts new file mode 100644 index 0000000000..a78ef29a93 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/consolidateAccount.ts @@ -0,0 +1,817 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + ConsolidateAccountParams, + ConsolidateAccountRequestBody, + ConsolidateAccountResponse, + ConsolidateAccountErrorResponse, + PostConsolidateAccount, +} from '../../../src/typedRoutes/api/v2/consolidateAccount'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('Consolidate Account API Tests', function () { + describe('Codec Validation Tests', function () { + describe('ConsolidateAccountParams', function () { + it('should validate valid params', function () { + const validParams = { + coin: 'algo', + id: '68c02f96aa757d9212bd1a536f123456', + }; + + const decoded = assertDecode(t.type(ConsolidateAccountParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing coin', function () { + const invalidParams = { + id: '68c02f96aa757d9212bd1a536f123456', + }; + + assert.throws(() => { + assertDecode(t.type(ConsolidateAccountParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'algo', + }; + + assert.throws(() => { + assertDecode(t.type(ConsolidateAccountParams), invalidParams); + }); + }); + }); + + describe('ConsolidateAccountRequestBody', function () { + it('should validate empty body', function () { + const decoded = assertDecode(t.type(ConsolidateAccountRequestBody), {}); + assert.strictEqual(decoded.consolidateAddresses, undefined); + assert.strictEqual(decoded.walletPassphrase, undefined); + }); + + it('should validate body with consolidateAddresses', function () { + const validBody = { + consolidateAddresses: ['ADDR1ABC', 'ADDR2DEF'], + walletPassphrase: 'test_passphrase', + }; + + const decoded = assertDecode(t.type(ConsolidateAccountRequestBody), validBody); + assert.ok(Array.isArray(decoded.consolidateAddresses)); + assert.strictEqual(decoded.consolidateAddresses?.length, 2); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + }); + + it('should validate body with all common fields', function () { + const validBody = { + consolidateAddresses: ['ADDR1'], + walletPassphrase: 'test_passphrase', + xprv: 'xprv123', + otp: '123456', + sequenceId: 'seq-123', + comment: 'Test consolidation', + nonce: '5', + preview: true, + }; + + const decoded = assertDecode(t.type(ConsolidateAccountRequestBody), validBody); + assert.strictEqual(decoded.consolidateAddresses?.[0], 'ADDR1'); + assert.strictEqual(decoded.sequenceId, validBody.sequenceId); + assert.strictEqual(decoded.comment, validBody.comment); + }); + + it('should reject invalid types', function () { + assert.throws(() => assertDecode(t.type(ConsolidateAccountRequestBody), { walletPassphrase: 123 })); + assert.throws(() => assertDecode(t.type(ConsolidateAccountRequestBody), { consolidateAddresses: 'string' })); + assert.throws(() => assertDecode(t.type(ConsolidateAccountRequestBody), { minConfirms: '2' })); + assert.throws(() => assertDecode(t.type(ConsolidateAccountRequestBody), { preview: 'true' })); + }); + }); + + describe('ConsolidateAccountResponse', function () { + it('should validate success response', function () { + const validResponse = { + success: [{ txid: 'tx123', status: 'signed' }], + failure: [], + }; + + const decoded = assertDecode(ConsolidateAccountResponse, validResponse); + assert.ok(Array.isArray(decoded.success)); + assert.ok(Array.isArray(decoded.failure)); + assert.strictEqual(decoded.success.length, 1); + assert.strictEqual(decoded.failure.length, 0); + }); + + it('should validate partial success response', function () { + const validResponse = { + success: [{ txid: 'tx123' }], + failure: [{ message: 'Error' }], + }; + + const decoded = assertDecode(ConsolidateAccountResponse, validResponse); + assert.strictEqual(decoded.success.length, 1); + assert.strictEqual(decoded.failure.length, 1); + }); + }); + + describe('ConsolidateAccountErrorResponse', function () { + it('should validate error response with all fields', function () { + const validResponse = { + success: [], + failure: [{ message: 'Insufficient funds' }], + message: 'All transactions failed', + name: 'ApiResponseError', + bitgoJsVersion: '38.0.0', + bitgoExpressVersion: '10.0.0', + }; + + const decoded = assertDecode(ConsolidateAccountErrorResponse, validResponse); + assert.strictEqual(decoded.message, validResponse.message); + assert.strictEqual(decoded.name, validResponse.name); + }); + }); + + describe('Route Definition', function () { + it('should have correct path and method', function () { + assert.strictEqual(PostConsolidateAccount.path, '/api/v2/{coin}/wallet/{id}/consolidateAccount'); + assert.strictEqual(PostConsolidateAccount.method, 'POST'); + }); + + it('should have correct response types', function () { + assert.ok(PostConsolidateAccount.response[200]); + assert.ok(PostConsolidateAccount.response[202]); + assert.ok(PostConsolidateAccount.response[400]); + }); + }); + }); + + describe('Integration Tests', function () { + const agent = setupAgent(); + const coin = 'algo'; + const walletId = '68c02f96aa757d9212bd1a536f123456'; + + const mockSuccessResponse = { + success: [ + { + txid: 'consolidation-tx-1', + hash: 'hash123', + status: 'signed', + walletId: walletId, + }, + ], + failure: [], + }; + + afterEach(function () { + sinon.restore(); + }); + + describe('Success Cases', function () { + it('should successfully consolidate account with walletPassphrase', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('success'); + result.body.should.have.property('failure'); + assert.ok(Array.isArray(result.body.success)); + assert.strictEqual(result.body.success.length, 1); + assert.strictEqual(result.body.failure.length, 0); + + sinon.assert.calledOnce(mockWallet.sendAccountConsolidations); + }); + + it('should successfully consolidate with consolidateAddresses', async function () { + const requestBody = { + consolidateAddresses: ['ADDR1ABC', 'ADDR2DEF'], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.success.length, 1); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.ok(Array.isArray(callArgs.consolidateAddresses)); + assert.strictEqual(callArgs.consolidateAddresses.length, 2); + }); + + it('should successfully consolidate with xprv', async function () { + const requestBody = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.success.length, 1); + }); + + it('should successfully consolidate with optional parameters', async function () { + const requestBody = { + consolidateAddresses: ['ADDR1'], + walletPassphrase: 'test_passphrase', + sequenceId: 'seq-123', + comment: 'Test consolidation', + minValue: 10000, + maxValue: 50000, + nonce: '5', + preview: false, + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.strictEqual(callArgs.sequenceId, requestBody.sequenceId); + assert.strictEqual(callArgs.comment, requestBody.comment); + assert.strictEqual(callArgs.minValue, requestBody.minValue); + assert.strictEqual(callArgs.nonce, requestBody.nonce); + }); + + it('should handle multiple successful consolidations', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockMultipleSuccess = { + success: [ + { txid: 'tx1', status: 'signed' }, + { txid: 'tx2', status: 'signed' }, + { txid: 'tx3', status: 'signed' }, + ], + failure: [], + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockMultipleSuccess), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.success.length, 3); + assert.strictEqual(result.body.failure.length, 0); + }); + }); + + describe('Partial Success Cases (Status 202)', function () { + it('should return 202 when some consolidations succeed and some fail', async function () { + const requestBody = { + consolidateAddresses: ['ADDR1', 'ADDR2', 'ADDR3'], + walletPassphrase: 'test_passphrase', + }; + + const mockPartialSuccess = { + success: [{ txid: 'tx1', status: 'signed' }], + failure: [{ message: 'Insufficient funds' }, { message: 'Invalid address' }], + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockPartialSuccess), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 202); + result.body.should.have.property('success'); + result.body.should.have.property('failure'); + result.body.should.have.property('message'); + assert.strictEqual(result.body.success.length, 1); + assert.strictEqual(result.body.failure.length, 2); + assert.ok(result.body.message.includes('Transactions failed:')); + assert.ok(result.body.message.includes('succeeded:')); + }); + }); + + describe('Complete Failure Cases (Status 400)', function () { + it('should return 400 when all consolidations fail', async function () { + const requestBody = { + consolidateAddresses: ['ADDR1', 'ADDR2'], + walletPassphrase: 'test_passphrase', + }; + + const mockAllFailed = { + success: [], + failure: [{ message: 'Insufficient funds' }, { message: 'Invalid address' }], + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockAllFailed), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.have.property('success'); + result.body.should.have.property('failure'); + result.body.should.have.property('message'); + assert.strictEqual(result.body.success.length, 0); + assert.strictEqual(result.body.failure.length, 2); + assert.strictEqual(result.body.message, 'All transactions failed'); + }); + }); + + describe('Error Handling Tests', function () { + it('should reject unsupported coin', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(false), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/btc/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + assert.ok(result.body.error.includes('invalid coin selected')); + }); + + it('should handle wallet not found error', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().rejects(new Error('Wallet not found')), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle sendAccountConsolidations failure', async function () { + const requestBody = { + walletPassphrase: 'wrong_passphrase', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().rejects(new Error('Invalid passphrase')), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.have.property('error'); + }); + + it('should handle insufficient funds error', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().rejects(new Error('Insufficient funds')), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.have.property('error'); + }); + + it('should handle invalid request body types', async function () { + const requestBody = { + walletPassphrase: 12345, // Number instead of string + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle missing authorization', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + }); + + describe('TSS Wallet Tests', function () { + it('should handle TSS wallet consolidation', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + _wallet: { + multisigType: 'tss', + multisigTypeVersion: 'MPCv2', + }, + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('eddsa'), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.success.length, 1); + + sinon.assert.calledOnce(mockWallet.sendAccountConsolidations); + }); + }); + + describe('Coin-Specific Parameter Tests', function () { + it('should handle Algorand-specific parameters', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + closeRemainderTo: 'CLOSEADDR123', + nonParticipation: true, + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/algo/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.strictEqual(callArgs.closeRemainderTo, requestBody.closeRemainderTo); + assert.strictEqual(callArgs.nonParticipation, requestBody.nonParticipation); + }); + + it('should handle Stellar/XRP ledger sequence parameters', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + lastLedgerSequence: 12345678, + ledgerSequenceDelta: 100, + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/xlm/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.strictEqual(callArgs.lastLedgerSequence, requestBody.lastLedgerSequence); + assert.strictEqual(callArgs.ledgerSequenceDelta, requestBody.ledgerSequenceDelta); + }); + + it('should handle memo parameter', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + memo: { + value: 'Consolidation from receive addresses', + type: 'text', + }, + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/algo/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.ok(callArgs.memo); + assert.strictEqual(callArgs.memo.value, requestBody.memo.value); + }); + }); + + describe('Edge Cases', function () { + it('should handle empty consolidateAddresses array', async function () { + const requestBody = { + consolidateAddresses: [], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + }); + + it('should handle minValue and maxValue as strings', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + minValue: '10000', + maxValue: '50000', + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.strictEqual(callArgs.minValue, '10000'); + assert.strictEqual(callArgs.maxValue, '50000'); + }); + + it('should handle preview mode', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + preview: true, + }; + + const mockWallet = { + sendAccountConsolidations: sinon.stub().resolves(mockSuccessResponse), + }; + + const mockCoin = { + allowsAccountConsolidations: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + wallets: sinon.stub().returns({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + const callArgs = mockWallet.sendAccountConsolidations.firstCall.args[0]; + assert.strictEqual(callArgs.preview, true); + }); + }); + }); +});