diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 698538d946..bd364fdd2f 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -935,11 +935,11 @@ async function handleV2SendOne(req: express.Request) { * handle send many * @param req */ -async function handleV2SendMany(req: express.Request) { +async function handleV2SendMany(req: ExpressApiRouteRequest<'express.v2.wallet.sendmany', 'post'>) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); + const coin = bitgo.coin(req.decoded.coin); const reqId = new RequestTracer(); - const wallet = await coin.wallets().get({ id: req.params.id, reqId }); + const wallet = await coin.wallets().get({ id: req.decoded.id, reqId }); req.body.reqId = reqId; let result; try { @@ -1639,7 +1639,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // send transaction app.post('/api/v2/:coin/wallet/:id/sendcoins', parseBody, prepareBitGo(config), promiseWrapper(handleV2SendOne)); - app.post('/api/v2/:coin/wallet/:id/sendmany', parseBody, prepareBitGo(config), promiseWrapper(handleV2SendMany)); + router.post('express.v2.wallet.sendmany', [prepareBitGo(config), typedPromiseWrapper(handleV2SendMany)]); app.post( '/api/v2/:coin/wallet/:id/prebuildAndSignTransaction', parseBody, diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 444b29e610..fc8714174e 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -34,6 +34,7 @@ import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; import { PostShareWallet } from './v2/shareWallet'; import { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; import { PostFanoutUnspents } from './v2/fanoutUnspents'; +import { PostSendMany } from './v2/sendmany'; import { PostConsolidateUnspents } from './v2/consolidateunspents'; import { PostCoinSign } from './v2/coinSign'; @@ -157,6 +158,12 @@ export const ExpressV2WalletCreateAddressApiSpec = apiSpec({ }, }); +export const ExpressV2WalletSendManyApiSpec = apiSpec({ + 'express.v2.wallet.sendmany': { + post: PostSendMany, + }, +}); + export const ExpressKeychainLocalApiSpec = apiSpec({ 'express.keychain.local': { post: PostKeychainLocal, @@ -252,6 +259,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressLightningGetStateApiSpec & typeof ExpressLightningInitWalletApiSpec & typeof ExpressLightningUnlockWalletApiSpec & + typeof ExpressV2WalletSendManyApiSpec & typeof ExpressOfcSignPayloadApiSpec & typeof ExpressWalletRecoverTokenApiSpec & typeof ExpressCoinSigningApiSpec & @@ -282,6 +290,7 @@ export const ExpressApi: ExpressApi = { ...ExpressLightningGetStateApiSpec, ...ExpressLightningInitWalletApiSpec, ...ExpressLightningUnlockWalletApiSpec, + ...ExpressV2WalletSendManyApiSpec, ...ExpressOfcSignPayloadApiSpec, ...ExpressWalletRecoverTokenApiSpec, ...ExpressCoinSigningApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/sendmany.ts b/modules/express/src/typedRoutes/api/v2/sendmany.ts new file mode 100644 index 0000000000..4ae3e948e1 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -0,0 +1,475 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Request parameters for sending to multiple recipients (v2) + */ +export const SendManyRequestParams = { + /** The coin identifier (e.g., 'btc', 'tbtc', 'eth', 'teth') */ + coin: t.string, + /** The ID of the wallet */ + id: t.string, +} as const; + +/** + * EIP-1559 fee parameters for Ethereum transactions + */ +const EIP1559Params = t.partial({ + /** Maximum priority fee per gas (in wei) */ + maxPriorityFeePerGas: t.union([t.number, t.string]), + /** Maximum fee per gas (in wei) */ + maxFeePerGas: t.union([t.number, t.string]), +}); + +/** + * Memo object for chains that support memos (e.g., Stellar, XRP) + */ +const MemoParams = t.partial({ + /** Memo value */ + value: t.string, + /** Memo type */ + type: t.string, +}); + +/** + * Token transfer recipient parameters + */ +const TokenTransferRecipientParams = t.partial({ + /** Type of token (e.g., 'ERC20', 'ERC721', 'ERC1155', 'NATIVE') */ + tokenType: t.string, + /** Quantity of tokens to transfer (as string) */ + tokenQuantity: t.string, + /** Token contract address (for ERC20, ERC721, etc.) */ + tokenContractAddress: t.string, + /** Token name */ + tokenName: t.string, + /** Token ID (for NFTs - ERC721, ERC1155) */ + tokenId: t.string, + /** Decimal places for the token */ + decimalPlaces: t.number, +}); + +/** + * Recipient object for sendMany transactions + */ +const RecipientParams = t.type({ + /** Recipient address */ + address: t.string, + /** Amount to send (in base units, e.g., satoshis for BTC, wei for ETH) */ + amount: t.union([t.number, t.string]), +}); + +const RecipientParamsOptional = t.partial({ + /** Fee limit for this specific recipient (e.g., for Tron TRC20 tokens) */ + feeLimit: t.string, + /** Data field for this recipient (can be hex string or token transfer params) */ + data: t.union([t.string, TokenTransferRecipientParams]), + /** Token name for this specific recipient */ + tokenName: t.string, + /** Token data for this specific recipient */ + tokenData: TokenTransferRecipientParams, +}); + +/** + * Complete recipient object combining required and optional fields + */ +const Recipient = t.intersection([RecipientParams, RecipientParamsOptional]); + +/** + * Request body for sending to multiple recipients (v2) + * + * This endpoint supports the full set of parameters available in the BitGo SDK + * for building, signing, and sending transactions to multiple recipients. + */ +export const SendManyRequestBody = { + /** Array of recipients with addresses and amounts */ + recipients: optional(t.array(Recipient)), + + /** The wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + + /** The extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + + /** The private key (prv) in string form */ + prv: optional(t.string), + + /** Estimate fees to aim for first confirmation within this number of blocks */ + numBlocks: optional(t.number), + + /** The desired fee rate for the transaction in base units per kilobyte (e.g., satoshis/kB) */ + feeRate: optional(t.number), + + /** Fee multiplier (multiplies the estimated fee by this factor) */ + feeMultiplier: optional(t.number), + + /** The maximum limit for a fee rate in base units per kilobyte */ + maxFeeRate: optional(t.number), + + /** Minimum number of confirmations needed for an unspent to be included (defaults to 1) */ + minConfirms: optional(t.number), + + /** If true, minConfirms also applies to change outputs */ + enforceMinConfirmsForChange: optional(t.boolean), + + /** Target number of unspents to maintain in the wallet */ + targetWalletUnspents: optional(t.number), + + /** Message to attach to the transaction */ + message: optional(t.string), + + /** Minimum value of unspents to use (in base units) */ + minValue: optional(t.union([t.number, t.string])), + + /** Maximum value of unspents to use (in base units) */ + maxValue: optional(t.union([t.number, t.string])), + + /** Custom sequence ID for the transaction */ + sequenceId: optional(t.string), + + /** Absolute max ledger the transaction should be accepted in (for XRP) */ + lastLedgerSequence: optional(t.number), + + /** Relative ledger height (in relation to the current ledger) that the transaction should be accepted in */ + ledgerSequenceDelta: optional(t.number), + + /** Custom gas price to be used for sending the transaction (for account-based coins) */ + gasPrice: optional(t.number), + + /** Set to true to disable automatic change splitting for purposes of unspent management */ + noSplitChange: optional(t.boolean), + + /** Array of specific unspent IDs to use in the transaction */ + unspents: optional(t.array(t.string)), + + /** Comment to attach to the transaction */ + comment: optional(t.string), + + /** One-time password for 2FA */ + otp: optional(t.string), + + /** Specifies the destination of the change output */ + changeAddress: optional(t.string), + + /** If true, allows using an external change address */ + allowExternalChangeAddress: optional(t.boolean), + + /** Send this transaction using coin-specific instant sending method (if available) */ + instant: optional(t.boolean), + + /** Memo to use in transaction (supported by Stellar, XRP, etc.) */ + memo: optional(MemoParams), + + /** Transfer ID for tracking purposes */ + transferId: optional(t.number), + + /** EIP-1559 fee parameters for Ethereum transactions */ + eip1559: optional(EIP1559Params), + + /** Gas limit for the transaction (for account-based coins) */ + gasLimit: optional(t.number), + + /** Token name for token transfers */ + tokenName: optional(t.string), + + /** Type of transaction (e.g., 'trustline' for Stellar) */ + type: optional(t.string), + + /** Custodian transaction ID (for institutional custody integrations) */ + custodianTransactionId: optional(t.string), + + /** If true, enables hop transactions for exchanges */ + hop: optional(t.boolean), + + /** Address type for the transaction (e.g., 'p2sh', 'p2wsh') */ + addressType: optional(t.string), + + /** Change address type (e.g., 'p2sh', 'p2wsh') */ + changeAddressType: optional(t.string), + + /** Transaction format (legacy or psbt) */ + txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])), + + /** If set to false, sweep all funds including required minimums (e.g., DOT requires 1 DOT minimum) */ + keepAlive: optional(t.boolean), + + /** NFT collection ID (for NFT transfers) */ + nftCollectionId: optional(t.string), + + /** NFT ID (for NFT transfers) */ + nftId: optional(t.string), + + /** Transaction nonce (for account-based coins) */ + nonce: optional(t.string), + + /** If true, only preview the transaction without sending */ + preview: optional(t.boolean), + + /** Receive address (for specific coins like ADA) */ + receiveAddress: optional(t.string), + + /** Messages to be signed with specific addresses */ + messages: optional( + t.array( + t.type({ + address: t.string, + message: t.string, + }) + ) + ), + + /** The receive address from which funds will be withdrawn (supported for specific coins like ADA) */ + senderAddress: optional(t.string), + + /** The wallet ID of the sender wallet when different from current wallet (for BTC unstaking) */ + senderWalletId: optional(t.string), + + /** Close remainder to address (for specific blockchain protocols like Algorand) */ + closeRemainderTo: optional(t.string), + + /** Non-participation flag (for governance/staking protocols like Algorand) */ + nonParticipation: optional(t.boolean), + + /** Valid from block height */ + validFromBlock: optional(t.number), + + /** Valid to block height */ + validToBlock: optional(t.number), + + /** Reservation parameters for unspent management */ + reservation: optional( + t.partial({ + expireTime: t.string, + pendingApprovalId: t.string, + }) + ), + + /** Enable offline transaction verification */ + offlineVerification: optional(t.boolean), + + /** Wallet contract address (for smart contract wallets) */ + walletContractAddress: optional(t.string), + + /** IDF (Identity Framework) signed timestamp */ + idfSignedTimestamp: optional(t.string), + + /** IDF user ID */ + idfUserId: optional(t.string), + + /** IDF version number */ + idfVersion: optional(t.number), + + /** Array of tokens to enable on the wallet */ + enableTokens: optional(t.array(t.any)), + + /** Low fee transaction ID (for CPFP - Child Pays For Parent) */ + lowFeeTxid: optional(t.string), + + /** Flag indicating if this is a TSS transaction */ + isTss: optional(t.boolean), + + /** API version to use for the transaction */ + apiVersion: optional(t.string), + + /** 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 building transactions with 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, + }) + ), + + /** Custom transaction parameters for Aptos entry function calls */ + aptosCustomTransactionParams: optional( + t.partial({ + moduleName: t.string, + functionName: t.string, + typeArguments: t.array(t.string), + functionArguments: t.array(t.any), + abi: t.any, + }) + ), + + /** Array of public keys for signing */ + pubs: optional(t.array(t.string)), + + /** Transaction request ID (for TSS wallets) */ + txRequestId: optional(t.string), + + /** Co-signer public key */ + cosignerPub: optional(t.string), + + /** Flag indicating if this is the last signature */ + isLastSignature: optional(t.boolean), + + /** Pre-built transaction object */ + txPrebuild: optional(t.any), + + /** Multisig type version (e.g., 'MPCv2') */ + multisigTypeVersion: optional(t.literal('MPCv2')), + + /** Pre-built transaction (hex string or serialized object) */ + prebuildTx: optional(t.union([t.string, t.any])), + + /** Verification options for the transaction */ + verification: optional(t.any), +} as const; + +/** + * Transaction response object for non-TSS wallets + */ +const SendManySingleTxResponse = t.type({ + /** The status of the transaction ('accepted', 'signed', 'pendingApproval', or 'otp') */ + status: t.string, + /** The transaction hex/serialized transaction */ + tx: t.string, +}); + +const SendManySingleTxResponseOptional = t.partial({ + /** The transaction hash/ID */ + hash: t.string, + /** Alternative field for transaction ID (some responses use this instead of hash) */ + txid: t.string, + /** The fee amount in base units (satoshis for BTC, wei for ETH) */ + fee: t.number, + /** The fee rate in base units per kilobyte (satoshis/kB for BTC) */ + feeRate: t.number, + /** Whether the transaction is instant */ + instant: t.boolean, + /** The instant transaction ID (if applicable) */ + instantId: t.string, + /** Travel rule information */ + travelInfos: t.unknown, + /** BitGo fee information (if applicable) */ + bitgoFee: t.unknown, + /** Travel rule result (if applicable) */ + travelResult: t.unknown, + /** Pending approval object or ID (if transaction requires approval) */ + pendingApproval: t.unknown, +}); + +/** + * TSS transaction response object (for TSS wallets using txRequest flow) + */ +const SendManyTssResponse = t.type({ + /** Transaction request object */ + txRequest: t.unknown, + /** The status of the transaction */ + status: t.string, +}); + +const SendManyTssResponseOptional = t.partial({ + /** Transfer object */ + transfer: t.unknown, + /** Transaction ID */ + txid: t.string, + /** Transaction hex */ + tx: t.string, + /** Pending approval object (if transaction requires approval) */ + pendingApproval: t.unknown, +}); + +/** + * Complete transaction response combining required and optional fields + */ +const CompleteSendManyResponse = t.intersection([SendManySingleTxResponse, SendManySingleTxResponseOptional]); +const CompleteTssResponse = t.intersection([SendManyTssResponse, SendManyTssResponseOptional]); + +/** + * Response for sending to multiple recipients (v2) + * + * Returns transaction details after the transaction is built, signed, and sent. + * The response structure depends on whether the wallet is a TSS wallet or traditional multisig wallet. + */ +export const SendManyResponse = t.union([CompleteSendManyResponse, CompleteTssResponse]); + +/** + * Pending approval response (status 202) + * + * Returned when the transaction requires additional approval before it can be sent. + */ +export const SendManyPendingApprovalResponse = t.union([ + t.type({ + /** Status indicating pending approval */ + status: t.literal('pendingApproval'), + /** Pending approval object or ID */ + pendingApproval: t.unknown, + }), + CompleteTssResponse, +]); + +/** + * Send to multiple recipients (v2) + * + * This endpoint sends funds to multiple recipients by: + * 1. Building a transaction with the specified recipients and parameters + * 2. Signing the transaction with the user's key (decrypted with walletPassphrase or xprv) + * 3. Requesting a signature from BitGo's key + * 4. Sending the fully-signed transaction to the blockchain network + * + * The v2 API supports: + * - Multiple recipients in a single transaction + * - Full control over transaction fees (feeRate, maxFeeRate, numBlocks) + * - UTXO selection (minValue, maxValue, unspents array) + * - Instant transactions (if supported by the coin) + * - TSS wallets with txRequest flow + * - Account-based and UTXO-based coins + * - Token transfers + * - Advanced features like memo fields, hop transactions, EIP-1559 fees + * + * @operationId express.v2.wallet.sendmany + * @tag express + */ +export const PostSendMany = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/sendmany', + method: 'POST', + request: httpRequest({ + params: SendManyRequestParams, + body: SendManyRequestBody, + }), + response: { + /** Successfully sent transaction */ + 200: SendManyResponse, + /** Transaction requires approval */ + 202: SendManyPendingApprovalResponse, + /** Invalid request or send operation fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/sendmany.ts b/modules/express/test/unit/typedRoutes/sendmany.ts new file mode 100644 index 0000000000..4d3918063d --- /dev/null +++ b/modules/express/test/unit/typedRoutes/sendmany.ts @@ -0,0 +1,839 @@ +import * as assert from 'assert'; +import { SendManyResponse } from '../../../src/typedRoutes/api/v2/sendmany'; +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('SendMany V2 codec tests', function () { + // Helper to assert single transaction response + function assertSingleTxResponse(response: any) { + assert.ok(!Array.isArray(response), 'Expected single transaction response, got array'); + return response as { + status: string; + tx: string; + hash?: string; + txid?: string; + fee?: number; + feeRate?: number; + instant?: boolean; + instantId?: string; + travelInfos?: unknown; + bitgoFee?: unknown; + travelResult?: unknown; + }; + } + + describe('sendMany v2', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + const mockSendManyResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0280a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac40420f00000000001976a914a2b6d08c6f5a2b5e4d6f0a72c3e8b9f5d4c3a21188ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully send to multiple recipients', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + { + address: 'mvQewFHmFjJVr5G7K9TJWNQxB7cLGhJpJV', + amount: 500000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet with sendMany method + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .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'); + assert.strictEqual(result.body.status, mockSendManyResponse.status); + assert.strictEqual(result.body.tx, mockSendManyResponse.tx); + assert.strictEqual(result.body.hash, mockSendManyResponse.hash); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(SendManyResponse, result.body); + const singleTxResponse = assertSingleTxResponse(decodedResponse); + assert.strictEqual(singleTxResponse.status, mockSendManyResponse.status); + assert.strictEqual(singleTxResponse.tx, mockSendManyResponse.tx); + assert.strictEqual(singleTxResponse.hash, mockSendManyResponse.hash); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(walletsGetStub.calledOnce, true); + assert.strictEqual(mockWallet.sendMany.calledOnce, true); + }); + + it('should successfully send with fee parameters', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000000', + }, + ], + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + maxFeeRate: 100000, + minConfirms: 2, + }; + + // Create mock wallet with sendMany method + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .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); + const singleTxResponse = assertSingleTxResponse(decodedResponse); + assert.strictEqual(singleTxResponse.status, mockSendManyResponse.status); + + // Verify that sendMany was called with the correct parameters + assert.strictEqual(mockWallet.sendMany.calledOnce, true); + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.strictEqual(callArgs.feeRate, 50000); + assert.strictEqual(callArgs.maxFeeRate, 100000); + assert.strictEqual(callArgs.minConfirms, 2); + }); + + it('should successfully send with unspents array and UTXO parameters', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 2000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + unspents: ['abc123:0', 'def456:1'], + minValue: 10000, + maxValue: 5000000, + }; + + // Create mock wallet + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .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); + assertSingleTxResponse(decodedResponse); + + // Verify unspents array was passed correctly + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.deepStrictEqual(callArgs.unspents, ['abc123:0', 'def456:1']); + assert.strictEqual(callArgs.minValue, 10000); + assert.strictEqual(callArgs.maxValue, 5000000); + }); + + it('should handle pending approval response (202)', async function () { + const mockPendingApprovalResponse = { + status: 'pendingApproval', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + pendingApproval: 'pending-approval-id-123', + }; + + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet that returns pending approval + const mockWallet = { + sendMany: sinon.stub().resolves(mockPendingApprovalResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify we get a 202 status for pending approval + assert.strictEqual(result.status, 202); + result.body.should.have.property('status'); + result.body.should.have.property('pendingApproval'); + assert.strictEqual(result.body.status, 'pendingApproval'); + }); + + it('should handle TSS wallet response', async function () { + const mockTssResponse = { + status: 'signed', + txRequest: { + txRequestId: 'tx-request-123', + state: 'signed', + }, + transfer: { + id: 'transfer-123', + state: 'signed', + }, + txid: 'txid-123', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock TSS wallet + const mockWallet = { + sendMany: sinon.stub().resolves(mockTssResponse), + _wallet: { multisigType: 'tss', multisigTypeVersion: 'MPCv2' }, + }; + + 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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('status'); + result.body.should.have.property('txRequest'); + assert.strictEqual(result.body.status, 'signed'); + + // Decode and verify TSS response structure + const decodedResponse = assertDecode(SendManyResponse, result.body); + assert.ok(decodedResponse); + }); + + it('should handle error response (400)', async function () { + const requestBody = { + recipients: [ + { + address: 'invalid-address', + amount: 1000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet that throws an error + const mockWallet = { + sendMany: sinon.stub().rejects(new Error('Invalid recipient 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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify we get a 400 error + assert.strictEqual(result.status, 400); + result.body.should.have.property('error'); + }); + + it('should support token transfer parameters', async function () { + const requestBody = { + recipients: [ + { + address: '0x1234567890123456789012345678901234567890', + amount: '1000000000000000000', // 1 token with 18 decimals + tokenName: 'terc', + }, + ], + walletPassphrase: 'test_passphrase_12345', + tokenName: 'terc', + }; + + const mockTokenResponse = { + status: 'accepted', + tx: '0xabcdef...', + hash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + fee: 21000, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockTokenResponse), + _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}/sendmany`) + .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); + assertSingleTxResponse(decodedResponse); + + // Verify token parameters were passed + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.strictEqual(callArgs.tokenName, 'terc'); + assert.strictEqual(callArgs.recipients[0].tokenName, 'terc'); + }); + + it('should support Ethereum EIP-1559 parameters', async function () { + const requestBody = { + recipients: [ + { + address: '0x1234567890123456789012345678901234567890', + amount: 1000000000000000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + eip1559: { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 100000000000, + }, + gasLimit: 21000, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify EIP-1559 parameters were passed + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.deepStrictEqual(callArgs.eip1559, { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 100000000000, + }); + assert.strictEqual(callArgs.gasLimit, 21000); + }); + + it('should support memo parameters for Stellar/XRP', async function () { + const requestBody = { + recipients: [ + { + address: 'GDSAMPLE1234567890', + amount: 10000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + memo: { + value: 'payment reference 123', + type: 'text', + }, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify memo was passed correctly + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.deepStrictEqual(callArgs.memo, { + value: 'payment reference 123', + type: 'text', + }); + }); + + it('should handle custodial wallet response', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockCustodialResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + txid: 'txid-custodial-123', + fee: 10000, + }; + + // Create mock custodial wallet + const mockWallet = { + sendMany: sinon.stub().resolves(mockCustodialResponse), + _wallet: { type: 'custodial', 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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('status'); + result.body.should.have.property('tx'); + assert.strictEqual(result.body.status, 'accepted'); + + // Validate custodial response structure + const decodedResponse = assertDecode(SendManyResponse, result.body); + const singleTxResponse = assertSingleTxResponse(decodedResponse); + assert.strictEqual(singleTxResponse.txid, 'txid-custodial-123'); + }); + + it('should handle TSS wallet pending approval', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockTssPendingResponse = { + txRequest: { + txRequestId: 'tx-request-pending-123', + state: 'pendingApproval', + pendingApprovalId: 'approval-123', + }, + pendingApproval: { + id: 'approval-123', + state: 'pending', + info: { + transactionRequest: 'tx-request-pending-123', + }, + }, + status: 'pending', + }; + + // Create mock TSS wallet that returns pending approval + const mockWallet = { + sendMany: sinon.stub().resolves(mockTssPendingResponse), + _wallet: { multisigType: 'tss', multisigTypeVersion: 'MPCv2' }, + }; + + 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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // TSS pending approval returns 200 (not 202 like traditional) + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequest'); + result.body.should.have.property('pendingApproval'); + + // Validate TSS pending approval response structure + const decodedResponse = assertDecode(SendManyResponse, result.body); + assert.ok(decodedResponse); + }); + + it('should support recipient with full tokenData object', async function () { + const requestBody = { + recipients: [ + { + address: '0x1234567890123456789012345678901234567890', + amount: '0', + tokenData: { + tokenType: 'ERC20', + tokenQuantity: '1000000', + tokenContractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + tokenName: 'USDT', + decimalPlaces: 6, + }, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockTokenResponse = { + status: 'accepted', + tx: '0xabcdef123456789...', + hash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + fee: 21000, + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockTokenResponse), + _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}/sendmany`) + .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); + assertSingleTxResponse(decodedResponse); + + // Verify full tokenData was passed correctly + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.deepStrictEqual(callArgs.recipients[0].tokenData, { + tokenType: 'ERC20', + tokenQuantity: '1000000', + tokenContractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + tokenName: 'USDT', + decimalPlaces: 6, + }); + }); + + it('should support recipient data field as hex string', async function () { + const requestBody = { + recipients: [ + { + address: '0x1234567890123456789012345678901234567890', + amount: 1000000, + data: '0xabcdef1234567890', + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify data as string was passed correctly + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.strictEqual(callArgs.recipients[0].data, '0xabcdef1234567890'); + }); + + it('should support recipient data field as TokenTransferRecipientParams', async function () { + const requestBody = { + recipients: [ + { + address: '0x1234567890123456789012345678901234567890', + amount: '0', + data: { + tokenType: 'ERC721', + tokenQuantity: '1', + tokenContractAddress: '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d', + tokenId: '12345', + }, + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify data as TokenTransferRecipientParams object was passed correctly + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.deepStrictEqual(callArgs.recipients[0].data, { + tokenType: 'ERC721', + tokenQuantity: '1', + tokenContractAddress: '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d', + tokenId: '12345', + }); + }); + }); + + describe('Request Validation', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + afterEach(function () { + sinon.restore(); + }); + + it('should accept amount as string', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000000', // String + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockSendManyResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.strictEqual(callArgs.recipients[0].amount, '1000000'); + }); + + it('should accept amount as number', async function () { + const requestBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, // Number + }, + ], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockSendManyResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + const mockWallet = { + sendMany: sinon.stub().resolves(mockSendManyResponse), + _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}/sendmany`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sendMany.firstCall.args[0]; + assert.strictEqual(callArgs.recipients[0].amount, 1000000); + }); + }); +});