diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index a7a2db6364..0c02ae891e 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -830,10 +830,10 @@ async function handleV2FanOutUnspents(req: ExpressApiRouteRequest<'express.v2.wa * handle wallet sweep * @param req */ -async function handleV2Sweep(req: express.Request) { +async function handleV2Sweep(req: ExpressApiRouteRequest<'express.v2.wallet.sweep', 'post'>) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); - const wallet = await coin.wallets().get({ id: req.params.id }); + const coin = bitgo.coin(req.decoded.coin); + const wallet = await coin.wallets().get({ id: req.decoded.id }); return wallet.sweep(createSendParams(req)); } @@ -1667,7 +1667,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ]); router.post('express.v2.wallet.fanoutunspents', [prepareBitGo(config), typedPromiseWrapper(handleV2FanOutUnspents)]); - app.post('/api/v2/:coin/wallet/:id/sweep', parseBody, prepareBitGo(config), promiseWrapper(handleV2Sweep)); + router.post('express.v2.wallet.sweep', [prepareBitGo(config), typedPromiseWrapper(handleV2Sweep)]); // CPFP app.post( diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 3a77e05aff..cc8d9e31ad 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -46,6 +46,7 @@ import { PostLightningWalletWithdraw } from './v2/lightningWithdraw'; import { PutV2PendingApproval } from './v2/pendingApproval'; import { PostConsolidateAccount } from './v2/consolidateAccount'; import { PostCanonicalAddress } from './v2/canonicalAddress'; +import { PostWalletSweep } from './v2/walletSweep'; // Too large types can cause the following error // @@ -290,6 +291,12 @@ export const ExpressV2CanonicalAddressApiSpec = apiSpec({ }, }); +export const ExpressV2WalletSweepApiSpec = apiSpec({ + 'express.v2.wallet.sweep': { + post: PostWalletSweep, + }, +}); + export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressPingExpressApiSpec & typeof ExpressLoginApiSpec & @@ -324,6 +331,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressExternalSigningApiSpec & typeof ExpressWalletSigningApiSpec & typeof ExpressV2CanonicalAddressApiSpec & + typeof ExpressV2WalletSweepApiSpec & typeof ExpressWalletManagementApiSpec; export const ExpressApi: ExpressApi = { @@ -361,6 +369,7 @@ export const ExpressApi: ExpressApi = { ...ExpressExternalSigningApiSpec, ...ExpressWalletSigningApiSpec, ...ExpressV2CanonicalAddressApiSpec, + ...ExpressV2WalletSweepApiSpec, ...ExpressWalletManagementApiSpec, }; diff --git a/modules/express/src/typedRoutes/api/v2/walletSweep.ts b/modules/express/src/typedRoutes/api/v2/walletSweep.ts new file mode 100644 index 0000000000..53687f8347 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/walletSweep.ts @@ -0,0 +1,102 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { SendManyResponse } from './sendmany'; + +/** + * Request path parameters for sweeping a wallet + */ +export const WalletSweepParams = { + /** The coin type */ + coin: t.string, + /** The wallet ID */ + id: t.string, +} as const; + +/** + * Request body for sweeping all funds from a wallet + * + * The sweep operation sends all available funds from the wallet to a specified address. + * For UTXO coins, it uses the native /sweepWallet endpoint. + * For account-based coins, it calculates the maximum spendable amount and uses sendMany. + */ +export const WalletSweepBody = { + /** The destination address to send all funds to - REQUIRED */ + address: t.string, + + /** The wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + + /** The extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + + /** One-time password for 2FA */ + otp: optional(t.string), + + /** The desired fee rate for the transaction in satoshis/kB (UTXO coins) */ + feeRate: optional(t.number), + + /** Upper limit for fee rate in satoshis/kB (UTXO coins) */ + maxFeeRate: optional(t.number), + + /** Estimate fees to aim for confirmation within this number of blocks (UTXO coins) */ + feeTxConfirmTarget: optional(t.number), + + /** Allows sweeping 200 unspents when wallet has more than that (UTXO coins) */ + allowPartialSweep: optional(t.boolean), + + /** Transaction format: 'legacy', 'psbt', or 'psbt-lite' (UTXO coins) */ + txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])), +} as const; + +/** + * Sweep all funds from a wallet to a specified address + * + * This endpoint sweeps (sends) all available funds from a wallet to a single destination address. + * + * **Behavior by coin type:** + * - **UTXO coins (BTC, LTC, etc.)**: Uses the native /sweepWallet endpoint that: + * - Collects all unspents in the wallet + * - Builds a transaction sending everything (minus fees) to the destination + * - Signs and broadcasts the transaction + * - Validates that all funds go to the specified destination address + * + * - **Account-based coins (ETH, etc.)**: + * - Checks for unconfirmed funds (fails if any exist) + * - Queries the maximumSpendable amount + * - Creates a sendMany transaction with that amount to the destination + * + * **Implementation Note:** + * Both execution paths (UTXO and account-based) ultimately call the same underlying + * transaction sending mechanisms as sendMany, resulting in identical response structures. + * + * **Authentication:** + * - Requires either `walletPassphrase` (to decrypt the encrypted user key) or `xprv` (raw private key) + * - Optional `otp` for 2FA + * + * **Fee control (UTXO coins):** + * - `feeRate`: Desired fee rate in satoshis/kB + * - `maxFeeRate`: Upper limit for fee rate + * - `feeTxConfirmTarget`: Target number of blocks for confirmation + * + * **Special options:** + * - `allowPartialSweep`: For UTXO wallets with >200 unspents, allows sweeping just 200 + * - `txFormat`: Choose between 'legacy', 'psbt', or 'psbt-lite' format + * + * @tag express + * @operationId express.v2.wallet.sweep + */ +export const PostWalletSweep = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/sweep', + method: 'POST', + request: httpRequest({ + params: WalletSweepParams, + body: WalletSweepBody, + }), + response: { + /** Successfully swept funds - same structure as sendMany */ + 200: SendManyResponse, + /** Invalid request or sweep operation fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/walletSweep.ts b/modules/express/test/unit/typedRoutes/walletSweep.ts new file mode 100644 index 0000000000..455cfdcb74 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/walletSweep.ts @@ -0,0 +1,1161 @@ +import * as assert from 'assert'; +import { SendManyResponse } from '../../../src/typedRoutes/api/v2/sendmany'; +import { WalletSweepParams, WalletSweepBody } from '../../../src/typedRoutes/api/v2/walletSweep'; +import { assertDecode } from './common'; +import * as t from 'io-ts'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('Wallet Sweep V2 codec tests', function () { + // Helper to create a valid Transfer object for testing + function createMockTransfer(overrides: any = {}): any { + return { + coin: 'tbtc', + id: 'transfer-123', + wallet: 'wallet-456', + txid: 'txid-789', + height: 700000, + date: new Date().toISOString(), + confirmations: 6, + type: 'send', + valueString: '10000000', + state: 'confirmed', + history: [{ action: 'created', date: new Date().toISOString() }], + ...overrides, + }; + } + + // Helper to assert response structure + function assertSweepResponse(response: any) { + assert.ok(!Array.isArray(response), 'Expected single transaction response, got array'); + return response; + } + + describe('walletSweep v2', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + const mockSweepResponse = { + status: 'signed', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180969800000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully sweep wallet funds to destination address', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet with sweep method + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + // Stub the wallets().get() chain + const walletsGetStub = sinon.stub().resolves(mockWallet); + + // Create mock coin object + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + // For V2, bitgo.coin() is called with the coin parameter + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + // Make the request to Express + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('status'); + result.body.should.have.property('tx'); + result.body.should.have.property('txid'); + assert.strictEqual(result.body.status, mockSweepResponse.status); + assert.strictEqual(result.body.tx, mockSweepResponse.tx); + assert.strictEqual(result.body.txid, mockSweepResponse.txid); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + assert.strictEqual(decodedResponse.status, mockSweepResponse.status); + assert.strictEqual(decodedResponse.tx, mockSweepResponse.tx); + assert.strictEqual(decodedResponse.txid, mockSweepResponse.txid); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(walletsGetStub.calledOnce, true); + assert.strictEqual(mockWallet.sweep.calledOnce, true); + + // Verify the sweep was called with correct parameters + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.walletPassphrase, requestBody.walletPassphrase); + }); + + it('should successfully sweep with UTXO fee parameters', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + maxFeeRate: 100000, + feeTxConfirmTarget: 3, + }; + + // Create mock wallet with sweep method + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + // Stub the wallets().get() chain + const walletsGetStub = sinon.stub().resolves(mockWallet); + + // Create mock coin object + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + // Make the request to Express + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + assert.strictEqual(decodedResponse.status, mockSweepResponse.status); + + // Verify that sweep was called with the correct UTXO fee parameters + assert.strictEqual(mockWallet.sweep.calledOnce, true); + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.feeRate, 50000); + assert.strictEqual(callArgs.maxFeeRate, 100000); + assert.strictEqual(callArgs.feeTxConfirmTarget, 3); + }); + + it('should successfully sweep with allowPartialSweep option', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + xprv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi', + allowPartialSweep: true, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify allowPartialSweep and xprv were passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.xprv, requestBody.xprv); + assert.strictEqual(callArgs.allowPartialSweep, true); + }); + + it('should successfully sweep with txFormat parameter', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + txFormat: 'psbt' as const, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify txFormat was passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.txFormat, 'psbt'); + }); + + it('should successfully sweep with OTP for 2FA', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + otp: '0000000', + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify OTP was passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.otp, '0000000'); + }); + + it('should handle sweep response with transfer details', async function () { + const transfer = createMockTransfer({ + id: 'transfer-sweep-123', + type: 'send', + value: 10000000, + valueString: '10000000', + fee: 5000, + feeString: '5000', + }); + + const mockSweepResponseWithTransfer = { + status: 'signed', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180969800000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + transfer: transfer, + }; + + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponseWithTransfer), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify transfer object is present and valid + assert.ok(decodedResponse.transfer, 'Response should include transfer object'); + assert.strictEqual(decodedResponse.transfer.id, 'transfer-sweep-123'); + assert.strictEqual(decodedResponse.transfer.type, 'send'); + assert.strictEqual(decodedResponse.transfer.valueString, '10000000'); + }); + + it('should handle sweep response with transfers array (main + fee transfer)', async function () { + const mainTransfer = createMockTransfer({ + id: 'transfer-main-123', + type: 'send', + value: 10000000, + valueString: '10000000', + fee: 5000, + feeString: '5000', + }); + + const feeTransfer = createMockTransfer({ + id: 'transfer-fee-456', + type: 'fee', + value: 0, + valueString: '0', + fee: 5000, + feeString: '5000', + }); + + const mockSweepResponseWithTransfers = { + status: 'signed', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180969800000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + transfer: mainTransfer, + transfers: [mainTransfer, feeTransfer], + }; + + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponseWithTransfers), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify transfers array is present + assert.ok(decodedResponse.transfers, 'Response should include transfers array'); + assert.strictEqual(Array.isArray(decodedResponse.transfers), true, 'transfers should be an array'); + assert.strictEqual(decodedResponse.transfers.length, 2, 'transfers should contain main + fee transfer'); + + // Verify main transfer + assert.strictEqual(decodedResponse.transfers[0].id, 'transfer-main-123'); + assert.strictEqual(decodedResponse.transfers[0].type, 'send'); + + // Verify fee transfer + assert.strictEqual(decodedResponse.transfers[1].id, 'transfer-fee-456'); + assert.strictEqual(decodedResponse.transfers[1].type, 'fee'); + }); + + it('should sweep with all UTXO parameters combined', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + maxFeeRate: 100000, + feeTxConfirmTarget: 3, + allowPartialSweep: true, + txFormat: 'psbt-lite' as const, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify all UTXO parameters were passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.feeRate, 50000); + assert.strictEqual(callArgs.maxFeeRate, 100000); + assert.strictEqual(callArgs.feeTxConfirmTarget, 3); + assert.strictEqual(callArgs.allowPartialSweep, true); + assert.strictEqual(callArgs.txFormat, 'psbt-lite'); + }); + + it('should handle error response (500)', async function () { + const requestBody = { + address: 'invalid-address', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().rejects(new Error('Invalid address')), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/{walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Handler has no try-catch, so errors return 500 + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should use xprv as alternative to walletPassphrase', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + xprv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi', + feeRate: 25000, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify xprv was passed instead of walletPassphrase + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.xprv, requestBody.xprv); + assert.strictEqual(callArgs.walletPassphrase, undefined); + assert.strictEqual(callArgs.feeRate, 25000); + }); + + it('should validate txFormat accepts legacy format', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + txFormat: 'legacy' as const, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify txFormat was passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.txFormat, 'legacy'); + }); + + it('should validate txFormat accepts psbt-lite format', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + txFormat: 'psbt-lite' as const, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(mockSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify txFormat was passed correctly + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.txFormat, 'psbt-lite'); + }); + }); + + describe('Request Validation', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + afterEach(function () { + sinon.restore(); + }); + + it('should require address field', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase_12345', + // Missing address field + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should fail validation + assert.strictEqual(result.status, 400); + }); + + it('should accept address as string', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().resolves({ status: 'signed', txid: 'abc123', tx: '0100' }), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(typeof callArgs.address, 'string'); + }); + + it('should accept feeRate as number', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + }; + + const mockWallet = { + sweep: sinon.stub().resolves({ status: 'signed', txid: 'abc123', tx: '0100' }), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(typeof callArgs.feeRate, 'number'); + assert.strictEqual(callArgs.feeRate, 50000); + }); + + it('should accept allowPartialSweep as boolean', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + allowPartialSweep: true, + }; + + const mockWallet = { + sweep: sinon.stub().resolves({ status: 'signed', txid: 'abc123', tx: '0100' }), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(typeof callArgs.allowPartialSweep, 'boolean'); + assert.strictEqual(callArgs.allowPartialSweep, true); + }); + + it('should reject invalid txFormat value', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + txFormat: 'invalid-format', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should fail validation due to invalid txFormat + assert.strictEqual(result.status, 400); + }); + }); + + describe('Response Codec Validation', function () { + it('should decode valid sweep response', function () { + const validResponse = { + status: 'signed', + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180969800000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.strictEqual(decoded.status, 'signed'); + assert.strictEqual(decoded.txid, validResponse.txid); + assert.strictEqual(decoded.tx, validResponse.tx); + }); + + it('should decode response with transfer object', function () { + const validResponse = { + status: 'signed', + txid: 'abcdef1234567890', + tx: '0100000001', + transfer: { + coin: 'tbtc', + id: 'transfer-123', + wallet: 'wallet-456', + txid: 'txid-789', + height: 700000, + date: new Date().toISOString(), + confirmations: 6, + type: 'send', + valueString: '10000000', + state: 'confirmed', + history: [{ action: 'created', date: new Date().toISOString() }], + }, + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.transfer); + assert.strictEqual(decoded.transfer.id, 'transfer-123'); + }); + + it('should decode response with transfers array', function () { + const mainTransfer = { + coin: 'tbtc', + id: 'transfer-main', + wallet: 'wallet-456', + txid: 'txid-789', + height: 700000, + date: new Date().toISOString(), + confirmations: 6, + type: 'send', + valueString: '10000000', + state: 'confirmed', + history: [{ action: 'created', date: new Date().toISOString() }], + }; + + const feeTransfer = { + coin: 'tbtc', + id: 'transfer-fee', + wallet: 'wallet-456', + txid: 'txid-789', + height: 700000, + date: new Date().toISOString(), + confirmations: 6, + type: 'fee', + valueString: '0', + state: 'confirmed', + history: [{ action: 'created', date: new Date().toISOString() }], + }; + + const validResponse = { + status: 'signed', + txid: 'abcdef1234567890', + tx: '0100000001', + transfer: mainTransfer, + transfers: [mainTransfer, feeTransfer], + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.transfers); + assert.strictEqual(Array.isArray(decoded.transfers), true); + assert.strictEqual(decoded.transfers.length, 2); + }); + + it('should decode minimal response', function () { + const minimalResponse = { + status: 'signed', + }; + + const decoded = assertDecode(SendManyResponse, minimalResponse); + assert.strictEqual(decoded.status, 'signed'); + }); + + it('should decode response with txRequest (TSS wallet)', function () { + const tssResponse = { + txRequest: { + txRequestId: 'txReq123', + walletId: 'wallet456', + version: 1, + state: 'signed', + date: new Date().toISOString(), + createdDate: new Date().toISOString(), + userId: 'user123', + initiatedBy: 'user123', + updatedBy: 'user123', + intents: [], + latest: true, + unsignedTxs: [], + }, + }; + + const decoded = assertDecode(SendManyResponse, tssResponse); + assert.ok(decoded.txRequest); + }); + + it('should decode response with pendingApproval', function () { + const pendingResponse = { + pendingApproval: { + id: 'pa123', + state: 'pending', + }, + }; + + const decoded = assertDecode(SendManyResponse, pendingResponse); + assert.ok(decoded.pendingApproval); + }); + }); + + describe('Request Body Codec Validation', function () { + it('should encode and decode valid request body', function () { + const validBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + maxFeeRate: 100000, + feeTxConfirmTarget: 3, + allowPartialSweep: true, + txFormat: 'psbt' as const, + otp: '0000000', + }; + + const bodyCodec = t.type(WalletSweepBody); + const decoded = assertDecode(bodyCodec, validBody); + + assert.strictEqual(decoded.address, validBody.address); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + assert.strictEqual(decoded.feeRate, validBody.feeRate); + assert.strictEqual(decoded.maxFeeRate, validBody.maxFeeRate); + assert.strictEqual(decoded.feeTxConfirmTarget, validBody.feeTxConfirmTarget); + assert.strictEqual(decoded.allowPartialSweep, validBody.allowPartialSweep); + assert.strictEqual(decoded.txFormat, validBody.txFormat); + assert.strictEqual(decoded.otp, validBody.otp); + }); + + it('should decode minimal request body with only address', function () { + const minimalBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + }; + + const bodyCodec = t.type(WalletSweepBody); + const decoded = assertDecode(bodyCodec, minimalBody); + + assert.strictEqual(decoded.address, minimalBody.address); + assert.strictEqual(decoded.walletPassphrase, undefined); + assert.strictEqual(decoded.xprv, undefined); + assert.strictEqual(decoded.otp, undefined); + }); + + it('should decode request body with xprv instead of walletPassphrase', function () { + const bodyWithXprv = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + xprv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi', + }; + + const bodyCodec = t.type(WalletSweepBody); + const decoded = assertDecode(bodyCodec, bodyWithXprv); + + assert.strictEqual(decoded.address, bodyWithXprv.address); + assert.strictEqual(decoded.xprv, bodyWithXprv.xprv); + assert.strictEqual(decoded.walletPassphrase, undefined); + }); + }); + + describe('Request Params Codec Validation', function () { + it('should encode and decode valid request params', function () { + const validParams = { + coin: 'tbtc', + id: '68c02f96aa757d9212bd1a536f123456', + }; + + const paramsCodec = t.type(WalletSweepParams); + const decoded = assertDecode(paramsCodec, validParams); + + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should validate coin parameter is string', function () { + const validParams = { + coin: 'eth', + id: '68c02f96aa757d9212bd1a536f123456', + }; + + const paramsCodec = t.type(WalletSweepParams); + const decoded = assertDecode(paramsCodec, validParams); + + assert.strictEqual(typeof decoded.coin, 'string'); + assert.strictEqual(decoded.coin, 'eth'); + }); + + it('should validate id parameter is string', function () { + const validParams = { + coin: 'tbtc', + id: 'abcd1234', + }; + + const paramsCodec = t.type(WalletSweepParams); + const decoded = assertDecode(paramsCodec, validParams); + + assert.strictEqual(typeof decoded.id, 'string'); + assert.strictEqual(decoded.id, 'abcd1234'); + }); + }); + + describe('Handler Logic Tests', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + afterEach(function () { + sinon.restore(); + }); + + it('should call wallet.sweep with correct params', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + }; + + const mockWallet = { + sweep: sinon.stub().resolves({ status: 'signed', txid: 'abc123', tx: '0100' }), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify wallet.sweep was called + assert.strictEqual(mockWallet.sweep.calledOnce, true); + + // Verify correct parameters were passed + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.deepStrictEqual(callArgs, requestBody); + }); + + it('should handle sweep error and return 500', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().rejects(new Error('Insufficient funds')), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Handler has no try-catch, so errors return 500 + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + + // Verify wallet.sweep was called + assert.strictEqual(mockWallet.sweep.calledOnce, true); + }); + + it('should return 200 for successful sweep', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sweep: sinon.stub().resolves({ + status: 'signed', + txid: 'abcdef1234567890', + tx: '0100000001', + }), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Handler always returns 200 for success (no 202 logic) + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.status, 'signed'); + }); + + it('should handle wallet.get() error', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Wallet not found')); + + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Handler has no try-catch, so errors return 500 + assert.strictEqual(result.status, 500); + }); + }); + + describe('Complete Request/Response Flow', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + afterEach(function () { + sinon.restore(); + }); + + it('should handle complete sweep flow with all parameters', async function () { + const requestBody = { + address: '2N5mbsEex9Kct2xTMvosgTGFkcBCdvFgF6h', + walletPassphrase: 'test_passphrase_12345', + otp: '0000000', + feeRate: 50000, + maxFeeRate: 100000, + feeTxConfirmTarget: 3, + allowPartialSweep: true, + txFormat: 'psbt' as const, + }; + + const transfer = createMockTransfer({ + id: 'sweep-transfer-123', + value: 10000000, + valueString: '10000000', + fee: 5000, + feeString: '5000', + }); + + const completeSweepResponse = { + status: 'signed', + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180969800000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + transfer: transfer, + }; + + const mockWallet = { + sweep: sinon.stub().resolves(completeSweepResponse), + _wallet: { multisigType: 'onchain' }, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ + get: walletsGetStub, + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sweep`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSweepResponse(decodedResponse); + + // Verify response structure + assert.strictEqual(decodedResponse.status, 'signed'); + assert.strictEqual(decodedResponse.txid, completeSweepResponse.txid); + assert.strictEqual(decodedResponse.tx, completeSweepResponse.tx); + assert.ok(decodedResponse.transfer); + assert.strictEqual(decodedResponse.transfer.id, 'sweep-transfer-123'); + + // Verify all parameters were passed to wallet.sweep + const callArgs = mockWallet.sweep.firstCall.args[0]; + assert.strictEqual(callArgs.address, requestBody.address); + assert.strictEqual(callArgs.walletPassphrase, requestBody.walletPassphrase); + assert.strictEqual(callArgs.otp, requestBody.otp); + assert.strictEqual(callArgs.feeRate, requestBody.feeRate); + assert.strictEqual(callArgs.maxFeeRate, requestBody.maxFeeRate); + assert.strictEqual(callArgs.feeTxConfirmTarget, requestBody.feeTxConfirmTarget); + assert.strictEqual(callArgs.allowPartialSweep, requestBody.allowPartialSweep); + assert.strictEqual(callArgs.txFormat, requestBody.txFormat); + }); + }); +});