diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 0c02ae891e..9c89986388 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -995,11 +995,11 @@ export async function handleV2PrebuildAndSignTransaction( * Enables tokens on a wallet * @param req */ -export async function handleV2EnableTokens(req: express.Request) { +export async function handleV2EnableTokens(req: ExpressApiRouteRequest<'express.v2.wallet.enableTokens', '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; try { return wallet.sendTokenEnablements(createSendParams(req)); @@ -1653,12 +1653,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ]); // token enablement - app.post( - '/api/v2/:coin/wallet/:id/enableTokens', - parseBody, - prepareBitGo(config), - promiseWrapper(handleV2EnableTokens) - ); + router.post('express.v2.wallet.enableTokens', [prepareBitGo(config), typedPromiseWrapper(handleV2EnableTokens)]); // unspent changes router.post('express.v2.wallet.consolidateunspents', [ diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index cc8d9e31ad..9e561ccd87 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -46,6 +46,7 @@ import { PostLightningWalletWithdraw } from './v2/lightningWithdraw'; import { PutV2PendingApproval } from './v2/pendingApproval'; import { PostConsolidateAccount } from './v2/consolidateAccount'; import { PostCanonicalAddress } from './v2/canonicalAddress'; +import { PostWalletEnableTokens } from './v2/walletEnableTokens'; import { PostWalletSweep } from './v2/walletSweep'; // Too large types can cause the following error @@ -246,6 +247,12 @@ export const ExpressWalletRecoverTokenApiSpec = apiSpec({ }, }); +export const ExpressWalletEnableTokensApiSpec = apiSpec({ + 'express.v2.wallet.enableTokens': { + post: PostWalletEnableTokens, + }, +}); + export const ExpressCoinSigningApiSpec = apiSpec({ 'express.v2.coin.signtx': { post: PostCoinSignTx, @@ -327,6 +334,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV2WalletSendCoinsApiSpec & typeof ExpressOfcSignPayloadApiSpec & typeof ExpressWalletRecoverTokenApiSpec & + typeof ExpressWalletEnableTokensApiSpec & typeof ExpressCoinSigningApiSpec & typeof ExpressExternalSigningApiSpec & typeof ExpressWalletSigningApiSpec & @@ -365,6 +373,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletSendCoinsApiSpec, ...ExpressOfcSignPayloadApiSpec, ...ExpressWalletRecoverTokenApiSpec, + ...ExpressWalletEnableTokensApiSpec, ...ExpressCoinSigningApiSpec, ...ExpressExternalSigningApiSpec, ...ExpressWalletSigningApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/walletEnableTokens.ts b/modules/express/src/typedRoutes/api/v2/walletEnableTokens.ts new file mode 100644 index 0000000000..6d84686c2c --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/walletEnableTokens.ts @@ -0,0 +1,398 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for enable tokens endpoint + */ +export const EnableTokensParams = { + /** Coin identifier (e.g., 'algo', 'sol', 'xtz', 'trx') */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * EIP-1559 fee parameters for Ethereum transactions + * When eip1559 object is present, both fields are REQUIRED + */ +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 */ + 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 + */ +export const MemoParams = t.type({ + /** Memo value - REQUIRED */ + value: t.string, + /** Memo type - REQUIRED */ + type: t.string, +}); + +/** + * Token enablement configuration + * name is REQUIRED when this object is present + */ +export 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 enabling tokens on a wallet + * Based on BuildTokenEnablementOptions which extends PrebuildTransactionOptions + * + * This endpoint supports the full set of parameters available in the BitGo SDK + * for building, signing, and sending token enablement transactions. + * + * Note: Recipients field is NOT supported for token enablement transactions. + * The SDK will throw an error if recipients are specified. + */ +export const EnableTokensRequestBody = { + /** Array of tokens to enable (REQUIRED - must have at least one token) */ + enableTokens: t.array(TokenEnablement), + + /** 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), + + /** 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), + + /** 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.any), +} as const; + +/** + * Response for enable tokens operation + * Returns arrays of successful and failed token enablement transactions + * + * The success array contains responses from sendTokenEnablement which vary by wallet type: + * - TSS wallets: May include txRequest, transfer, pendingApproval, status fields + * - Hot/Cold wallets: Transaction details (tx, txid, status, transfer, etc.) + * - Custodial wallets: Pending approval or initiation details + * + * All fields are optional to accommodate different wallet types and scenarios. + */ +export const EnableTokensResponse = t.type({ + /** Array of successfully sent token enablement transactions */ + success: t.array(t.unknown), + /** Array of errors from failed token enablement transactions */ + failure: t.array(t.unknown), +}); + +/** + * Enable Tokens on Wallet + * + * Some chains require tokens to be enabled before they can be received or sent. + * This endpoint builds, signs, and sends transactions that enable the specified tokens. + * + * Supported coins: Algorand (algo), Solana (sol), Tezos (xtz), Tron (trx), Stellar (xlm), etc. + * + * The endpoint processes each token enablement as a separate transaction and returns: + * - success: Array of successfully created/sent token enablement transactions + * - failure: Array of errors from failed token enablements + * + * Requirements: + * - enableTokens: Array of tokens to enable with name and optional address + * - walletPassphrase, xprv, or prv: For signing the transactions (required based on wallet type) + * + * Behavior: + * - For hot/cold wallets: Builds, signs, and submits transactions + * - For custodial wallets: Initiates the transactions + * - For TSS wallets: Uses TSS signing flow + * + * Note: The endpoint processes all token enablements and returns partial success if some succeed + * but others fail. Check both success and failure arrays in the response. + * + * @operationId express.v2.wallet.enableTokens + * @tag express + */ +export const PostWalletEnableTokens = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/enableTokens', + method: 'POST', + request: httpRequest({ + params: EnableTokensParams, + body: EnableTokensRequestBody, + }), + response: { + /** Successfully enabled tokens (may include partial failures in failure array) */ + 200: EnableTokensResponse, + /** Invalid request parameters or validation failure */ + 400: BitgoExpressError, + /** Internal server error, wallet not found, SDK errors, or coin operation failures */ + 500: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/clientRoutes/enableTokens.ts b/modules/express/test/unit/clientRoutes/enableTokens.ts index 6c3d262322..f59bf1522c 100644 --- a/modules/express/test/unit/clientRoutes/enableTokens.ts +++ b/modules/express/test/unit/clientRoutes/enableTokens.ts @@ -4,9 +4,8 @@ import 'should-http'; import 'should-sinon'; import '../../lib/asserts'; -import * as express from 'express'; - import { handleV2EnableTokens } from '../../../src/clientRoutes'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; import { BitGo } from 'bitgo'; @@ -20,14 +19,14 @@ describe('Enable tokens', () => { const mockRequest = { bitgo: stubBitgo, - params: { + decoded: { coin: 'tbtc', id: '23423423423423', }, body: { enableTokens: [{ name: 'tsol:usdc' }, { name: 'tsol:usdt' }], }, - } as unknown as express.Request; + } as unknown as ExpressApiRouteRequest<'express.v2.wallet.enableTokens', 'post'>; await handleV2EnableTokens(mockRequest).should.be.resolvedWith('success'); }); diff --git a/modules/express/test/unit/typedRoutes/walletEnableTokens.ts b/modules/express/test/unit/typedRoutes/walletEnableTokens.ts new file mode 100644 index 0000000000..3447657da0 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/walletEnableTokens.ts @@ -0,0 +1,1159 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + EnableTokensParams, + EnableTokensRequestBody, + EnableTokensResponse, + PostWalletEnableTokens, + TokenEnablement, +} from '../../../src/typedRoutes/api/v2/walletEnableTokens'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('WalletEnableTokens codec tests', function () { + describe('walletEnableTokens', function () { + const agent = setupAgent(); + const coin = 'algo'; + const walletId = '68c02f96aa757d9212bd1a536f123456'; + + const mockEnableTokensResponse = { + success: [ + { + tx: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + txid: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + status: 'signed', + transfer: { + id: 'transfer123', + coin: coin, + wallet: walletId, + txid: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + }, + }, + ], + failure: [], + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully enable tokens with walletPassphrase', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }, { name: 'USDT' }], + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('success'); + result.body.should.have.property('failure'); + assert.ok(Array.isArray(result.body.success)); + assert.ok(Array.isArray(result.body.failure)); + assert.strictEqual(result.body.success.length, 1); + assert.strictEqual(result.body.failure.length, 0); + + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 1); + assert.strictEqual(decodedResponse.failure.length, 0); + + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockCoin.wallets.calledOnce, true); + assert.strictEqual(walletsGetStub.calledOnce, true); + // Verify the get method was called with the wallet ID (reqId is added by handler) + const getCallArgs = walletsGetStub.firstCall.args[0]; + assert.strictEqual(getCallArgs.id, walletId); + assert.strictEqual(mockWallet.sendTokenEnablements.calledOnce, true); + }); + + it('should successfully enable single token with prv', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 1); + assert.strictEqual(decodedResponse.failure.length, 0); + }); + + it('should successfully enable token with xprv', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.ok(decodedResponse.success); + }); + + it('should successfully enable tokens with address field (Solana)', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' }, { name: 'USDT' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.ok(decodedResponse.success); + + // Verify the parameters were passed correctly + const callArgs = mockWallet.sendTokenEnablements.firstCall.args[0]; + assert.strictEqual(callArgs.enableTokens.length, 2); + assert.strictEqual(callArgs.enableTokens[0].name, 'USDC'); + assert.strictEqual(callArgs.enableTokens[0].address, 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); + assert.strictEqual(callArgs.enableTokens[1].name, 'USDT'); + }); + + it('should successfully enable tokens with optional fee parameters', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + gasPrice: 20000000000, + gasLimit: 100000, + maxFeeRate: 50000, + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify all parameters were passed to SDK + const callArgs = mockWallet.sendTokenEnablements.firstCall.args[0]; + assert.strictEqual(callArgs.gasPrice, requestBody.gasPrice); + assert.strictEqual(callArgs.gasLimit, requestBody.gasLimit); + assert.strictEqual(callArgs.maxFeeRate, requestBody.maxFeeRate); + }); + + it('should successfully enable tokens with EIP-1559 parameters', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + eip1559: { + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1500000000', + }, + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .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.sendTokenEnablements.firstCall.args[0]; + assert.deepStrictEqual(callArgs.eip1559, requestBody.eip1559); + }); + + it('should handle partial success (some tokens succeed, some fail)', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }, { name: 'INVALID_TOKEN' }], + walletPassphrase: 'test_passphrase', + }; + + const partialSuccessResponse = { + success: [ + { + tx: '0xabc123', + txid: '0xabc123', + status: 'signed', + }, + ], + failure: [ + { + message: 'Token not supported', + name: 'TokenNotSupportedError', + }, + ], + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(partialSuccessResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 1); + assert.strictEqual(decodedResponse.failure.length, 1); + }); + + describe('Error Cases', function () { + it('should handle wallet not found error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Wallet not found')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle sendTokenEnablements failure', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'wrong_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().rejects(new Error('Invalid passphrase')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle no tokens specified error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().rejects(new Error('No tokens are being specified')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle coin does not require token enablement error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().rejects(new Error('Bitcoin does not require token enablements')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/btc/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle unsupported coin error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + sinon.stub(BitGo.prototype, 'coin').throws(new Error('Unsupported coin: invalidcoin')); + + const result = await agent + .post(`/api/v2/invalidcoin/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle insufficient funds for gas error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().rejects(new Error('Insufficient funds for gas')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle recipients field rejection error', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + recipients: [{ address: '0xabc', amount: '1000' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon + .stub() + .rejects(new Error('Can not specify recipients for token enablement transactions')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // SDK should reject recipients field + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + }); + + describe('Invalid Request Body', function () { + it('should reject request without enableTokens field', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should fail validation because enableTokens is required + assert.ok(result.status >= 400); + }); + + it('should reject request with empty enableTokens array', async function () { + const requestBody = { + enableTokens: [], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().rejects(new Error('No tokens are being specified')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // SDK should reject empty array + assert.strictEqual(result.status, 500); + }); + + it('should reject request with invalid enableTokens type', async function () { + const requestBody = { + enableTokens: 'USDC', // string instead of array + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid token object (missing name)', async function () { + const requestBody = { + enableTokens: [{ address: 'someaddress' }], // missing name field + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should fail validation because name is required in TokenEnablement + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid token name type', async function () { + const requestBody = { + enableTokens: [{ name: 123 }], // number instead of string + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid token address type', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC', address: 123 }], // number instead of string + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid walletPassphrase type', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 123, // number instead of string + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid prv type', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + prv: 123, // number instead of string + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid gasPrice type', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + gasPrice: '20000000000', // string instead of number + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid eip1559 structure', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + eip1559: { + maxFeePerGas: '30000000000', + // missing maxPriorityFeePerGas - both fields required when eip1559 is present + }, + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle request with malformed JSON', async function () { + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send('{ invalid json ]'); + + assert.ok(result.status >= 400); + }); + }); + + describe('Edge Cases', function () { + it('should handle very long wallet ID', async function () { + const veryLongWalletId = 'a'.repeat(1000); + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Invalid wallet ID')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${veryLongWalletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle wallet ID with special characters', async function () { + const specialCharWalletId = '../../../etc/passwd'; + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Invalid wallet ID')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${encodeURIComponent(specialCharWalletId)}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle multiple auth methods provided (walletPassphrase and prv)', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should succeed - SDK handles priority of auth methods + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.ok(decodedResponse.success); + }); + + it('should handle enabling large number of tokens', async function () { + const tokens: { name: string }[] = []; + for (let i = 0; i < 50; i++) { + tokens.push({ name: `TOKEN${i}` }); + } + + const requestBody = { + enableTokens: tokens, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should succeed with many tokens + assert.strictEqual(result.status, 200); + const callArgs = mockWallet.sendTokenEnablements.firstCall.args[0]; + assert.strictEqual(callArgs.enableTokens.length, 50); + }); + + it('should handle token names with special characters', async function () { + const requestBody = { + enableTokens: [{ name: 'USD-Coin' }, { name: 'TOKEN_V2' }, { name: 'TOKEN.NEW' }], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(mockEnableTokensResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should succeed with special characters in token names + assert.strictEqual(result.status, 200); + }); + }); + + describe('Response Validation Edge Cases', function () { + it('should accept response with all successes', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }, { name: 'USDT' }], + walletPassphrase: 'test_passphrase', + }; + + const allSuccessResponse = { + success: [ + { tx: '0xabc123', txid: '0xabc123', status: 'signed' }, + { tx: '0xdef456', txid: '0xdef456', status: 'signed' }, + ], + failure: [], + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(allSuccessResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 2); + assert.strictEqual(decodedResponse.failure.length, 0); + }); + + it('should accept response with all failures', async function () { + const requestBody = { + enableTokens: [{ name: 'INVALID1' }, { name: 'INVALID2' }], + walletPassphrase: 'test_passphrase', + }; + + const allFailureResponse = { + success: [], + failure: [ + { message: 'Token not supported', name: 'TokenError' }, + { message: 'Token not supported', name: 'TokenError' }, + ], + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(allFailureResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 0); + assert.strictEqual(decodedResponse.failure.length, 2); + }); + + it('should accept response with varying transaction structures (TSS wallet)', async function () { + const requestBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'test_passphrase', + }; + + const tssResponse = { + success: [ + { + txRequest: { txRequestId: 'req123', state: 'pending' }, + pendingApproval: { id: 'approval123' }, + status: 'pendingApproval', + }, + ], + failure: [], + }; + + const mockWallet = { + sendTokenEnablements: sinon.stub().resolves(tssResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/enableTokens`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(EnableTokensResponse, result.body); + assert.strictEqual(decodedResponse.success.length, 1); + }); + }); + }); + + // ========================================== + // CODEC VALIDATION TESTS + // ========================================== + + describe('EnableTokensParams', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'algo', + id: '123456789abcdef', + }; + + const decoded = assertDecode(t.type(EnableTokensParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing coin', function () { + const invalidParams = { + id: '123456789abcdef', + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'algo', + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensParams), invalidParams); + }); + }); + + it('should reject params with non-string coin', function () { + const invalidParams = { + coin: 123, + id: '123456789abcdef', + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + coin: 'algo', + id: 123, + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensParams), invalidParams); + }); + }); + }); + + describe('TokenEnablement', function () { + it('should validate token with name only', function () { + const validToken = { + name: 'USDC', + }; + + const decoded = assertDecode(TokenEnablement, validToken); + assert.strictEqual(decoded.name, 'USDC'); + assert.strictEqual(decoded.address, undefined); + }); + + it('should validate token with name and address', function () { + const validToken = { + name: 'USDC', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }; + + const decoded = assertDecode(TokenEnablement, validToken); + assert.strictEqual(decoded.name, 'USDC'); + assert.strictEqual(decoded.address, 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); + }); + + it('should reject token without name', function () { + const invalidToken = { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }; + + assert.throws(() => { + assertDecode(TokenEnablement, invalidToken); + }); + }); + + it('should reject token with non-string name', function () { + const invalidToken = { + name: 123, + }; + + assert.throws(() => { + assertDecode(TokenEnablement, invalidToken); + }); + }); + + it('should reject token with non-string address', function () { + const invalidToken = { + name: 'USDC', + address: 123, + }; + + assert.throws(() => { + assertDecode(TokenEnablement, invalidToken); + }); + }); + }); + + describe('EnableTokensRequestBody', function () { + it('should reject empty body (enableTokens required)', function () { + const invalidBody = {}; + + // Should fail because enableTokens is required + assert.throws(() => { + assertDecode(t.type(EnableTokensRequestBody), invalidBody); + }); + }); + + it('should validate body with enableTokens only', function () { + const validBody = { + enableTokens: [{ name: 'USDC' }], + }; + + const decoded = assertDecode(t.type(EnableTokensRequestBody), validBody); + assert.strictEqual(decoded.enableTokens.length, 1); + assert.strictEqual(decoded.enableTokens[0].name, 'USDC'); + }); + + it('should validate body with enableTokens and walletPassphrase', function () { + const validBody = { + enableTokens: [{ name: 'USDC' }], + walletPassphrase: 'mySecurePassphrase', + }; + + const decoded = assertDecode(t.type(EnableTokensRequestBody), validBody); + assert.strictEqual(decoded.enableTokens.length, 1); + assert.strictEqual(decoded.walletPassphrase, 'mySecurePassphrase'); + }); + + it('should validate body with multiple tokens', function () { + const validBody = { + enableTokens: [{ name: 'USDC' }, { name: 'USDT', address: '0x123' }, { name: 'DAI' }], + }; + + const decoded = assertDecode(t.type(EnableTokensRequestBody), validBody); + assert.strictEqual(decoded.enableTokens.length, 3); + assert.strictEqual(decoded.enableTokens[0].name, 'USDC'); + assert.strictEqual(decoded.enableTokens[1].name, 'USDT'); + assert.strictEqual(decoded.enableTokens[1].address, '0x123'); + }); + + it('should validate body with fee parameters', function () { + const validBody = { + enableTokens: [{ name: 'USDC' }], + gasPrice: 20000000000, + gasLimit: 100000, + maxFeeRate: 50000, + }; + + const decoded = assertDecode(t.type(EnableTokensRequestBody), validBody); + assert.strictEqual(decoded.gasPrice, 20000000000); + assert.strictEqual(decoded.gasLimit, 100000); + assert.strictEqual(decoded.maxFeeRate, 50000); + }); + + it('should reject body with non-array enableTokens', function () { + const invalidBody = { + enableTokens: 'USDC', + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensRequestBody), invalidBody); + }); + }); + + it('should reject body with invalid token in array', function () { + const invalidBody = { + enableTokens: [{ name: 'USDC' }, { address: '0x123' }], // second token missing name + }; + + assert.throws(() => { + assertDecode(t.type(EnableTokensRequestBody), invalidBody); + }); + }); + }); + + describe('EnableTokensResponse', function () { + it('should validate response with success and failure arrays', function () { + const validResponse = { + success: [{ tx: '0xabc', status: 'signed' }], + failure: [], + }; + + const decoded = assertDecode(EnableTokensResponse, validResponse); + assert.strictEqual(decoded.success.length, 1); + assert.strictEqual(decoded.failure.length, 0); + }); + + it('should validate response with empty success array', function () { + const validResponse = { + success: [], + failure: [{ message: 'Error occurred' }], + }; + + const decoded = assertDecode(EnableTokensResponse, validResponse); + assert.strictEqual(decoded.success.length, 0); + assert.strictEqual(decoded.failure.length, 1); + }); + + it('should validate response with both arrays populated', function () { + const validResponse = { + success: [ + { tx: '0xabc', status: 'signed' }, + { txid: '0xdef', status: 'accepted' }, + ], + failure: [{ message: 'Token not supported' }], + }; + + const decoded = assertDecode(EnableTokensResponse, validResponse); + assert.strictEqual(decoded.success.length, 2); + assert.strictEqual(decoded.failure.length, 1); + }); + + it('should reject response with missing success array', function () { + const invalidResponse = { + failure: [], + }; + + assert.throws(() => { + assertDecode(EnableTokensResponse, invalidResponse); + }); + }); + + it('should reject response with missing failure array', function () { + const invalidResponse = { + success: [], + }; + + assert.throws(() => { + assertDecode(EnableTokensResponse, invalidResponse); + }); + }); + + it('should reject response with non-array success', function () { + const invalidResponse = { + success: 'success', + failure: [], + }; + + assert.throws(() => { + assertDecode(EnableTokensResponse, invalidResponse); + }); + }); + + it('should reject response with non-array failure', function () { + const invalidResponse = { + success: [], + failure: 'failure', + }; + + assert.throws(() => { + assertDecode(EnableTokensResponse, invalidResponse); + }); + }); + }); + + describe('PostWalletEnableTokens route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostWalletEnableTokens.path, '/api/v2/{coin}/wallet/{id}/enableTokens'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostWalletEnableTokens.method, 'POST'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PostWalletEnableTokens.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PostWalletEnableTokens.response[200]); + assert.ok(PostWalletEnableTokens.response[400]); + }); + }); +});