diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index b3f302c29c..9ebac5fc16 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -911,11 +911,11 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { * handle send one * @param req */ -async function handleV2SendOne(req: express.Request) { +async function handleV2SendOne(req: ExpressApiRouteRequest<'express.v2.wallet.sendcoins', '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; @@ -1640,7 +1640,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.v2.wallet.recovertoken', [prepareBitGo(config), typedPromiseWrapper(handleV2RecoverToken)]); // send transaction - app.post('/api/v2/:coin/wallet/:id/sendcoins', parseBody, prepareBitGo(config), promiseWrapper(handleV2SendOne)); + router.post('express.v2.wallet.sendcoins', [prepareBitGo(config), typedPromiseWrapper(handleV2SendOne)]); router.post('express.v2.wallet.sendmany', [prepareBitGo(config), typedPromiseWrapper(handleV2SendMany)]); router.post('express.v2.wallet.prebuildandsigntransaction', [ prepareBitGo(config), diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 3e5a65cc79..f5e78d4cfd 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -38,6 +38,7 @@ import { PostSendMany } from './v2/sendmany'; import { PostConsolidateUnspents } from './v2/consolidateunspents'; import { PostPrebuildAndSignTransaction } from './v2/prebuildAndSignTransaction'; import { PostCoinSign } from './v2/coinSign'; +import { PostSendCoins } from './v2/sendCoins'; // Too large types can cause the following error // @@ -168,6 +169,12 @@ export const ExpressV2WalletSendManyApiSpec = apiSpec({ }, }); +export const ExpressV2WalletSendCoinsApiSpec = apiSpec({ + 'express.v2.wallet.sendcoins': { + post: PostSendCoins, + }, +}); + export const ExpressKeychainLocalApiSpec = apiSpec({ 'express.keychain.local': { post: PostKeychainLocal, @@ -264,6 +271,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressLightningInitWalletApiSpec & typeof ExpressLightningUnlockWalletApiSpec & typeof ExpressV2WalletSendManyApiSpec & + typeof ExpressV2WalletSendCoinsApiSpec & typeof ExpressOfcSignPayloadApiSpec & typeof ExpressWalletRecoverTokenApiSpec & typeof ExpressCoinSigningApiSpec & @@ -295,6 +303,7 @@ export const ExpressApi: ExpressApi = { ...ExpressLightningInitWalletApiSpec, ...ExpressLightningUnlockWalletApiSpec, ...ExpressV2WalletSendManyApiSpec, + ...ExpressV2WalletSendCoinsApiSpec, ...ExpressOfcSignPayloadApiSpec, ...ExpressWalletRecoverTokenApiSpec, ...ExpressCoinSigningApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/sendCoins.ts b/modules/express/src/typedRoutes/api/v2/sendCoins.ts new file mode 100644 index 0000000000..6b71cd4de7 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -0,0 +1,401 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { SendManyResponse, EIP1559Params, MemoParams, TokenEnablement } from './sendmany'; + +/** + * Request parameters for sending to a single recipient (v2) + */ +export const SendCoinsRequestParams = { + /** The coin identifier (e.g., 'btc', 'tbtc', 'eth', 'teth') */ + coin: t.string, + /** The ID of the wallet */ + id: t.string, +} as const; + +/** + * Request body for sending to a single recipient (v2) + * + * This endpoint is a convenience wrapper around sendMany that accepts a single + * address and amount instead of a recipients array. It supports the full set of + * parameters available in sendMany. + * + * Internally, wallet.send() converts the address and amount into a recipients array + * and calls wallet.sendMany(), so the response structure is identical. + */ +export const SendCoinsRequestBody = { + /** The destination address - REQUIRED */ + address: t.string, + + /** The amount to send in base units (e.g., satoshis for BTC, wei for ETH) - REQUIRED */ + amount: t.union([t.number, t.string]), + + /** Data field for the transaction (e.g., for Ethereum contract calls) */ + data: optional(t.string), + + /** Fee limit for this transaction (e.g., for Tron TRC20 tokens) */ + feeLimit: optional(t.string), + + /** The wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + + /** The extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + + /** The private key (prv) in string form */ + prv: optional(t.string), + + /** Message to attach to the transaction */ + message: optional(t.string), + + /** 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), + + /** Custodian transaction ID (for institutional custody integrations like Metamask) */ + custodianTransactionId: optional(t.string), + + /** Token name for token transfers */ + tokenName: optional(t.string), + + // All SendManyOptions fields are also supported via the SendOptions index signature + // Including these commonly used fields explicitly for better documentation: + + /** 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), + + /** Target number of unspents to maintain in the wallet */ + targetWalletUnspents: optional(t.number), + + /** 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), + + /** Type of transaction (e.g., 'trustline' for Stellar) */ + type: 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; + +/** + * Send coins to a single recipient (v2) + * + * This endpoint is a convenience wrapper around the sendMany endpoint that accepts + * a single address and amount instead of a recipients array. + * + * Internally, wallet.send() converts the address and amount into a recipients array + * with a single recipient and calls wallet.sendMany(). This means: + * 1. All sendMany parameters are supported + * 2. The response structure is identical to sendMany + * + * The endpoint: + * 1. Validates the address and amount parameters + * 2. Builds a transaction to the specified address + * 3. Signs the transaction with the user's key (decrypted with walletPassphrase or xprv) + * 4. Requests a signature from BitGo's key + * 5. Sends the fully-signed transaction to the blockchain network + * + * Supports: + * - TSS wallets with txRequest flow + * - Custodial wallets + * - Traditional multisig wallets + * - Account-based and UTXO-based coins + * - Token transfers + * - Advanced features like memo fields, hop transactions, EIP-1559 fees + * + * @operationId express.v2.wallet.sendcoins + * @tag express + */ +export const PostSendCoins = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/sendcoins', + method: 'POST', + request: httpRequest({ + params: SendCoinsRequestParams, + body: SendCoinsRequestBody, + }), + 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/src/typedRoutes/api/v2/sendmany.ts b/modules/express/src/typedRoutes/api/v2/sendmany.ts index 019f3cfd25..7222bb254a 100644 --- a/modules/express/src/typedRoutes/api/v2/sendmany.ts +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -18,7 +18,7 @@ export const SendManyRequestParams = { * EIP-1559 fee parameters for Ethereum transactions * When eip1559 object is present, both fields are REQUIRED */ -const EIP1559Params = t.type({ +export 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 */ @@ -29,7 +29,7 @@ const EIP1559Params = t.type({ * Memo object for chains that support memos (e.g., Stellar, XRP) * When memo object is present, both fields are REQUIRED */ -const MemoParams = t.type({ +export const MemoParams = t.type({ /** Memo value - REQUIRED */ value: t.string, /** Memo type - REQUIRED */ @@ -89,7 +89,7 @@ const Recipient = t.intersection([RecipientParams, RecipientParamsOptional]); * Token enablement configuration * name is REQUIRED when this object is present */ -const TokenEnablement = t.intersection([ +export const TokenEnablement = t.intersection([ t.type({ /** Token name - REQUIRED */ name: t.string, diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts new file mode 100644 index 0000000000..47aa8fde87 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -0,0 +1,969 @@ +import * as assert from 'assert'; +import { SendCoinsRequestParams, SendCoinsRequestBody } from '../../../src/typedRoutes/api/v2/sendCoins'; +import { SendManyResponse } 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('SendCoins 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 assertSendCoinsResponse(response: any) { + assert.ok(!Array.isArray(response), 'Expected single transaction response, got array'); + return response; + } + + describe('sendCoins v2', function () { + const agent = setupAgent(); + const walletId = '68c02f96aa757d9212bd1a536f123456'; + const coin = 'tbtc'; + + const mockSendResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully send to single recipient with address and amount', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet with send method + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .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, mockSendResponse.status); + assert.strictEqual(result.body.tx, mockSendResponse.tx); + assert.strictEqual(result.body.txid, mockSendResponse.txid); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(SendManyResponse, result.body); + assertSendCoinsResponse(decodedResponse); + assert.strictEqual(decodedResponse.status, mockSendResponse.status); + assert.strictEqual(decodedResponse.tx, mockSendResponse.tx); + assert.strictEqual(decodedResponse.txid, mockSendResponse.txid); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(walletsGetStub.calledOnce, true); + assert.strictEqual(mockWallet.send.calledOnce, true); + }); + + it('should successfully send with amount as string', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000000', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .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); + assertSendCoinsResponse(decodedResponse); + + // Verify that send was called with string amount + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.amount, '1000000'); + }); + + it('should successfully send with fee parameters', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + feeRate: 50000, + maxFeeRate: 100000, + minConfirms: 2, + }; + + // Create mock wallet with send method + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .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); + assertSendCoinsResponse(decodedResponse); + assert.strictEqual(decodedResponse.status, mockSendResponse.status); + + // Verify that send was called with the correct parameters + assert.strictEqual(mockWallet.send.calledOnce, true); + const callArgs = mockWallet.send.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 = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 2000000, + walletPassphrase: 'test_passphrase_12345', + unspents: ['abc123:0', 'def456:1'], + minValue: 10000, + maxValue: 5000000, + }; + + // Create mock wallet + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .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); + assertSendCoinsResponse(decodedResponse); + + // Verify unspents array was passed correctly + const callArgs = mockWallet.send.firstCall.args[0]; + assert.deepStrictEqual(callArgs.unspents, ['abc123:0', 'def456:1']); + assert.strictEqual(callArgs.minValue, 10000); + assert.strictEqual(callArgs.maxValue, 5000000); + }); + + it('should successfully send with token transfer (tokenName)', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + walletPassphrase: 'test_passphrase_12345', + tokenName: 'terc', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.tokenName, 'terc'); + }); + + it('should successfully send with data field (Ethereum)', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: 0, + walletPassphrase: 'test_passphrase_12345', + data: '0xa9059cbb000000000000000000000000', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.data, '0xa9059cbb000000000000000000000000'); + }); + + it('should successfully send with EIP-1559 parameters (Ethereum)', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + walletPassphrase: 'test_passphrase_12345', + eip1559: { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 100000000000, + }, + gasLimit: 21000, + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.ok(callArgs.eip1559); + assert.strictEqual(callArgs.eip1559.maxPriorityFeePerGas, 2000000000); + assert.strictEqual(callArgs.eip1559.maxFeePerGas, 100000000000); + assert.strictEqual(callArgs.gasLimit, 21000); + }); + + it('should successfully send with memo (XRP/Stellar)', async function () { + const requestBody = { + address: 'GDSAMPLE123456789', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + memo: { + value: 'payment reference 123', + type: 'text', + }, + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.ok(callArgs.memo); + assert.strictEqual(callArgs.memo.value, 'payment reference 123'); + assert.strictEqual(callArgs.memo.type, 'text'); + }); + + it('should handle pending approval response (202)', async function () { + const mockPendingApprovalResponse = { + status: 'pendingApproval', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + pendingApproval: 'pending-approval-id-123', + }; + + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet that returns pending approval + const mockWallet = { + send: 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}/sendcoins`) + .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 = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock TSS wallet + const mockWallet = { + send: 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}/sendcoins`) + .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 = { + address: 'invalid-address', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + }; + + // Create mock wallet that throws an error + const mockWallet = { + send: 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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify we get a 400 status for error + assert.strictEqual(result.status, 400); + result.body.should.have.property('error'); + }); + + it('should reject request with missing address', async function () { + const requestBody = { + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should return 400 for missing required field + assert.strictEqual(result.status, 400); + }); + + it('should reject request with missing amount', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + walletPassphrase: 'test_passphrase_12345', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should return 400 for missing required field + assert.strictEqual(result.status, 400); + }); + + it('should successfully send with instant parameter', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + instant: true, + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.instant, true); + }); + + it('should successfully send with custodian transaction ID', async function () { + const requestBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + walletPassphrase: 'test_passphrase_12345', + custodianTransactionId: 'custodian-tx-123', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.custodianTransactionId, 'custodian-tx-123'); + }); + + it('should successfully send with message parameter', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase_12345', + message: 'Payment for invoice #12345', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.message, 'Payment for invoice #12345'); + }); + + it('should successfully send with prv instead of walletPassphrase', async function () { + const requestBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + prv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _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}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.ok(callArgs.prv); + }); + }); + + describe('Codec Validation', function () { + describe('SendCoinsRequestParams', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'tbtc', + id: '68c02f96aa757d9212bd1a536f123456', + }; + + const decoded = assertDecode(t.type(SendCoinsRequestParams), 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(SendCoinsRequestParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'tbtc', + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string coin', function () { + const invalidParams = { + coin: 123, + id: '68c02f96aa757d9212bd1a536f123456', + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + coin: 'tbtc', + id: 123, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestParams), invalidParams); + }); + }); + }); + + describe('SendCoinsRequestBody', function () { + it('should validate body with basic address and amount', function () { + const validBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.strictEqual(decoded.address, validBody.address); + assert.strictEqual(decoded.amount, validBody.amount); + }); + + it('should validate body with amount as string', function () { + const validBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000000', + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.strictEqual(decoded.address, validBody.address); + assert.strictEqual(decoded.amount, '1000000'); + }); + + it('should validate body with optional parameters', function () { + const validBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: 1000000, + walletPassphrase: 'test_passphrase', + feeRate: 50000, + minConfirms: 2, + instant: true, + message: 'test message', + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.strictEqual(decoded.walletPassphrase, 'test_passphrase'); + assert.strictEqual(decoded.feeRate, 50000); + assert.strictEqual(decoded.minConfirms, 2); + assert.strictEqual(decoded.instant, true); + assert.strictEqual(decoded.message, 'test message'); + }); + + it('should validate body with eip1559 params', function () { + const validBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + eip1559: { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 100000000000, + }, + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), 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 = { + address: 'GDSAMPLE', + amount: 1000000, + memo: { + value: 'payment reference 123', + type: 'text', + }, + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), 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 = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + tokenName: 'terc', + }; + + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.strictEqual(decoded.tokenName, 'terc'); + }); + + it('should reject body with missing address', function () { + const invalidBody = { + amount: 1000000, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + + it('should reject body with missing amount', function () { + const invalidBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string address', function () { + const invalidBody = { + address: 123, + amount: 1000000, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + + it('should reject body with invalid amount type (not number or string)', function () { + const invalidBody = { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: { value: 1000000 }, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + + it('should reject body with incomplete eip1559 params', function () { + const invalidBody = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000000000000000', + eip1559: { + maxPriorityFeePerGas: 2000000000, + // Missing maxFeePerGas + }, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + + it('should reject body with incomplete memo params', function () { + const invalidBody = { + address: 'GDSAMPLE', + amount: 1000000, + memo: { + value: 'payment reference 123', + // Missing type + }, + }; + + assert.throws(() => { + assertDecode(t.type(SendCoinsRequestBody), invalidBody); + }); + }); + }); + + describe('SendCoinsResponse (reuses 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: [], + intent: {}, + policiesChecked: true, + unsignedTxs: [], + 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 = { + status: 'pendingApproval', + pendingApproval: { + id: 'pending-123', + coin: 'tbtc', + state: 'pending', + }, + }; + + const decoded = assertDecode(SendManyResponse, validResponse); + assert.ok(decoded.pendingApproval); + }); + }); + }); +});