From 6ef2483c54fb3d2772710d19ac2aa433f9affbbc Mon Sep 17 00:00:00 2001 From: Lokesh Chandra Date: Mon, 3 Nov 2025 11:44:06 +0530 Subject: [PATCH] feat(express): migrated sendmany as type route Ticket: WP-5427 --- modules/express/src/clientRoutes.ts | 8 +- modules/express/src/typedRoutes/api/index.ts | 9 + .../src/typedRoutes/api/v2/sendmany.ts | 761 +++++++++++ .../express/test/unit/typedRoutes/sendmany.ts | 1168 +++++++++++++++++ 4 files changed, 1942 insertions(+), 4 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/sendmany.ts create mode 100644 modules/express/test/unit/typedRoutes/sendmany.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index eb6295322c..b3f302c29c 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 { @@ -1641,7 +1641,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)]); router.post('express.v2.wallet.prebuildandsigntransaction', [ prepareBitGo(config), typedPromiseWrapper(handleV2PrebuildAndSignTransaction), diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 21aae5ecaf..3e5a65cc79 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 { PostPrebuildAndSignTransaction } from './v2/prebuildAndSignTransaction'; import { PostCoinSign } from './v2/coinSign'; @@ -161,6 +162,12 @@ export const ExpressV2WalletCreateAddressApiSpec = apiSpec({ }, }); +export const ExpressV2WalletSendManyApiSpec = apiSpec({ + 'express.v2.wallet.sendmany': { + post: PostSendMany, + }, +}); + export const ExpressKeychainLocalApiSpec = apiSpec({ 'express.keychain.local': { post: PostKeychainLocal, @@ -256,6 +263,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressLightningGetStateApiSpec & typeof ExpressLightningInitWalletApiSpec & typeof ExpressLightningUnlockWalletApiSpec & + typeof ExpressV2WalletSendManyApiSpec & typeof ExpressOfcSignPayloadApiSpec & typeof ExpressWalletRecoverTokenApiSpec & typeof ExpressCoinSigningApiSpec & @@ -286,6 +294,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..019f3cfd25 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -0,0 +1,761 @@ +import * as t from 'io-ts'; +import { DateFromISOString } from 'io-ts-types'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; +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 + * When eip1559 object is present, both fields are REQUIRED + */ +const EIP1559Params = t.type({ + /** Maximum priority fee per gas (in wei) - REQUIRED */ + maxPriorityFeePerGas: t.union([t.number, t.string]), + /** Maximum fee per gas (in wei) - REQUIRED */ + maxFeePerGas: t.union([t.number, t.string]), +}); + +/** + * Memo object for chains that support memos (e.g., Stellar, XRP) + * When memo object is present, both fields are REQUIRED + */ +const MemoParams = t.type({ + /** Memo value - REQUIRED */ + value: t.string, + /** Memo type - REQUIRED */ + type: t.string, +}); + +/** + * Token transfer recipient parameters + * tokenType and tokenQuantity are REQUIRED when this object is present + */ +const TokenTransferRecipientParams = t.intersection([ + t.type({ + /** Type of token (e.g., 'ERC20', 'ERC721', 'ERC1155', 'NATIVE') - REQUIRED */ + tokenType: t.string, + /** Quantity of tokens to transfer (as string) - REQUIRED */ + tokenQuantity: t.string, + }), + t.partial({ + /** Token contract address (for ERC20, ERC721, etc.) - OPTIONAL */ + tokenContractAddress: t.string, + /** Token name - OPTIONAL */ + tokenName: t.string, + /** Token ID (for NFTs - ERC721, ERC1155) - OPTIONAL */ + tokenId: t.string, + /** Decimal places for the token - OPTIONAL */ + 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]); + +/** + * Token enablement configuration + * name is REQUIRED when this object is present + */ +const TokenEnablement = t.intersection([ + t.type({ + /** Token name - REQUIRED */ + name: t.string, + }), + t.partial({ + /** Token address (some chains like Solana require tokens to be enabled for specific address) - OPTIONAL */ + address: t.string, + }), +]); + +/** + * 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(TokenEnablement)), + + /** 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.intersection([ + t.type({ + /** Module name - REQUIRED */ + moduleName: t.string, + /** Function name - REQUIRED */ + functionName: t.string, + }), + t.partial({ + /** Type arguments - OPTIONAL */ + typeArguments: t.array(t.string), + /** Function arguments - OPTIONAL */ + functionArguments: t.array(t.any), + /** ABI - OPTIONAL */ + 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), + + /** Transaction verification parameters (used for verifying transaction before signing) */ + verifyTxParams: optional( + t.intersection([ + t.type({ + /** Transaction parameters to verify - REQUIRED when verifyTxParams is present */ + txParams: t.partial({ + /** Recipients for verification */ + recipients: t.array( + t.intersection([ + t.type({ + /** Recipient address */ + address: t.string, + /** Amount to send */ + amount: t.union([t.string, t.number]), + }), + t.partial({ + /** Token name */ + tokenName: t.string, + /** Memo */ + memo: t.string, + }), + ]) + ), + /** Wallet passphrase */ + walletPassphrase: t.string, + /** Transaction type */ + type: t.string, + /** Memo for verification */ + memo: MemoParams, + /** Tokens to enable */ + enableTokens: t.array(TokenEnablement), + }), + }), + t.partial({ + /** Verification options - OPTIONAL */ + verification: t.any, + }), + ]) + ), +} as const; + +/** + * Entry in a transfer (input or output) + */ +const TransferEntry = t.intersection([ + t.type({ + /** Address associated with this entry - REQUIRED */ + address: t.string, + }), + t.partial({ + /** Label for the address - OPTIONAL */ + label: t.string, + /** Whether this entry failed - OPTIONAL */ + failed: t.boolean, + /** Whether this is a change output - OPTIONAL */ + isChange: t.boolean, + /** Whether this is a fee entry - OPTIONAL */ + isFee: t.boolean, + /** Whether this is an internal transfer - OPTIONAL */ + isInternal: t.boolean, + /** Whether this is a PayGo entry - OPTIONAL */ + isPayGo: t.boolean, + /** Memo associated with this entry - OPTIONAL */ + memo: t.string, + /** Reward address (for staking) - OPTIONAL */ + rewardAddress: t.string, + /** Entry subtype - OPTIONAL */ + subtype: t.string, + /** Token name - OPTIONAL */ + token: t.string, + /** Token contract hash - OPTIONAL */ + tokenContractHash: t.string, + /** Value in base units (number) - OPTIONAL */ + value: t.number, + /** Value as string - OPTIONAL */ + valueString: t.string, + /** Wallet ID - OPTIONAL */ + wallet: t.string, + /** Backing fee as string - OPTIONAL */ + backingFeeString: t.string, + /** Entry type - OPTIONAL */ + type: t.string, + /** NFT ID - OPTIONAL */ + nftId: t.string, + /** NFT symbol - OPTIONAL */ + nftSymbol: t.string, + /** Associated native coin address - OPTIONAL */ + associatedNativeCoinAddress: t.string, + }), +]); + +/** + * History item in transfer history + */ +const TransferHistory = t.intersection([ + t.type({ + /** Action performed (e.g., 'created', 'signed', 'confirmed') - REQUIRED */ + action: t.string, + /** Date of the action - REQUIRED */ + date: DateFromISOString, + }), + t.partial({ + /** Comment for this history item - OPTIONAL */ + comment: t.string, + /** Transfer ID (for replaced transfers) - OPTIONAL */ + transferId: t.string, + /** Transaction ID - OPTIONAL */ + txid: t.string, + /** User who performed the action - OPTIONAL */ + user: t.string, + /** Malformed transfer data - OPTIONAL */ + malformedTransfer: t.unknown, + }), +]); + +/** + * Transfer object returned by sendMany - simplified with optional fields + */ +export const Transfer = t.type({ + /** Coin identifier - REQUIRED */ + coin: t.string, + /** Transfer ID - REQUIRED */ + id: t.string, + /** Wallet ID - REQUIRED */ + wallet: t.string, + /** Enterprise ID - OPTIONAL */ + enterprise: optional(t.string), + /** Transaction ID - REQUIRED */ + txid: t.string, + /** Transaction ID type - OPTIONAL */ + txidType: optional(t.string), + /** Block height - REQUIRED */ + height: t.number, + /** Height ID - OPTIONAL */ + heightId: optional(t.string), + /** Transfer date - REQUIRED */ + date: DateFromISOString, + /** Number of confirmations - REQUIRED */ + confirmations: t.number, + /** Transfer type ('send' or 'receive') - REQUIRED */ + type: t.string, + /** Value (number) - OPTIONAL */ + value: optional(t.number), + /** Value as string - REQUIRED */ + valueString: t.string, + /** Intended value string - OPTIONAL */ + intendedValueString: optional(t.string), + /** Base value (in base currency) - OPTIONAL */ + baseValue: optional(t.number), + /** Base value as string - OPTIONAL */ + baseValueString: optional(t.string), + /** Base value without fees - OPTIONAL */ + baseValueWithoutFees: optional(t.number), + /** Base value without fees as string - OPTIONAL */ + baseValueWithoutFeesString: optional(t.string), + /** Fee as string - OPTIONAL */ + feeString: optional(t.string), + /** PayGo fee - OPTIONAL */ + payGoFee: optional(t.number), + /** PayGo fee as string - OPTIONAL */ + payGoFeeString: optional(t.string), + /** USD value of the transfer in dollars - OPTIONAL */ + usd: optional(t.number), + /** USD exchange rate used for conversion - OPTIONAL */ + usdRate: optional(t.number), + /** Transfer state (e.g., 'signed', 'confirmed', 'rejected') - REQUIRED */ + state: t.string, + /** Tags - OPTIONAL */ + tags: optional(t.array(t.string)), + /** Transfer history - REQUIRED */ + history: t.array(TransferHistory), + /** Transfer comment - OPTIONAL */ + comment: optional(t.string), + /** Virtual size (for Bitcoin) - OPTIONAL */ + vSize: optional(t.number), + /** Coin-specific data (format varies by blockchain) - OPTIONAL */ + coinSpecific: optional(t.any), + /** Sequence ID - OPTIONAL */ + sequenceId: optional(t.string), + /** Array of entries (inputs/outputs) - OPTIONAL */ + entries: optional(t.array(TransferEntry)), + /** Users notified flag - OPTIONAL */ + usersNotified: optional(t.boolean), + /** Label (stored in DB, not returned in API response) - OPTIONAL */ + label: optional(t.string), + /** Transfer IDs that this transfer replaces - OPTIONAL */ + replaces: optional(t.array(t.string)), + /** Transfer ID that replaced this transfer - OPTIONAL */ + replacedBy: optional(t.array(t.string)), +}); + +/** + * Resolver object for pending approval actions - simplified with optional fields + * Represents a user who has resolved/approved a pending approval action + */ +const Resolver = t.type({ + /** User who resolved the action - REQUIRED */ + user: t.string, + /** Date when the action was resolved - REQUIRED */ + date: DateFromISOString, + /** Type of resolution (e.g., 'approved', 'rejected') - REQUIRED */ + resolutionType: t.string, + /** Signatures provided during resolution - REQUIRED */ + signatures: t.array(t.string), + /** Link to video verification (if applicable) - OPTIONAL */ + videoLink: optional(t.string), + /** Video approver ID (if video verification was used) - OPTIONAL */ + videoApprover: optional(t.string), + /** Video verification exception information - OPTIONAL */ + videoException: optional(t.string), +}); + +/** + * Pending approval information - simplified with optional fields + */ +const PendingApprovalInfo = t.type({ + /** Type of pending approval - OPTIONAL */ + type: optional(t.string), + /** Transaction request details - OPTIONAL */ + transactionRequest: optional( + t.type({ + /** Build parameters - OPTIONAL */ + buildParams: optional(t.unknown), + /** Coin-specific data - OPTIONAL */ + coinSpecific: optional(t.unknown), + /** Comment - OPTIONAL */ + comment: optional(t.string), + /** Fee - OPTIONAL */ + fee: optional(t.unknown), + /** Whether transaction is unsigned - OPTIONAL */ + isUnsigned: optional(t.boolean), + /** Recipients - OPTIONAL */ + recipients: optional( + t.array( + t.partial({ + /** Recipient address - OPTIONAL */ + address: t.string, + /** Amount to send - OPTIONAL */ + amount: t.unknown, + /** Data field - OPTIONAL */ + data: t.string, + }) + ) + ), + /** Requested amount - OPTIONAL */ + requestedAmount: optional(t.unknown), + /** Source wallet ID - OPTIONAL */ + sourceWallet: optional(t.string), + /** Triggered policy - OPTIONAL */ + triggeredPolicy: optional(t.unknown), + /** Valid transaction - OPTIONAL */ + validTransaction: optional(t.string), + /** Valid transaction hash - OPTIONAL */ + validTransactionHash: optional(t.string), + }) + ), +}); + +/** + * Pending approval object - simplified with optional fields to reduce type depth + */ +export const PendingApproval = t.type({ + /** Pending approval ID - OPTIONAL */ + id: optional(t.string), + /** Coin type - OPTIONAL */ + coin: optional(t.string), + /** Wallet ID (if wallet-level approval) - OPTIONAL */ + wallet: optional(t.string), + /** Enterprise ID (if enterprise-level approval) - OPTIONAL */ + enterprise: optional(t.string), + /** Organization ID - OPTIONAL */ + organization: optional(t.string), + /** User who created the pending approval - OPTIONAL */ + creator: optional(t.string), + /** Create date - OPTIONAL */ + createDate: optional(DateFromISOString), + /** Pending approval information - OPTIONAL */ + info: optional(PendingApprovalInfo), + /** State of the pending approval - OPTIONAL */ + state: optional(t.string), + /** Scope of the pending approval - OPTIONAL */ + scope: optional(t.string), + /** User IDs involved - OPTIONAL */ + userIds: optional(t.array(t.string)), + /** Number of approvals required - OPTIONAL */ + approvalsRequired: optional(t.number), + /** Wallet label - OPTIONAL */ + walletLabel: optional(t.string), + /** Resolvers who have acted - OPTIONAL */ + resolvers: optional(t.array(Resolver)), +}); + +/** + * Unified Response Codec for both 200 and 202 status codes + * Split into smaller parts to avoid TypeScript maximum length error + * + * Covers all 5 response paths (see SENDMANY_RESPONSE_FLOWCHART.md): + * - Path 1A: TSS Full API (Pending) → 200 with { pendingApproval, txRequest } + * - Path 1B: TSS Full API (Success) → 200 or 202 with { transfer, txRequest, status, ... } + * - Path 1C: TSS Lite API → 200 with TxRequest object + * - Path 2: Custodial → 200 with { error?, pendingApprovals? } + * - Path 3: Non-Custodial → 200 or 202 with { status, transfer?, ... } + * + * All fields are optional to accommodate different wallet types and scenarios. + * The HTTP status code (200 vs 202) conveys the semantic difference. + */ + +// Basic transaction fields +const SendManyResponseBasic = t.type({ + /** Transfer details - varies by coin and wallet type */ + transfer: optional(Transfer), + /** Transaction status (e.g., 'signed', 'accepted', 'pendingApproval') */ + status: optional(t.string), + /** Transaction hex */ + tx: optional(t.string), + /** Transaction ID/hash */ + txid: optional(t.string), +}); + +// Complex type fields (using t.unknown to avoid TypeScript serialization limits) +const SendManyTxRequestResponse = t.type({ + /** Transaction request object - used in TSS wallets */ + txRequest: optional(TxRequestResponse), +}); + +const SendManyPendingApprovalResponse = t.type({ + /** Pending approval object - used when approval is pending */ + pendingApproval: optional(PendingApproval), +}); + +// Combined response codec +export const SendManyResponse = t.intersection([ + SendManyResponseBasic, + SendManyTxRequestResponse, + SendManyPendingApprovalResponse, +]); + +/** + * 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 (same structure as 200) */ + 202: SendManyResponse, + /** 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..0ca18a598a --- /dev/null +++ b/modules/express/test/unit/typedRoutes/sendmany.ts @@ -0,0 +1,1168 @@ +import * as assert from 'assert'; +import { SendManyResponse, SendManyRequestParams, SendManyRequestBody } from '../../../src/typedRoutes/api/v2/sendmany'; +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('SendMany 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: '1000000', + state: 'confirmed', + history: [], + ...overrides, + }; + } + + // Helper to assert response structure + function assertSendManyResponse(response: any) { + assert.ok(!Array.isArray(response), 'Expected single transaction response, got array'); + return response; + } + + describe('sendMany v2', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + const mockSendManyResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0280a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac40420f00000000001976a914a2b6d08c6f5a2b5e4d6f0a72c3e8b9f5d4c3a21188ac00000000', + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + 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.txid, mockSendManyResponse.txid); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSendManyResponse(decodedResponse); + assert.strictEqual(decodedResponse.status, mockSendManyResponse.status); + assert.strictEqual(decodedResponse.tx, mockSendManyResponse.tx); + assert.strictEqual(decodedResponse.txid, mockSendManyResponse.txid); + + // 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); + assertSendManyResponse(decodedResponse); + assert.strictEqual(decodedResponse.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); + assertSendManyResponse(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', + walletId: walletId, + walletType: 'hot', + version: 1, + state: 'signed', + date: new Date().toISOString(), + createdDate: new Date().toISOString(), + userId: 'user-123', + initiatedBy: 'user-123', + updatedBy: 'user-123', + intents: [], + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }, + transfer: createMockTransfer({ + 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...', + txid: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + 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); + assertSendManyResponse(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', + txid: 'txid-custodial-123', + }; + + // 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); + assertSendManyResponse(decodedResponse); + assert.strictEqual(decodedResponse.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', + walletId: walletId, + walletType: 'hot', + version: 1, + state: 'pendingApproval', + date: new Date().toISOString(), + createdDate: new Date().toISOString(), + userId: 'user-123', + initiatedBy: 'user-123', + updatedBy: 'user-123', + intents: [], + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + pendingApprovalId: 'approval-123', + }, + pendingApproval: { + id: 'approval-123', + state: 'pending', + info: { + type: 'transactionRequest', + }, + }, + }; + + // 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...', + txid: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + 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); + assertSendManyResponse(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', + txid: '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', + txid: '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); + }); + }); + + describe('Codec Validation', function () { + describe('SendManyRequestParams', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'tbtc', + id: '68c02f96aa757d9212bd1a536f123456', + }; + + const decoded = assertDecode(t.type(SendManyRequestParams), 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(SendManyRequestParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'tbtc', + }; + + assert.throws(() => { + assertDecode(t.type(SendManyRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string coin', function () { + const invalidParams = { + coin: 123, + id: '68c02f96aa757d9212bd1a536f123456', + }; + + assert.throws(() => { + assertDecode(t.type(SendManyRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + coin: 'tbtc', + id: 123, + }; + + assert.throws(() => { + assertDecode(t.type(SendManyRequestParams), invalidParams); + }); + }); + }); + + describe('SendManyRequestBody', function () { + it('should validate body with basic recipients', function () { + const validBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }, + ], + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.ok(decoded.recipients); + assert.strictEqual(decoded.recipients.length, 1); + assert.strictEqual(decoded.recipients[0].address, validBody.recipients[0].address); + assert.strictEqual(decoded.recipients[0].amount, validBody.recipients[0].amount); + }); + + it('should validate body with amount as string', function () { + const validBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000000', + }, + ], + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.ok(decoded.recipients); + assert.strictEqual(decoded.recipients[0].amount, '1000000'); + }); + + it('should validate body with walletPassphrase', function () { + const validBody = { + recipients: [{ address: 'addr', amount: 1000 }], + walletPassphrase: 'test_passphrase', + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.strictEqual(decoded.walletPassphrase, 'test_passphrase'); + }); + + it('should validate body with feeRate', function () { + const validBody = { + recipients: [{ address: 'addr', amount: 1000 }], + feeRate: 50000, + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.strictEqual(decoded.feeRate, 50000); + }); + + it('should validate body with eip1559 params', function () { + const validBody = { + recipients: [{ address: '0x123', amount: 1000 }], + eip1559: { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 100000000000, + }, + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.ok(decoded.eip1559); + assert.strictEqual(decoded.eip1559.maxPriorityFeePerGas, 2000000000); + assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); + }); + + it('should validate body with memo', function () { + const validBody = { + recipients: [{ address: 'GDSAMPLE', amount: 1000 }], + memo: { + value: 'payment reference 123', + type: 'text', + }, + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.ok(decoded.memo); + assert.strictEqual(decoded.memo.value, 'payment reference 123'); + assert.strictEqual(decoded.memo.type, 'text'); + }); + + it('should validate body with tokenName', function () { + const validBody = { + recipients: [{ address: '0x123', amount: '1000', tokenName: 'terc' }], + tokenName: 'terc', + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.strictEqual(decoded.tokenName, 'terc'); + assert.ok(decoded.recipients); + assert.strictEqual(decoded.recipients[0].tokenName, 'terc'); + }); + + it('should validate body with unspents array', function () { + const validBody = { + recipients: [{ address: 'addr', amount: 1000 }], + unspents: ['abc123:0', 'def456:1'], + }; + + const decoded = assertDecode(t.type(SendManyRequestBody), validBody); + assert.deepStrictEqual(decoded.unspents, ['abc123:0', 'def456:1']); + }); + + it('should reject body with invalid recipient (missing address)', function () { + const invalidBody = { + recipients: [ + { + amount: 1000000, + }, + ], + }; + + assert.throws(() => { + assertDecode(t.type(SendManyRequestBody), invalidBody); + }); + }); + + it('should reject body with invalid recipient (missing amount)', function () { + const invalidBody = { + recipients: [ + { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + }, + ], + }; + + assert.throws(() => { + assertDecode(t.type(SendManyRequestBody), invalidBody); + }); + }); + }); + + describe('SendManyResponse', function () { + it('should validate response with status and tx', function () { + const validResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f', + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.strictEqual(decoded.status, 'accepted'); + assert.strictEqual(decoded.tx, validResponse.tx); + }); + + it('should validate response with txid', function () { + const validResponse = { + status: 'accepted', + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.strictEqual(decoded.txid, validResponse.txid); + }); + + it('should validate response with transfer', function () { + const validResponse = { + status: 'accepted', + transfer: createMockTransfer(), + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.transfer); + assert.strictEqual(decoded.transfer.coin, 'tbtc'); + }); + + it('should validate response with txRequest (TSS)', function () { + const validResponse = { + status: 'signed', + txRequest: { + txRequestId: 'tx-request-123', + walletId: 'wallet-456', + walletType: 'hot', + version: 1, + state: 'signed', + date: new Date().toISOString(), + createdDate: new Date().toISOString(), + userId: 'user-123', + initiatedBy: 'user-123', + updatedBy: 'user-123', + intents: [], + latest: true, + }, + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.txRequest); + assert.strictEqual(decoded.txRequest.txRequestId, 'tx-request-123'); + }); + + it('should validate response with pendingApproval', function () { + const validResponse = { + pendingApproval: { + id: 'approval-123', + state: 'pending', + }, + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.pendingApproval); + assert.strictEqual(decoded.pendingApproval.id, 'approval-123'); + }); + + it('should validate complete response (all fields)', function () { + const validResponse = { + status: 'signed', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f', + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + transfer: createMockTransfer(), + txRequest: { + txRequestId: 'tx-request-123', + walletId: 'wallet-456', + walletType: 'hot', + version: 1, + state: 'signed', + date: new Date().toISOString(), + createdDate: new Date().toISOString(), + userId: 'user-123', + initiatedBy: 'user-123', + updatedBy: 'user-123', + intents: [], + latest: true, + }, + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.strictEqual(decoded.status, 'signed'); + assert.ok(decoded.tx); + assert.ok(decoded.txid); + assert.ok(decoded.transfer); + assert.ok(decoded.txRequest); + }); + + it('should validate empty response (all fields optional)', function () { + const validResponse = {}; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.strictEqual(decoded.status, undefined); + assert.strictEqual(decoded.tx, undefined); + assert.strictEqual(decoded.txid, undefined); + assert.strictEqual(decoded.transfer, undefined); + assert.strictEqual(decoded.txRequest, undefined); + assert.strictEqual(decoded.pendingApproval, undefined); + }); + }); + }); +});