From 665160ee16b7a3d3f02b0a05122db5dc0439ec95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Thu, 25 Sep 2025 21:57:44 +0900 Subject: [PATCH 01/29] wip --- src/config/unifiedZapConfig.js | 181 +++++++++++++++++----------- src/executors/UnifiedZapExecutor.js | 35 +++--- src/protocols/AaveProtocol.js | 43 +++---- src/protocols/BaseProtocolV2.js | 38 +++--- src/protocols/PendlePTProtocol.js | 40 +++--- src/protocols/VelodromeProtocol.js | 95 ++++++++------- 6 files changed, 227 insertions(+), 205 deletions(-) diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index f41e7fe..9500a80 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -31,87 +31,106 @@ const UNIFIED_ZAP_CONFIG = { assetDecimals: 6, }, }, - // Pendle PT gUSDC on Arbitrum + // Aave USDC on Arbitrum { - id: 'pendle-pt-gusdc-arbitrum', - name: 'Pendle PT gUSDC (Arbitrum)', - implementation: 'PendlePTProtocol', + id: 'aave-usdc-arbitrum', + name: 'Aave USDC (Arbitrum)', + implementation: 'AaveProtocol', chain: 'arbitrum', chainId: 42161, - weight: 25, + weight: 100, enabled: true, config: { mode: 'single', - marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', - assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', - ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', + symbolOfBestTokenToZapInOut: 'usdc', + zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + assetAddress: '0x625e7708f30ca75bfd92586e17077590c60eb4cd', + protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', assetDecimals: 6, - symbolOfBestTokenToZapOut: 'usdc', - bestTokenAddressToZapOut: - '0xaf88d065e77c8cc2239327c5edb3a432268e5831', - decimalOfBestTokenToZapOut: 6, - }, - }, - // Velodrome BOLD/USDC LP on Base - { - id: 'velodrome-bold-usdc-base', - name: 'Velodrome BOLD/USDC LP (Base)', - implementation: 'VelodromeProtocol', - chain: 'base', - chainId: 8453, - weight: 30, - enabled: true, - config: { - mode: 'LP', - protocolName: 'aerodrome', - protocolVersion: '0', - assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', - assetDecimals: 18, - routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', - guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', - lpTokens: [ - ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], - ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], - ], - rewards: [ - { - symbol: 'aero', - address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', - decimals: 18, - }, - ], - }, - }, - // Velodrome USDC/sUSD LP on Optimism - { - id: 'velodrome-usdc-susd-optimism', - name: 'Velodrome USDC/sUSD LP (Optimism)', - implementation: 'VelodromeProtocol', - chain: 'optimism', - chainId: 10, - weight: 25, - enabled: true, - config: { - mode: 'LP', - protocolName: 'velodrome', - protocolVersion: 'v2', - assetAddress: '0xbC26519f936A90E78fe2C9aA2A03CC208f041234', - assetDecimals: 18, - routerAddress: '0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858', - guageAddress: '0x0E4c56B4a766968b12c286f67aE341b11eDD8b8d', - lpTokens: [ - ['usdc', '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', 6], - ['susd', '0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9', 18], - ], - rewards: [ - { - symbol: 'velo', - address: '0x9560e827af36c94d2ac33a39bce1fe78631088db', - decimals: 18, - }, - ], }, }, + // // Pendle PT gUSDC on Arbitrum + // { + // id: 'pendle-pt-gusdc-arbitrum', + // name: 'Pendle PT gUSDC (Arbitrum)', + // implementation: 'PendlePTProtocol', + // chain: 'arbitrum', + // chainId: 42161, + // weight: 25, + // enabled: true, + // config: { + // mode: 'single', + // marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', + // assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', + // protocolAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', // Add this line - using marketAddress + // ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', + // assetDecimals: 6, + // symbolOfBestTokenToZapOut: 'usdc', + // bestTokenAddressToZapOut: + // '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + // decimalOfBestTokenToZapOut: 6, + // }, + // }, + // // Velodrome BOLD/USDC LP on Base + // { + // id: 'velodrome-bold-usdc-base', + // name: 'Velodrome BOLD/USDC LP (Base)', + // implementation: 'VelodromeProtocol', + // chain: 'base', + // chainId: 8453, + // weight: 30, + // enabled: true, + // config: { + // mode: 'LP', + // protocolName: 'aerodrome', + // protocolVersion: '0', + // assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', + // assetDecimals: 18, + // routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', + // guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', + // lpTokens: [ + // ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], + // ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], + // ], + // rewards: [ + // { + // symbol: 'aero', + // address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', + // decimals: 18, + // }, + // ], + // }, + // }, + // // Velodrome USDC/sUSD LP on Optimism + // { + // id: 'velodrome-usdc-susd-optimism', + // name: 'Velodrome USDC/sUSD LP (Optimism)', + // implementation: 'VelodromeProtocol', + // chain: 'optimism', + // chainId: 10, + // weight: 25, + // enabled: true, + // config: { + // mode: 'LP', + // protocolName: 'velodrome', + // protocolVersion: 'v2', + // assetAddress: '0xbC26519f936A90E78fe2C9aA2A03CC208f041234', + // assetDecimals: 18, + // routerAddress: '0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858', + // guageAddress: '0x0E4c56B4a766968b12c286f67aE341b11eDD8b8d', + // lpTokens: [ + // ['usdc', '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', 6], + // ['susd', '0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9', 18], + // ], + // rewards: [ + // { + // symbol: 'velo', + // address: '0x9560e827af36c94d2ac33a39bce1fe78631088db', + // decimals: 18, + // }, + // ], + // }, + // }, ], }, @@ -157,6 +176,24 @@ const UNIFIED_ZAP_CONFIG = { assetDecimals: 18, }, }, + // Aave WETH on Arbitrum + { + id: 'aave-weth-arbitrum', + name: 'Aave WETH (Arbitrum)', + implementation: 'AaveProtocol', + chain: 'arbitrum', + chainId: 42161, + weight: 100, + enabled: true, + config: { + mode: 'single', + symbolOfBestTokenToZapInOut: 'weth', + zapInOutTokenAddress: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + assetAddress: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + assetDecimals: 18, + }, + }, ], }, diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index eb5e8c2..12b4361 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -5,7 +5,6 @@ const { protocolFactory } = require('../protocols'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); -const { ethers } = require('ethers'); class UnifiedZapExecutor { constructor(swapService, priceService, rebalanceClient) { @@ -29,8 +28,8 @@ class UnifiedZapExecutor { slippage = UNIFIED_ZAP_CONFIG.DEFAULT_SLIPPAGE, } = params; - // Convert input amount to BigNumber - const totalAmount = ethers.BigNumber.from(inputAmount); + // Convert input amount to BigInt + const totalAmount = BigInt(inputAmount); // Phase 1: Parse strategies into protocol allocations const protocolAllocations = await this._parseStrategyAllocations( @@ -154,7 +153,7 @@ class UnifiedZapExecutor { async estimateGas(executionContext, transactions) { const { userAddress, protocolAllocations } = executionContext; - let totalGas = ethers.BigNumber.from(0); + let totalGas = 0n; const protocolGasEstimates = {}; // Group transactions by protocol for estimation @@ -174,7 +173,7 @@ class UnifiedZapExecutor { ); protocolGasEstimates[protocolId] = gasEstimate; - totalGas = totalGas.add(gasEstimate.total.gasLimit); + totalGas = totalGas + BigInt(gasEstimate.total.gasLimit); } } catch (error) { console.warn( @@ -183,14 +182,12 @@ class UnifiedZapExecutor { ); // Fallback estimation based on transaction count - const fallbackGas = ethers.BigNumber.from(150000).mul( - protocolTxs.length - ); + const fallbackGas = BigInt(150000) * BigInt(protocolTxs.length); protocolGasEstimates[protocolId] = { total: { gasLimit: fallbackGas }, estimated: true, }; - totalGas = totalGas.add(fallbackGas); + totalGas = totalGas + fallbackGas; } } @@ -249,7 +246,7 @@ class UnifiedZapExecutor { /** * Parse strategy allocations into protocol-level allocations * @param {Array} strategyAllocations - Strategy allocations from request - * @param {BigNumber} totalAmount - Total amount to allocate + * @param {BigInt} totalAmount - Total amount to allocate * @param {number} chainId - Chain ID for filtering protocols * @returns {Promise} - Protocol allocations * @private @@ -266,9 +263,8 @@ class UnifiedZapExecutor { } // Calculate strategy amount - const strategyAmount = totalAmount - .mul(Math.floor(percentage * 100)) - .div(10000); + const strategyAmount = + (totalAmount * BigInt(Math.floor(percentage * 100))) / 10000n; // Get protocols for this strategy const strategyProtocols = this.protocolFactory.createProtocolsForStrategy( @@ -289,9 +285,8 @@ class UnifiedZapExecutor { ); for (const protocol of strategyProtocols) { - const protocolAmount = strategyAmount - .mul(protocol.weight) - .div(totalWeight); + const protocolAmount = + (strategyAmount * BigInt(protocol.weight)) / BigInt(totalWeight); protocolAllocations.push({ ...protocol, @@ -455,7 +450,7 @@ class UnifiedZapExecutor { if (lpTokens.length === 2) { // Calculate token amounts for LP (50/50 split for simplicity) - const halfAmount = amount.div(2); + const halfAmount = amount / 2n; // Generate approvals for both tokens for (const token of lpTokens) { @@ -519,7 +514,7 @@ class UnifiedZapExecutor { * Get gas estimate for specific transaction * @param {Object} transaction - Transaction object * @param {Object} gasEstimates - Overall gas estimates - * @returns {BigNumber} - Gas estimate for transaction + * @returns {BigInt} - Gas estimate for transaction * @private */ _getTransactionGasEstimate(transaction, gasEstimates) { @@ -534,9 +529,9 @@ class UnifiedZapExecutor { transaction.description && transaction.description.toLowerCase().includes('approve') ) { - return ethers.BigNumber.from('50000'); + return BigInt(50000); } else { - return ethers.BigNumber.from('200000'); + return BigInt(200000); } } diff --git a/src/protocols/AaveProtocol.js b/src/protocols/AaveProtocol.js index 7cd716d..66fc482 100644 --- a/src/protocols/AaveProtocol.js +++ b/src/protocols/AaveProtocol.js @@ -24,7 +24,7 @@ class AaveProtocol extends BaseProtocolV2 { * Generate deposit transaction for Aave lending * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address (should match underlying) - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Additional parameters (unused for Aave) * @returns {Promise} - Deposit transaction object */ @@ -37,12 +37,11 @@ class AaveProtocol extends BaseProtocolV2 { this._validateAddress(userAddress); this._validateAddress(inputToken); - // Convert amount to BigNumber if needed - const depositAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt if needed + const depositAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (depositAmount.isZero()) { + if (depositAmount === 0n) { throw new Error('Deposit amount cannot be zero'); } @@ -75,19 +74,18 @@ class AaveProtocol extends BaseProtocolV2 { * Estimate gas for Aave operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(userAddress, inputToken, amount) { try { - // Convert amount to BigNumber - const _processAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt + const _processAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); // Base estimates for Aave operations - const approvalGas = ethers.BigNumber.from('50000'); // ~50k gas for approval - const supplyGas = ethers.BigNumber.from('200000'); // ~200k gas for supply + const approvalGas = BigInt(50000); // ~50k gas for approval + const supplyGas = BigInt(200000); // ~200k gas for supply return { approval: { @@ -99,7 +97,7 @@ class AaveProtocol extends BaseProtocolV2 { description: 'Supply tokens to Aave', }, total: { - gasLimit: approvalGas.add(supplyGas), + gasLimit: approvalGas + supplyGas, description: 'Total estimated gas', }, }; @@ -153,7 +151,7 @@ class AaveProtocol extends BaseProtocolV2 { 'zapInOutTokenAddress', ]; addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + if (!ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); @@ -170,7 +168,7 @@ class AaveProtocol extends BaseProtocolV2 { /** * Encode Aave supply function call * @param {string} asset - Asset address to supply - * @param {BigNumber} amount - Amount to supply + * @param {BigInt} amount - Amount to supply * @param {string} onBehalfOf - Address to receive aTokens * @param {number} referralCode - Referral code (default 0) * @returns {string} - Encoded function data @@ -178,7 +176,7 @@ class AaveProtocol extends BaseProtocolV2 { */ _encodeSupplyCall(asset, amount, onBehalfOf, referralCode = 0) { // Aave V3 Pool interface - const poolInterface = new ethers.utils.Interface([ + const poolInterface = new ethers.Interface([ 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', ]); @@ -216,15 +214,14 @@ class AaveProtocol extends BaseProtocolV2 { /** * Get withdrawal transaction (for future use) * @param {string} userAddress - User wallet address - * @param {BigNumber|string} amount - Amount to withdraw + * @param {BigInt|string} amount - Amount to withdraw * @returns {Promise} - Withdrawal transaction */ getWithdrawalTransaction(userAddress, amount) { this._validateAddress(userAddress); - const withdrawAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + const withdrawAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); const withdrawData = this._encodeWithdrawCall( this.underlyingTokenAddress, @@ -244,13 +241,13 @@ class AaveProtocol extends BaseProtocolV2 { /** * Encode Aave withdraw function call * @param {string} asset - Asset address to withdraw - * @param {BigNumber} amount - Amount to withdraw (use ethers.constants.MaxUint256 for max) + * @param {BigInt} amount - Amount to withdraw (use ethers.MaxUint256 for max) * @param {string} to - Address to receive tokens * @returns {string} - Encoded function data * @private */ _encodeWithdrawCall(asset, amount, to) { - const poolInterface = new ethers.utils.Interface([ + const poolInterface = new ethers.Interface([ 'function withdraw(address asset, uint256 amount, address to) returns (uint256)', ]); diff --git a/src/protocols/BaseProtocolV2.js b/src/protocols/BaseProtocolV2.js index 3bdcf7c..4f06eed 100644 --- a/src/protocols/BaseProtocolV2.js +++ b/src/protocols/BaseProtocolV2.js @@ -27,23 +27,19 @@ class BaseProtocolV2 { * @param {string} userAddress - User wallet address * @param {string} tokenAddress - Token contract address * @param {string} spenderAddress - Spender contract address - * @param {BigNumber|string} amount - Amount to approve + * @param {BigInt|string} amount - Amount to approve * @returns {Promise} - Approval transaction object */ getApprovalTransaction(userAddress, tokenAddress, spenderAddress, amount) { - // Convert amount to BigNumber if needed - const approvalAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + // Convert amount to BigInt if needed + const approvalAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (approvalAmount.isZero()) { + if (approvalAmount === 0n) { throw new Error('Approval amount cannot be zero'); } - if ( - !ethers.utils.isAddress(tokenAddress) || - !ethers.utils.isAddress(spenderAddress) - ) { + if (!ethers.isAddress(tokenAddress) || !ethers.isAddress(spenderAddress)) { throw new Error('Invalid token or spender address'); } @@ -66,7 +62,7 @@ class BaseProtocolV2 { * Generate deposit transaction for protocol * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Protocol-specific parameters * @returns {Promise} - Deposit transaction object */ @@ -85,7 +81,7 @@ class BaseProtocolV2 { * Estimate gas for all protocol operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @param {Object} additionalParams - Protocol-specific parameters * @returns {Promise} - Gas estimates */ @@ -203,12 +199,12 @@ class BaseProtocolV2 { /** * Encode ERC20 approval transaction data * @param {string} spender - Spender address - * @param {BigNumber} amount - Approval amount + * @param {BigInt} amount - Approval amount * @returns {string} - Encoded transaction data * @private */ _encodeERC20Approval(spender, amount) { - const iface = new ethers.utils.Interface([ + const iface = new ethers.Interface([ 'function approve(address spender, uint256 amount) returns (bool)', ]); @@ -217,14 +213,14 @@ class BaseProtocolV2 { /** * Format amount for display - * @param {BigNumber} amount - Amount to format + * @param {BigInt} amount - Amount to format * @param {number} decimals - Token decimals * @returns {string} - Formatted amount * @private */ _formatAmount(amount, decimals = 18) { try { - return ethers.utils.formatUnits(amount, decimals); + return ethers.formatUnits(amount, decimals); } catch { return amount.toString(); } @@ -241,15 +237,15 @@ class BaseProtocolV2 { /** * Apply slippage to amount - * @param {BigNumber} amount - Original amount + * @param {BigInt} amount - Original amount * @param {number} slippage - Slippage percentage (0.5 for 0.5%) - * @returns {BigNumber} - Amount with slippage applied + * @returns {BigInt} - Amount with slippage applied * @protected */ _applySlippage(amount, slippage) { const slippageBasisPoints = Math.floor(slippage * 100); - const multiplier = ethers.BigNumber.from(10000 - slippageBasisPoints); - return amount.mul(multiplier).div(10000); + const multiplier = BigInt(10000 - slippageBasisPoints); + return (amount * multiplier) / 10000n; } /** @@ -259,7 +255,7 @@ class BaseProtocolV2 { * @protected */ _validateAddress(address) { - if (!address || !ethers.utils.isAddress(address)) { + if (!address || !ethers.isAddress(address)) { throw new Error(`Invalid address: ${address}`); } } diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index bf11190..20c11fc 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -28,7 +28,7 @@ class PendlePTProtocol extends BaseProtocolV2 { * Generate deposit transaction for Pendle PT minting * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Slippage and other params * @returns {Promise} - Deposit transaction object */ @@ -41,11 +41,10 @@ class PendlePTProtocol extends BaseProtocolV2 { this._validateAddress(userAddress); this._validateAddress(inputToken); - const depositAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + const depositAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - if (depositAmount.isZero()) { + if (depositAmount === 0n) { throw new Error('Deposit amount cannot be zero'); } @@ -77,14 +76,14 @@ class PendlePTProtocol extends BaseProtocolV2 { * Estimate gas for Pendle operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(_userAddress, _inputToken, _amount) { try { // Pendle operations are more gas-intensive due to market interactions - const approvalGas = ethers.BigNumber.from('50000'); - const mintPTGas = ethers.BigNumber.from('400000'); // PT minting is complex + const approvalGas = BigInt(50000); + const mintPTGas = BigInt(400000); // PT minting is complex return { approval: { @@ -96,7 +95,7 @@ class PendlePTProtocol extends BaseProtocolV2 { description: 'Mint PT tokens via Pendle', }, total: { - gasLimit: approvalGas.add(mintPTGas), + gasLimit: approvalGas + mintPTGas, description: 'Total estimated gas for Pendle PT', }, }; @@ -153,7 +152,7 @@ class PendlePTProtocol extends BaseProtocolV2 { 'bestTokenAddressToZapOut', ]; addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + if (!ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); @@ -162,7 +161,7 @@ class PendlePTProtocol extends BaseProtocolV2 { /** * Get PT minting transaction * @param {string} userAddress - User address - * @param {BigNumber} amount - Amount to mint + * @param {BigInt} amount - Amount to mint * @param {number} slippage - Slippage tolerance * @param {number} deadline - Transaction deadline * @returns {Object} - Mint transaction @@ -194,15 +193,15 @@ class PendlePTProtocol extends BaseProtocolV2 { * Encode Pendle PT minting call * @param {string} receiver - Address to receive PT+YT * @param {string} market - Market address - * @param {BigNumber} netTokenIn - Amount of underlying token - * @param {BigNumber} minPTOut - Minimum PT tokens to receive + * @param {BigInt} netTokenIn - Amount of underlying token + * @param {BigInt} minPTOut - Minimum PT tokens to receive * @param {number} deadline - Transaction deadline * @returns {string} - Encoded function data * @private */ _encodeMintPTCall(receiver, market, netTokenIn, minPTOut, _deadline) { // Simplified Pendle Router interface for PT minting - const routerInterface = new ethers.utils.Interface([ + const routerInterface = new ethers.Interface([ 'function mintPyFromToken(address receiver, address market, uint256 minPyOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netPyOut, uint256 netSyFee)', ]); @@ -284,15 +283,14 @@ class PendlePTProtocol extends BaseProtocolV2 { /** * Get redemption transaction (for matured PT) * @param {string} userAddress - User wallet address - * @param {BigNumber|string} amount - PT amount to redeem + * @param {BigInt|string} amount - PT amount to redeem * @returns {Promise} - Redemption transaction */ getRedemptionTransaction(userAddress, amount) { this._validateAddress(userAddress); - const redeemAmount = ethers.BigNumber.isBigNumber(amount) - ? amount - : ethers.BigNumber.from(amount.toString()); + const redeemAmount = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); const redeemData = this._encodeRedeemPTCall( userAddress, @@ -313,18 +311,18 @@ class PendlePTProtocol extends BaseProtocolV2 { * Encode PT redemption call * @param {string} receiver - Address to receive underlying tokens * @param {string} market - Market address - * @param {BigNumber} netPyIn - Amount of PT to redeem + * @param {BigInt} netPyIn - Amount of PT to redeem * @returns {string} - Encoded function data * @private */ _encodeRedeemPTCall(receiver, market, netPyIn) { - const routerInterface = new ethers.utils.Interface([ + const routerInterface = new ethers.Interface([ 'function redeemPyToToken(address receiver, address market, uint256 netPyIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netTokenOut, uint256 netSyFee)', ]); const tokenOutput = { tokenOut: this.underlyingTokenAddress, - minTokenOut: ethers.BigNumber.from(0), // Would calculate based on slippage + minTokenOut: 0n, // Would calculate based on slippage tokenRedeemSy: this.underlyingTokenAddress, pendleSwap: ethers.constants.AddressZero, swapData: { diff --git a/src/protocols/VelodromeProtocol.js b/src/protocols/VelodromeProtocol.js index e7d6a7f..502be28 100644 --- a/src/protocols/VelodromeProtocol.js +++ b/src/protocols/VelodromeProtocol.js @@ -39,7 +39,7 @@ class VelodromeProtocol extends BaseProtocolV2 { * Generate deposit transaction for Velodrome LP provision * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to deposit + * @param {BigInt|string} amount - Amount to deposit * @param {Object} additionalParams - Token amounts and slippage * @returns {Promise} - Deposit transaction object */ @@ -59,15 +59,17 @@ class VelodromeProtocol extends BaseProtocolV2 { ); } - const amount0 = ethers.BigNumber.isBigNumber(token0Amount) - ? token0Amount - : ethers.BigNumber.from(token0Amount.toString()); + const amount0 = + typeof token0Amount === 'bigint' + ? token0Amount + : BigInt(token0Amount.toString()); - const amount1 = ethers.BigNumber.isBigNumber(token1Amount) - ? token1Amount - : ethers.BigNumber.from(token1Amount.toString()); + const amount1 = + typeof token1Amount === 'bigint' + ? token1Amount + : BigInt(token1Amount.toString()); - if (amount0.isZero() || amount1.isZero()) { + if (amount0 === 0n || amount1 === 0n) { throw new Error('Both token amounts must be greater than zero'); } @@ -118,17 +120,16 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Generate staking transaction for LP tokens in gauge * @param {string} userAddress - User wallet address - * @param {BigNumber|string} lpAmount - LP token amount to stake + * @param {BigInt|string} lpAmount - LP token amount to stake * @returns {Promise} - Staking transaction */ getStakingTransaction(userAddress, lpAmount) { this._validateAddress(userAddress); - const stakeAmount = ethers.BigNumber.isBigNumber(lpAmount) - ? lpAmount - : ethers.BigNumber.from(lpAmount.toString()); + const stakeAmount = + typeof lpAmount === 'bigint' ? lpAmount : BigInt(lpAmount.toString()); - if (stakeAmount.isZero()) { + if (stakeAmount === 0n) { throw new Error('Stake amount cannot be zero'); } @@ -147,20 +148,20 @@ class VelodromeProtocol extends BaseProtocolV2 { * Estimate gas for Velodrome operations * @param {string} userAddress - User wallet address * @param {string} inputToken - Input token address - * @param {BigNumber|string} amount - Amount to process + * @param {BigInt|string} amount - Amount to process * @returns {Promise} - Gas estimates */ estimateGas(_userAddress, _inputToken, _amount) { try { // LP operations require multiple approvals and higher gas - const token0ApprovalGas = ethers.BigNumber.from('50000'); - const token1ApprovalGas = ethers.BigNumber.from('50000'); - const addLiquidityGas = ethers.BigNumber.from('300000'); - const stakeGas = ethers.BigNumber.from('150000'); + const token0ApprovalGas = BigInt(50000); + const token1ApprovalGas = BigInt(50000); + const addLiquidityGas = BigInt(300000); + const stakeGas = BigInt(150000); return { approvals: { - gasLimit: token0ApprovalGas.add(token1ApprovalGas), + gasLimit: token0ApprovalGas + token1ApprovalGas, description: 'Approve both LP tokens', }, addLiquidity: { @@ -172,10 +173,8 @@ class VelodromeProtocol extends BaseProtocolV2 { description: 'Stake LP tokens in gauge', }, total: { - gasLimit: token0ApprovalGas - .add(token1ApprovalGas) - .add(addLiquidityGas) - .add(stakeGas), + gasLimit: + token0ApprovalGas + token1ApprovalGas + addLiquidityGas + stakeGas, description: 'Total estimated gas for LP + staking', }, }; @@ -247,7 +246,7 @@ class VelodromeProtocol extends BaseProtocolV2 { throw new Error(`Invalid symbol for lpTokens[${index}]: ${symbol}`); } - if (!ethers.utils.isAddress(address)) { + if (!ethers.isAddress(address)) { throw new Error(`Invalid address for lpTokens[${index}]: ${address}`); } @@ -259,7 +258,7 @@ class VelodromeProtocol extends BaseProtocolV2 { // Validate addresses const addresses = ['routerAddress', 'guageAddress', 'assetAddress']; addresses.forEach(key => { - if (!ethers.utils.isAddress(this.config[key])) { + if (!ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); @@ -294,10 +293,10 @@ class VelodromeProtocol extends BaseProtocolV2 { * Sort tokens according to Velodrome requirements * @param {string} tokenA - Token A address * @param {string} tokenB - Token B address - * @param {BigNumber} amountA - Amount A - * @param {BigNumber} amountB - Amount B - * @param {BigNumber} minA - Min amount A - * @param {BigNumber} minB - Min amount B + * @param {BigInt} amountA - Amount A + * @param {BigInt} amountB - Amount B + * @param {BigInt} minA - Min amount A + * @param {BigInt} minB - Min amount B * @returns {Array} - Sorted tokens and amounts * @private */ @@ -316,10 +315,10 @@ class VelodromeProtocol extends BaseProtocolV2 { * @param {string} tokenA - Token A address * @param {string} tokenB - Token B address * @param {boolean} stable - Whether pool is stable - * @param {BigNumber} amountADesired - Desired amount A - * @param {BigNumber} amountBDesired - Desired amount B - * @param {BigNumber} amountAMin - Minimum amount A - * @param {BigNumber} amountBMin - Minimum amount B + * @param {BigInt} amountADesired - Desired amount A + * @param {BigInt} amountBDesired - Desired amount B + * @param {BigInt} amountAMin - Minimum amount A + * @param {BigInt} amountBMin - Minimum amount B * @param {string} to - Recipient address * @param {number} deadline - Transaction deadline * @returns {string} - Encoded function data @@ -336,7 +335,7 @@ class VelodromeProtocol extends BaseProtocolV2 { to, deadline ) { - const routerInterface = new ethers.utils.Interface([ + const routerInterface = new ethers.Interface([ 'function addLiquidity(address tokenA, address tokenB, bool stable, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity)', ]); @@ -355,12 +354,12 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Encode stake call for gauge contract - * @param {BigNumber} amount - Amount to stake + * @param {BigInt} amount - Amount to stake * @returns {string} - Encoded function data * @private */ _encodeStakeCall(amount) { - const gaugeInterface = new ethers.utils.Interface([ + const gaugeInterface = new ethers.Interface([ 'function deposit(uint256 amount)', ]); @@ -393,15 +392,15 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Get required approvals for LP provision * @param {string} userAddress - User address - * @param {BigNumber} amount0 - Token 0 amount - * @param {BigNumber} amount1 - Token 1 amount + * @param {BigInt} amount0 - Token 0 amount + * @param {BigInt} amount1 - Token 1 amount * @returns {Array} - Array of approval transactions */ async getRequiredApprovals(userAddress, amount0, amount1) { const approvals = []; // Approve token0 - if (!amount0.isZero()) { + if (amount0 !== 0n) { approvals.push( await this.getApprovalTransaction( userAddress, @@ -413,7 +412,7 @@ class VelodromeProtocol extends BaseProtocolV2 { } // Approve token1 - if (!amount1.isZero()) { + if (amount1 !== 0n) { approvals.push( await this.getApprovalTransaction( userAddress, @@ -429,7 +428,7 @@ class VelodromeProtocol extends BaseProtocolV2 { /** * Calculate token amounts for balanced LP provision - * @param {BigNumber} totalValue - Total USD value to invest + * @param {BigInt} totalValue - Total USD value to invest * @param {Object} tokenPrices - Token price mapping * @returns {Object} - Calculated token amounts */ @@ -444,15 +443,15 @@ class VelodromeProtocol extends BaseProtocolV2 { } // Simple 50/50 split for LP provision - const halfValue = totalValue.div(2); + const halfValue = totalValue / 2n; - const token0Amount = halfValue - .mul(ethers.utils.parseUnits('1', this.token0.decimals)) - .div(ethers.utils.parseUnits(token0Price.toString(), 18)); + const token0Amount = + (halfValue * ethers.parseUnits('1', this.token0.decimals)) / + ethers.parseUnits(token0Price.toString(), 18); - const token1Amount = halfValue - .mul(ethers.utils.parseUnits('1', this.token1.decimals)) - .div(ethers.utils.parseUnits(token1Price.toString(), 18)); + const token1Amount = + (halfValue * ethers.parseUnits('1', this.token1.decimals)) / + ethers.parseUnits(token1Price.toString(), 18); return { token0Amount, From 33beb6dfd47fff6eed9400831557a35ed858c0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sat, 27 Sep 2025 15:18:26 +0900 Subject: [PATCH 02/29] wip: at least SSE works --- src/executors/UnifiedZapExecutor.js | 72 +++++++++---- src/intents/UnifiedZapIntentHandler.js | 135 ++++++++++++------------- 2 files changed, 114 insertions(+), 93 deletions(-) diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index 12b4361..aaaa236 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -5,6 +5,7 @@ const { protocolFactory } = require('../protocols'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const SSEEventFactory = require('../services/SSEEventFactory'); class UnifiedZapExecutor { constructor(swapService, priceService, rebalanceClient) { @@ -81,22 +82,36 @@ class UnifiedZapExecutor { let allTransactions = []; let processedCount = 0; + const progressBase = 60; + const progressSpan = 20; // Process each protocol allocation for (const protocolAllocation of protocolAllocations) { try { // Update progress if (streamWriter) { - streamWriter({ - event: 'protocol_processing', - data: { - phase: 'transaction_building', - progress: 60 + (processedCount / protocolAllocations.length) * 20, - message: `Processing ${protocolAllocation.name}`, - protocol: protocolAllocation.name, - chain: protocolAllocation.chain, - }, - }); + const progressPercent = + progressBase + + Math.floor( + (processedCount / Math.max(protocolAllocations.length, 1)) * + progressSpan + ); + + streamWriter( + SSEEventFactory.createProgressEvent({ + processedTokens: progressPercent, + totalTokens: 100, + currentOperation: 'transaction_building', + additionalInfo: { + phase: 'transaction_building', + progressPercent, + protocol: protocolAllocation.name, + chain: protocolAllocation.chain, + protocolIndex: processedCount, + totalProtocols: protocolAllocations.length, + }, + }) + ); } // Generate protocol-specific transactions @@ -125,15 +140,16 @@ class UnifiedZapExecutor { ); if (streamWriter) { - streamWriter({ - event: 'protocol_error', - data: { - phase: 'transaction_building', - message: `Error processing ${protocolAllocation.name}: ${error.message}`, - protocol: protocolAllocation.name, - error: error.message, - }, - }); + streamWriter( + SSEEventFactory.createErrorEvent( + `Error processing ${protocolAllocation.name}: ${error.message}`, + { + phase: 'transaction_building', + protocol: protocolAllocation.name, + chain: protocolAllocation.chain, + } + ) + ); } // For now, continue with other protocols instead of failing entirely @@ -216,7 +232,9 @@ class UnifiedZapExecutor { return transactions.map((tx, index) => ({ ...tx, transactionIndex: index, - estimatedGas: this._getTransactionGasEstimate(tx, gasEstimates), + estimatedGas: this._serializeBigInt( + this._getTransactionGasEstimate(tx, gasEstimates) + ), timestamp: Date.now(), })); } @@ -535,6 +553,20 @@ class UnifiedZapExecutor { } } + /** + * Convert BigInt values to string for safe JSON serialization + * @param {BigInt|number|string|null} value - Value to serialize + * @returns {string|number|null} - Serializable value + * @private + */ + _serializeBigInt(value) { + if (typeof value === 'bigint') { + return value.toString(); + } + + return value; + } + /** * Get chain name from chain ID * @param {number} chainId - Chain ID diff --git a/src/intents/UnifiedZapIntentHandler.js b/src/intents/UnifiedZapIntentHandler.js index 931a18c..8b1c8af 100644 --- a/src/intents/UnifiedZapIntentHandler.js +++ b/src/intents/UnifiedZapIntentHandler.js @@ -8,6 +8,7 @@ const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); const UnifiedZapValidator = require('../validators/UnifiedZapValidator'); const IntentIdGenerator = require('../utils/intentIdGenerator'); const ExecutionContextManager = require('../managers/ExecutionContextManager'); +const SSEEventFactory = require('../services/SSEEventFactory'); class UnifiedZapIntentHandler extends BaseIntentHandler { constructor(swapService, priceService, rebalanceClient) { @@ -88,52 +89,47 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { * @returns {Promise} - Final processing results */ async processWithSSEStreaming(executionContext, streamWriter) { + const totalProgressUnits = 100; + const emitPhaseProgress = (phase, progressPercent, additionalInfo = {}) => { + streamWriter( + SSEEventFactory.createProgressEvent({ + processedTokens: progressPercent, + totalTokens: totalProgressUnits, + currentOperation: phase, + additionalInfo: { + phase, + progressPercent, + ...additionalInfo, + }, + }) + ); + }; + try { // Phase 1: Strategy Parsing - streamWriter({ - event: 'strategy_parsing_started', - data: { - phase: 'strategy_parsing', - progress: 0, - message: - 'Parsing strategy allocations into protocol-level allocations', - strategyCount: executionContext.strategyAllocations.length, - }, + emitPhaseProgress('strategy_parsing', 0, { + message: 'Parsing strategy allocations into protocol-level allocations', + strategyCount: executionContext.strategyAllocations.length, }); // Phase 2: Token Requirements Analysis - streamWriter({ - event: 'token_analysis_started', - data: { - phase: 'token_analysis', - progress: 20, - message: 'Analyzing token requirements for each protocol', - protocolCount: executionContext.protocolAllocations.length, - }, + emitPhaseProgress('token_analysis', 20, { + message: 'Analyzing token requirements for each protocol', + protocolCount: executionContext.protocolAllocations.length, }); // Phase 3: Swap Preparation - streamWriter({ - event: 'swap_preparation_started', - data: { - phase: 'swap_preparation', - progress: 40, - message: 'Preparing token swaps for multi-protocol deposits', - swapCount: this._countRequiredSwaps( - executionContext.protocolAllocations - ), - }, + emitPhaseProgress('swap_preparation', 40, { + message: 'Preparing token swaps for multi-protocol deposits', + swapCount: this._countRequiredSwaps( + executionContext.protocolAllocations + ), }); // Phase 4: Transaction Building - streamWriter({ - event: 'transaction_building_started', - data: { - phase: 'transaction_building', - progress: 60, - message: 'Building approval and deposit transactions', - transactionTypes: ['approvals', 'deposits', 'stakes'], - }, + emitPhaseProgress('transaction_building', 60, { + message: 'Building approval and deposit transactions', + transactionTypes: ['approvals', 'deposits', 'stakes'], }); // Execute transaction generation @@ -143,14 +139,9 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { ); // Phase 5: Gas Estimation - streamWriter({ - event: 'gas_estimation_started', - data: { - phase: 'gas_estimation', - progress: 80, - message: 'Estimating gas costs for transaction batch', - transactionCount: transactions.length, - }, + emitPhaseProgress('gas_estimation', 80, { + message: 'Estimating gas costs for transaction batch', + transactionCount: transactions.length, }); const gasEstimates = await this.executor.estimateGas( @@ -159,14 +150,9 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { ); // Phase 6: Final Assembly - streamWriter({ - event: 'final_assembly_started', - data: { - phase: 'final_assembly', - progress: 90, - message: 'Assembling final transaction array with fee insertion', - finalizing: true, - }, + emitPhaseProgress('final_assembly', 90, { + message: 'Assembling final transaction array with fee insertion', + finalizing: true, }); const finalTransactions = await this.executor.assembleFinalTransactions( @@ -175,24 +161,32 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { executionContext ); + const estimatedGasTotal = + typeof gasEstimates.total === 'bigint' + ? gasEstimates.total.toString() + : gasEstimates.total; + // Completion - streamWriter({ - event: 'execution_completed', - data: { - phase: 'completed', - progress: 100, - message: 'Multi-strategy allocation ready for execution', - summary: { - totalTransactions: finalTransactions.length, - estimatedGas: gasEstimates.total, + streamWriter( + SSEEventFactory.createCompletionEvent({ + transactions: finalTransactions, + metadata: { strategiesAllocated: executionContext.strategyAllocations.length, protocolsUsed: executionContext.protocolAllocations.length, + chains: this._getUniqueChains(executionContext.protocolAllocations), chainsInvolved: this._getUniqueChains( executionContext.protocolAllocations ).length, + transactionCount: finalTransactions.length, + estimatedGas: estimatedGasTotal, }, - }, - }); + processedTokens: totalProgressUnits, + totalTokens: totalProgressUnits, + additionalData: { + message: 'Multi-strategy allocation ready for execution', + }, + }) + ); return { success: true, @@ -206,18 +200,13 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { }, }; } catch (error) { - streamWriter({ - event: 'execution_error', - data: { - phase: 'error', - progress: -1, + streamWriter( + SSEEventFactory.createErrorEvent(error, { + phase: 'execution_error', + progressPercent: -1, message: `Error during processing: ${error.message}`, - error: { - type: error.constructor.name, - message: error.message, - }, - }, - }); + }) + ); throw error; } From 0f05d240c021cd198fe0de669599a414911e5e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 6 Oct 2025 14:53:34 +0900 Subject: [PATCH 03/29] fixCI: now token balance endpoint works --- .env.example | 11 + src/app.js | 3 + src/config/swaggerConfig.js | 114 +++ src/controllers/balanceController.js | 313 +++++++ .../__tests__/balanceRateLimit.test.js | 227 +++++ src/middleware/balanceRateLimit.js | 166 ++++ src/middleware/rateLimiter.js | 154 ++++ src/middleware/requestValidator.js | 60 +- src/routes/balanceRoutes.js | 251 ++++++ src/routes/balances.js | 237 ++++++ src/services/__mocks__/balanceService.js | 25 + src/services/__tests__/balanceService.test.js | 315 +++++++ src/services/balanceService.js | 430 ++++++++++ .../priceService/PriceCache.enhanced.js | 205 +++++ .../__tests__/balanceCache.enhanced.test.js | 310 +++++++ src/utils/__tests__/balanceCache.test.js | 284 +++++++ src/utils/balanceCache.enhanced.js | 468 ++++++++++ src/utils/balanceCache.js | 249 ++++++ .../__tests__/balanceValidator.test.js | 268 ++++++ src/validators/balanceValidator.js | 169 ++++ test/app.test.js | 5 + test/balance.integration.test.js | 755 +++++++++++++++++ test/balanceController.test.js | 800 ++++++++++++++++++ 23 files changed, 5818 insertions(+), 1 deletion(-) create mode 100644 src/controllers/balanceController.js create mode 100644 src/middleware/__tests__/balanceRateLimit.test.js create mode 100644 src/middleware/balanceRateLimit.js create mode 100644 src/middleware/rateLimiter.js create mode 100644 src/routes/balanceRoutes.js create mode 100644 src/routes/balances.js create mode 100644 src/services/__mocks__/balanceService.js create mode 100644 src/services/__tests__/balanceService.test.js create mode 100644 src/services/balanceService.js create mode 100644 src/services/priceService/PriceCache.enhanced.js create mode 100644 src/utils/__tests__/balanceCache.enhanced.test.js create mode 100644 src/utils/__tests__/balanceCache.test.js create mode 100644 src/utils/balanceCache.enhanced.js create mode 100644 src/utils/balanceCache.js create mode 100644 src/validators/__tests__/balanceValidator.test.js create mode 100644 src/validators/balanceValidator.js create mode 100644 test/balance.integration.test.js create mode 100644 test/balanceController.test.js diff --git a/.env.example b/.env.example index 7d3c040..c3a424f 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,14 @@ REBALANCE_BACKEND_TIMEOUT=10000 PLATFORM_FEE_RATE=0.0001 REFERRER_FEE_SHARE=0.7 TREASURY_ADDRESS=0x2eCBC6f229feD06044CDb0dD772437a30190CD50 + +# Balance Service Configuration +# Moralis Web3 Data API for multi-chain token balance aggregation +# Get your API key from: https://admin.moralis.io/web3apis +MORALIS_API_KEY=your_moralis_api_key_here + +# Balance cache TTL in milliseconds (default: 180000 = 3 minutes) +BALANCE_CACHE_TTL=180000 + +# Moralis API request timeout in milliseconds (default: 10000) +MORALIS_TIMEOUT=10000 diff --git a/src/app.js b/src/app.js index 034bffd..fbe8fc6 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,7 @@ const errorHandler = require('./middleware/errorHandler'); const swapRoutes = require('./routes/swap'); const intentRoutes = require('./routes/intents'); const tokenRoutes = require('./routes/tokens'); +const balanceRoutes = require('./routes/balanceRoutes'); const app = express(); const PORT = process.env.PORT || 3002; @@ -43,6 +44,7 @@ app.get('/health', (req, res) => { app.use('/', swapRoutes); app.use('/', intentRoutes); app.use('/tokens', tokenRoutes); +app.use('/', balanceRoutes); // Error handling middleware (must be last) app.use(errorHandler); @@ -59,6 +61,7 @@ if (require.main === module) { `Supported intents: dustZap, unifiedZap (zapIn, zapOut, rebalance coming soon)` ); console.log(`🪙 Token endpoints: /tokens/zap/{chainId}, /tokens/chains`); + console.log(`💰 Balance endpoint: /api/v1/balances/{chainId}/{address}`); }); } diff --git a/src/config/swaggerConfig.js b/src/config/swaggerConfig.js index 56be540..141c74a 100644 --- a/src/config/swaggerConfig.js +++ b/src/config/swaggerConfig.js @@ -556,6 +556,116 @@ const swaggerOptions = { }, }, }, + + // Balance schemas + BalanceRequest: { + type: 'object', + properties: { + tokens: { + type: 'array', + description: + 'Optional list of token addresses to filter balances', + items: { + $ref: '#/components/schemas/EthereumAddress', + }, + }, + }, + }, + + BalanceResponse: { + type: 'object', + required: [ + 'success', + 'chainId', + 'address', + 'balances', + 'totalBalanceUSD', + 'timestamp', + 'metadata', + ], + properties: { + success: { + type: 'boolean', + example: true, + }, + chainId: { + $ref: '#/components/schemas/ChainId', + }, + address: { + $ref: '#/components/schemas/EthereumAddress', + }, + balances: { + type: 'array', + items: { + type: 'object', + required: [ + 'token', + 'symbol', + 'balance', + 'balanceUSD', + 'decimals', + 'price', + ], + properties: { + token: { + $ref: '#/components/schemas/EthereumAddress', + }, + symbol: { + type: 'string', + example: 'USDC', + }, + balance: { + type: 'string', + description: + "Balance in token's smallest unit (wei/satoshis)", + example: '1000000000', + }, + balanceUSD: { + type: 'number', + description: 'Total balance value in USD', + example: 1000.5, + }, + decimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + }, + price: { + type: 'number', + description: 'Token price in USD', + example: 1.0, + }, + }, + }, + }, + totalBalanceUSD: { + type: 'number', + description: 'Total balance across all tokens in USD', + example: 5250.75, + }, + timestamp: { + type: 'string', + format: 'date-time', + description: 'Timestamp of balance retrieval', + }, + metadata: { + type: 'object', + properties: { + fromCache: { + type: 'boolean', + description: 'Whether the balance was retrieved from cache', + example: true, + }, + cacheAge: { + type: 'integer', + description: 'Age of cached data in seconds', + example: 15, + }, + }, + }, + }, + }, }, responses: { @@ -615,6 +725,10 @@ const swaggerOptions = { name: 'Prices', description: 'Token price data with fallback providers', }, + { + name: 'Balances', + description: 'Multi-chain token balance retrieval', + }, { name: 'Vaults', description: 'Vault strategy information', diff --git a/src/controllers/balanceController.js b/src/controllers/balanceController.js new file mode 100644 index 0000000..6dca3e5 --- /dev/null +++ b/src/controllers/balanceController.js @@ -0,0 +1,313 @@ +const BalanceService = require('../services/balanceService'); + +// Initialize balance service +const balanceService = new BalanceService(); + +/** + * Balance Controller + * Handles token balance queries with caching support + */ +class BalanceController { + /** + * Get token balances for an address on a specific chain + * GET /api/v1/balances/:chainId/:address + * + * @param {Object} req - Express request object + * @param {Object} req.params - Route parameters + * @param {string} req.params.chainId - Chain ID (e.g., 1, 8453, 42161) + * @param {string} req.params.address - Wallet address (0x...) + * @param {Object} req.query - Query parameters + * @param {string} [req.query.tokens] - Optional comma-separated token addresses + * @param {boolean} [req.query.skipCache] - Skip cache lookup + * @param {Object} res - Express response object + */ + static async getBalances(req, res) { + try { + const { chainId, address } = req.params; + const { tokens, skipCache } = req.query; + + // Parse tokens if provided (comma-separated addresses) + const parsedTokens = tokens + ? tokens + .split(',') + .map(addr => addr.trim()) + .filter(Boolean) + : []; + + const includeNative = parsedTokens.some( + token => token.toLowerCase() === 'native' + ); + + const tokenAddresses = parsedTokens + .filter(token => token.toLowerCase() !== 'native') + .filter(Boolean); + + const tokenFilter = tokenAddresses.length > 0 ? tokenAddresses : null; + + // Call balance service with new signature + const result = await balanceService.getBalances(address, { + chainId, + tokenAddresses: tokenFilter, + includeNative, + skipCache: skipCache === 'true', + }); + + // Return standardized response + res.json({ + success: true, + data: result, + cached: result.cacheHit || false, + }); + } catch (error) { + console.error('Balance controller error:', error); + + // Map errors to appropriate HTTP status codes + const statusCode = BalanceController._getErrorStatusCode(error); + const errorResponse = BalanceController._formatErrorResponse(error); + + res.status(statusCode).json(errorResponse); + } + } + + /** + * Get native token balance for an address + * GET /api/v1/balances/:chainId/:address/native + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static async getNativeBalance(req, res) { + try { + const { chainId, address } = req.params; + + const result = await balanceService.getNativeBalance(address, chainId); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + console.error('Native balance controller error:', error); + + const statusCode = BalanceController._getErrorStatusCode(error); + const errorResponse = BalanceController._formatErrorResponse(error); + + res.status(statusCode).json(errorResponse); + } + } + + /** + * Get cache statistics + * GET /api/v1/balances/cache/stats + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static getCacheStats(req, res) { + try { + const stats = balanceService.getCacheStats(); + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error('Cache stats error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Clear cache for specific address or chain + * DELETE /api/v1/balances/cache + * + * @param {Object} req - Express request object + * @param {Object} req.query - Query parameters + * @param {string} [req.query.address] - Address to clear + * @param {string} [req.query.chainId] - Chain to clear + * @param {Object} res - Express response object + */ + static clearCache(req, res) { + try { + const { address, chainId } = req.query; + let cleared = 0; + + if (address) { + cleared = balanceService.clearAddressCache(address); + } else if (chainId) { + cleared = balanceService.clearChainCache(chainId); + } else { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Either address or chainId parameter is required', + }, + }); + } + + res.json({ + success: true, + data: { + cleared, + message: `Cleared ${cleared} cache entries`, + }, + }); + } catch (error) { + console.error('Clear cache error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Get supported chains + * GET /api/v1/balances/chains + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + static getSupportedChains(req, res) { + try { + const chains = balanceService.getSupportedChains(); + + res.json({ + success: true, + data: { + chains, + count: chains.length, + }, + }); + } catch (error) { + console.error('Get chains error:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }, + }); + } + } + + /** + * Map error types to HTTP status codes + * @private + */ + static _getErrorStatusCode(error) { + if (typeof error.status === 'number' && error.status >= 400) { + return error.status; + } + + const message = error.message ? error.message.toLowerCase() : ''; + + // Invalid input errors + if ( + message.includes('invalid') || + message.includes('required') || + message.includes('must be') + ) { + return 400; + } + + // Chain not supported errors + if ( + message.includes('not supported') || + message.includes('unsupported chain') + ) { + return 400; + } + + // RPC or external service errors + if (message.includes('network')) { + return 500; + } + + if (message.includes('rpc') || message.includes('provider')) { + return 503; + } + + // Rate limit errors + if (message.includes('rate limit')) { + return 429; + } + + // Default to internal server error + return 500; + } + + /** + * Format error response + * @private + */ + static _formatErrorResponse(error) { + const errorCode = BalanceController._getErrorCode(error); + + return { + success: false, + error: { + code: errorCode, + message: error.message || 'Failed to fetch balances', + details: error.details || undefined, + }, + }; + } + + /** + * Get error code from error object + * @private + */ + static _getErrorCode(error) { + // Check if error already has a code + if (error.code) { + return error.code; + } + + const message = error.message ? error.message.toLowerCase() : ''; + + if (error.status === 503) { + return 'RPC_ERROR'; + } + + // Derive code from message + if (message.includes('moralis response format')) { + return 'INTERNAL_SERVER_ERROR'; + } + + if (message.includes('invalid')) { + return 'INVALID_INPUT'; + } + if (message.includes('not supported')) { + return 'UNSUPPORTED_CHAIN'; + } + if (message.includes('rpc') || message.includes('provider')) { + return 'RPC_ERROR'; + } + if (message.includes('rate limit')) { + return 'RATE_LIMIT_EXCEEDED'; + } + + return 'INTERNAL_SERVER_ERROR'; + } + + /** + * Expose balanceService for testing purposes + * @static + */ + static get balanceService() { + return balanceService; + } +} + +module.exports = BalanceController; diff --git a/src/middleware/__tests__/balanceRateLimit.test.js b/src/middleware/__tests__/balanceRateLimit.test.js new file mode 100644 index 0000000..8b181e6 --- /dev/null +++ b/src/middleware/__tests__/balanceRateLimit.test.js @@ -0,0 +1,227 @@ +/** + * Tests for Balance Rate Limiting Middleware + */ + +const balanceRateLimit = require('../balanceRateLimit'); +const { RateLimiter } = require('../balanceRateLimit'); + +describe('RateLimiter', () => { + let limiter; + + beforeEach(() => { + limiter = new RateLimiter(); + }); + + afterEach(() => { + limiter.destroy(); + }); + + describe('Per-wallet limit', () => { + it('should allow 10 requests per wallet per minute', () => { + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + // First 10 should be allowed + for (let i = 0; i < 10; i++) { + const result = limiter.checkLimit(wallet); + expect(result.allowed).toBe(true); + } + + // 11th should be blocked + const result = limiter.checkLimit(wallet); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('wallet'); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it('should normalize wallet addresses to lowercase', () => { + const wallet1 = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const wallet2 = '0x742D35CC6634C0532925A3B844BC9E7595F0BEB'; + + // Should count against same limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(i % 2 === 0 ? wallet1 : wallet2); + } + + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + }); + + it('should allow different wallets independently', () => { + const wallet1 = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const wallet2 = '0x8ba1f109551bD432803012645Ac136ddd64DBA72'; + + // Each wallet should get 10 requests + for (let i = 0; i < 10; i++) { + expect(limiter.checkLimit(wallet1).allowed).toBe(true); + expect(limiter.checkLimit(wallet2).allowed).toBe(true); + } + + // Both should be limited independently + expect(limiter.checkLimit(wallet1).allowed).toBe(false); + expect(limiter.checkLimit(wallet2).allowed).toBe(false); + }); + }); + + describe('Global limit', () => { + it('should enforce global limit of 100 requests per minute', () => { + const wallets = Array.from( + { length: 20 }, + (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` + ); + + let requestCount = 0; + + // Should allow 100 requests across all wallets + for (let i = 0; i < 100; i++) { + const wallet = wallets[i % wallets.length]; + const result = limiter.checkLimit(wallet); + + if (result.allowed) { + requestCount++; + } else { + break; + } + } + + expect(requestCount).toBe(100); + + // 101st should hit global limit + const result = limiter.checkLimit(wallets[0]); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('global'); + }); + }); + + describe('Status tracking', () => { + it('should track wallet and global counts', () => { + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + limiter.checkLimit(wallet); + limiter.checkLimit(wallet); + limiter.checkLimit(wallet); + + const status = limiter.getStatus(wallet); + expect(status.walletCount).toBe(3); + expect(status.globalCount).toBe(3); + expect(status.walletReset).toBeGreaterThan(Date.now()); + expect(status.globalReset).toBeGreaterThan(Date.now()); + }); + }); + + describe('Cleanup', () => { + it('should clean up expired entries', done => { + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + // Manually set an expired entry + limiter.walletLimits.set(wallet.toLowerCase(), { + count: 5, + resetAt: Date.now() - 1000, // Expired + }); + + expect(limiter.walletLimits.size).toBe(1); + + limiter.cleanup(); + + expect(limiter.walletLimits.size).toBe(0); + done(); + }); + }); +}); + +describe('balanceRateLimit middleware', () => { + let req, res, next; + + beforeEach(() => { + req = { + query: {}, + body: {}, + }; + res = { + set: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + + // Clear rate limiter between tests + const { rateLimiter } = require('../balanceRateLimit'); + rateLimiter.walletLimits.clear(); + rateLimiter.globalLimit = { count: 0, resetAt: Date.now() + 60000 }; + }); + + it('should pass through when no wallet provided', () => { + balanceRateLimit(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('should set rate limit headers on success', () => { + req.query.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + balanceRateLimit(req, res, next); + + expect(res.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'X-RateLimit-Limit-Wallet': '10', + 'X-RateLimit-Remaining-Wallet': expect.any(Number), + 'X-RateLimit-Reset-Wallet': expect.any(Number), + 'X-RateLimit-Limit-Global': '100', + 'X-RateLimit-Remaining-Global': expect.any(Number), + }) + ); + expect(next).toHaveBeenCalled(); + }); + + it('should return 429 when wallet limit exceeded', () => { + const { rateLimiter } = require('../balanceRateLimit'); + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + req.query.wallet = wallet; + + // Exhaust the limit + for (let i = 0; i < 10; i++) { + rateLimiter.checkLimit(wallet); + } + + balanceRateLimit(req, res, next); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(Number)); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Too Many Requests', + limitType: 'wallet', + retryAfter: expect.any(Number), + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 429 when global limit exceeded', () => { + const { rateLimiter } = require('../balanceRateLimit'); + + // Exhaust global limit + for (let i = 0; i < 100; i++) { + rateLimiter.checkLimit(`0x${'0'.repeat(39)}${i}`); + } + + req.query.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + balanceRateLimit(req, res, next); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Too Many Requests', + limitType: 'global', + }) + ); + }); + + it('should handle wallet from body', () => { + req.body.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + balanceRateLimit(req, res, next); + + expect(res.set).toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/src/middleware/balanceRateLimit.js b/src/middleware/balanceRateLimit.js new file mode 100644 index 0000000..b464ba9 --- /dev/null +++ b/src/middleware/balanceRateLimit.js @@ -0,0 +1,166 @@ +/** + * Rate Limiting Middleware for Balance Endpoint + * + * Implements two-tier rate limiting: + * - Per-wallet: 10 requests/minute + * - Global: 100 requests/minute + * + * Uses in-memory Map storage with automatic cleanup of expired entries. + */ + +class RateLimiter { + constructor() { + // Map + this.walletLimits = new Map(); + this.globalLimit = { count: 0, resetAt: Date.now() + 60000 }; + + // Cleanup expired entries every 2 minutes + this.cleanupInterval = setInterval(() => this.cleanup(), 120000); + } + + /** + * Remove expired rate limit entries to prevent memory leaks + */ + cleanup() { + const now = Date.now(); + for (const [wallet, data] of this.walletLimits.entries()) { + if (data.resetAt < now) { + this.walletLimits.delete(wallet); + } + } + } + + /** + * Check and increment rate limits for a wallet + * @param {string} wallet - Ethereum wallet address + * @returns {{ allowed: boolean, retryAfter?: number, limit?: string }} + */ + checkLimit(wallet) { + const now = Date.now(); + + // Check global limit (100 req/min) + if (this.globalLimit.resetAt < now) { + this.globalLimit = { count: 0, resetAt: now + 60000 }; + } + + if (this.globalLimit.count >= 100) { + const retryAfter = Math.ceil((this.globalLimit.resetAt - now) / 1000); + return { + allowed: false, + retryAfter, + limit: 'global', + message: + 'Global rate limit exceeded. Too many requests across all wallets.', + }; + } + + // Check per-wallet limit (10 req/min) + const walletKey = wallet.toLowerCase(); + let walletData = this.walletLimits.get(walletKey); + + if (!walletData || walletData.resetAt < now) { + walletData = { count: 0, resetAt: now + 60000 }; + this.walletLimits.set(walletKey, walletData); + } + + if (walletData.count >= 10) { + const retryAfter = Math.ceil((walletData.resetAt - now) / 1000); + return { + allowed: false, + retryAfter, + limit: 'wallet', + message: `Rate limit exceeded for wallet ${wallet}. Maximum 10 requests per minute.`, + }; + } + + // Increment both counters + walletData.count++; + this.globalLimit.count++; + + return { allowed: true }; + } + + /** + * Get current limit status for a wallet (for monitoring/debugging) + * @param {string} wallet - Ethereum wallet address + * @returns {{ walletCount: number, globalCount: number, walletReset: number, globalReset: number }} + */ + getStatus(wallet) { + const walletKey = wallet.toLowerCase(); + const walletData = this.walletLimits.get(walletKey); + + return { + walletCount: walletData?.count || 0, + globalCount: this.globalLimit.count, + walletReset: walletData?.resetAt || Date.now(), + globalReset: this.globalLimit.resetAt, + }; + } + + /** + * Cleanup resources + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.walletLimits.clear(); + } +} + +// Singleton instance +const rateLimiter = new RateLimiter(); + +/** + * Express middleware for balance endpoint rate limiting + * + * Usage: + * router.get('/balance', balanceRateLimit, balanceValidator, getBalance); + * + * Response headers: + * X-RateLimit-Limit-Wallet: 10 + * X-RateLimit-Remaining-Wallet: 7 + * X-RateLimit-Reset-Wallet: 1633024800 + * X-RateLimit-Limit-Global: 100 + * X-RateLimit-Remaining-Global: 85 + * Retry-After: 45 (only on 429) + */ +const balanceRateLimit = (req, res, next) => { + // Extract wallet from query params or body + const wallet = req.query.wallet || req.body?.wallet; + + if (!wallet) { + // If no wallet provided, validation will catch it later + return next(); + } + + const result = rateLimiter.checkLimit(wallet); + + // Add rate limit headers + const status = rateLimiter.getStatus(wallet); + res.set({ + 'X-RateLimit-Limit-Wallet': '10', + 'X-RateLimit-Remaining-Wallet': Math.max(0, 10 - status.walletCount), + 'X-RateLimit-Reset-Wallet': Math.floor(status.walletReset / 1000), + 'X-RateLimit-Limit-Global': '100', + 'X-RateLimit-Remaining-Global': Math.max(0, 100 - status.globalCount), + }); + + if (!result.allowed) { + res.set('Retry-After', result.retryAfter); + return res.status(429).json({ + error: 'Too Many Requests', + message: result.message, + limitType: result.limit, + retryAfter: result.retryAfter, + retryAfterMs: result.retryAfter * 1000, + }); + } + + next(); +}; + +// Export for testing +module.exports = balanceRateLimit; +module.exports.RateLimiter = RateLimiter; +module.exports.rateLimiter = rateLimiter; diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js new file mode 100644 index 0000000..933f229 --- /dev/null +++ b/src/middleware/rateLimiter.js @@ -0,0 +1,154 @@ +/** + * Simple in-memory rate limiter middleware + * For production, consider using Redis-based rate limiting (e.g., express-rate-limit with Redis store) + */ + +class RateLimiter { + constructor(windowMs = 60000, maxRequests = 100, options = {}) { + // Allow passing a single options object for readability + if (typeof windowMs === 'object' && windowMs !== null) { + options = windowMs; + windowMs = options.windowMs ?? 60000; + maxRequests = options.maxRequests ?? 100; + } + + this.windowMs = windowMs; // Time window in milliseconds + this.maxRequests = maxRequests; // Max requests per window + this.requests = new Map(); // Store: key -> { count, resetTime } + + this.cleanupInterval = null; + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60000; + this.autoStartCleanup = + options.autoStartCleanup ?? process.env.NODE_ENV !== 'test'; + + if (this.autoStartCleanup) { + this.startCleanup(); + } + } + + /** + * Get client identifier from request + * Priority: API key > IP address + */ + _getClientKey(req) { + // Use API key if provided in headers + const apiKey = req.headers['x-api-key']; + if (apiKey) { + return `apikey:${apiKey}`; + } + + // Fallback to IP address + const ip = + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.connection.remoteAddress || + req.socket.remoteAddress || + 'unknown'; + + return `ip:${ip}`; + } + + /** + * Clean up expired entries (called periodically) + */ + _cleanup() { + const now = Date.now(); + for (const [key, data] of this.requests.entries()) { + if (now >= data.resetTime) { + this.requests.delete(key); + } + } + } + + startCleanup() { + if (this.cleanupInterval) { + return; + } + + const interval = setInterval(() => this._cleanup(), this.cleanupIntervalMs); + + if (typeof interval.unref === 'function') { + interval.unref(); + } + + this.cleanupInterval = interval; + } + + stopCleanup() { + if (!this.cleanupInterval) { + return; + } + + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + /** + * Get rate limit middleware function + */ + middleware() { + if (!this.cleanupInterval && process.env.NODE_ENV !== 'test') { + this.startCleanup(); + } + + return (req, res, next) => { + const clientKey = this._getClientKey(req); + const now = Date.now(); + + // Get or initialize client data + let clientData = this.requests.get(clientKey); + + if (!clientData || now >= clientData.resetTime) { + // Reset window + clientData = { + count: 0, + resetTime: now + this.windowMs, + }; + this.requests.set(clientKey, clientData); + } + + // Increment request count + clientData.count++; + + // Set rate limit headers + const remaining = Math.max(0, this.maxRequests - clientData.count); + const resetTime = Math.ceil(clientData.resetTime / 1000); + + res.setHeader('X-RateLimit-Limit', this.maxRequests); + res.setHeader('X-RateLimit-Remaining', remaining); + res.setHeader('X-RateLimit-Reset', resetTime); + + // Check if rate limit exceeded + if (clientData.count > this.maxRequests) { + const retryAfter = Math.ceil((clientData.resetTime - now) / 1000); + res.setHeader('Retry-After', retryAfter); + + return res.status(429).json({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests, please try again later', + details: { + limit: this.maxRequests, + windowMs: this.windowMs, + retryAfter: retryAfter, + }, + }, + }); + } + + next(); + }; + } +} + +// Create rate limiters for different endpoints +const balanceRateLimiter = new RateLimiter(60000, 100); +const generalRateLimiter = new RateLimiter(60000, 60); + +module.exports = { + balanceRateLimit: balanceRateLimiter.middleware(), + generalRateLimit: generalRateLimiter.middleware(), + RateLimiter, // Export class for custom configurations + balanceRateLimiter, + generalRateLimiter, +}; diff --git a/src/middleware/requestValidator.js b/src/middleware/requestValidator.js index f7f6abb..367a62a 100644 --- a/src/middleware/requestValidator.js +++ b/src/middleware/requestValidator.js @@ -1,4 +1,4 @@ -const { body, validationResult } = require('express-validator'); +const { body, param, query, validationResult } = require('express-validator'); const validateIntentRequest = [ body('userAddress') @@ -27,6 +27,64 @@ const validateIntentRequest = [ }, ]; +/** + * Validate balance request parameters + * Route: GET /api/v1/balances/:chainId/:address + */ +const validateBalanceRequest = [ + param('chainId') + .isInt({ gt: 0 }) + .withMessage('Invalid chainId: must be a positive integer'), + param('address') + .exists() + .withMessage('Invalid address: must be a valid Ethereum address') + .bail() + .isString() + .withMessage('Invalid address: must be a valid Ethereum address') + .matches(/^0[xX][a-fA-F0-9]{40}$/) + .withMessage('Invalid address: must be a valid Ethereum address') + .customSanitizer(value => value.toLowerCase()), + query('tokens') + .optional() + .isString() + .custom(value => { + // Validate comma-separated Ethereum addresses + const addresses = value + .split(',') + .map(addr => addr.trim()) + .filter(Boolean); + const addressRegex = /^0x[a-fA-F0-9]{40}$/; + + for (const addr of addresses) { + if (addr.toLowerCase() === 'native') { + continue; + } + if (!addressRegex.test(addr)) { + throw new Error(`Invalid token address: ${addr}`); + } + } + return true; + }), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: errors.array()[0].msg, + details: { + field: errors.array()[0].param, + value: errors.array()[0].value, + }, + }, + }); + } + next(); + }, +]; + module.exports = { validateIntentRequest, + validateBalanceRequest, }; diff --git a/src/routes/balanceRoutes.js b/src/routes/balanceRoutes.js new file mode 100644 index 0000000..443c196 --- /dev/null +++ b/src/routes/balanceRoutes.js @@ -0,0 +1,251 @@ +const express = require('express'); +const BalanceController = require('../controllers/balanceController'); +const { validateBalanceRequest } = require('../middleware/requestValidator'); +const { balanceRateLimit } = require('../middleware/rateLimiter'); + +const router = express.Router(); + +/** + * @swagger + * /api/v1/balances/{chainId}/{address}: + * get: + * tags: + * - Balances + * summary: Get token balances for an address + * description: | + * Retrieves token balances for a specific wallet address on a given blockchain. + * Results are cached to improve performance and reduce RPC calls. + * Rate limited to 100 requests per minute per client. + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 8453 + * description: Chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, etc.) + * - in: path + * name: address + * required: true + * schema: + * type: string + * pattern: '^0x[a-fA-F0-9]{40}$' + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * description: Wallet address (checksummed or lowercase) + * - in: query + * name: tokens + * required: false + * schema: + * type: string + * example: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913,0x4200000000000000000000000000000000000006" + * description: Optional comma-separated list of token contract addresses to query. If omitted, queries all known tokens for the chain. + * responses: + * 200: + * description: Token balances retrieved successfully + * headers: + * X-RateLimit-Limit: + * schema: + * type: integer + * example: 100 + * description: Maximum requests allowed per window + * X-RateLimit-Remaining: + * schema: + * type: integer + * example: 95 + * description: Remaining requests in current window + * X-RateLimit-Reset: + * schema: + * type: integer + * example: 1640995260 + * description: Unix timestamp when the rate limit resets + * content: + * application/json: + * schema: + * type: object + * required: [chainId, address, balances, cached, timestamp, ttl] + * properties: + * chainId: + * type: integer + * example: 8453 + * description: Chain ID + * address: + * type: string + * example: "0x2ecbc6f229fed06044cdb0dd772437a30190cd50" + * description: Wallet address (normalized to lowercase) + * balances: + * type: array + * description: Array of token balance objects + * items: + * type: object + * required: [token, symbol, decimals, balance, balanceFormatted] + * properties: + * token: + * type: string + * example: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * description: Token contract address + * symbol: + * type: string + * example: "USDC" + * description: Token symbol + * decimals: + * type: integer + * example: 6 + * description: Token decimals + * balance: + * type: string + * example: "1000000000" + * description: Raw balance (wei/smallest unit) + * balanceFormatted: + * type: string + * example: "1000.0" + * description: Human-readable balance + * price: + * type: number + * example: 0.9998 + * description: Token price in USD (if available) + * valueUsd: + * type: number + * example: 999.8 + * description: Balance value in USD (if price available) + * cached: + * type: boolean + * example: true + * description: Whether the response was served from cache + * timestamp: + * type: string + * format: date-time + * example: "2024-01-01T00:00:00.000Z" + * description: Timestamp when data was fetched + * ttl: + * type: integer + * example: 30 + * description: Time-to-live in seconds before cache expires + * examples: + * successResponse: + * summary: Successful balance query + * value: + * chainId: 8453 + * address: "0x2ecbc6f229fed06044cdb0dd772437a30190cd50" + * balances: + * - token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * symbol: "USDC" + * decimals: 6 + * balance: "1000000000" + * balanceFormatted: "1000.0" + * price: 0.9998 + * valueUsd: 999.8 + * - token: "0x4200000000000000000000000000000000000006" + * symbol: "WETH" + * decimals: 18 + * balance: "500000000000000000" + * balanceFormatted: "0.5" + * price: 3500.0 + * valueUsd: 1750.0 + * cached: false + * timestamp: "2024-01-01T00:00:00.000Z" + * ttl: 30 + * 400: + * description: Invalid request parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INVALID_INPUT" + * message: + * type: string + * example: "Invalid chainId: must be a positive integer" + * details: + * type: object + * 429: + * description: Rate limit exceeded + * headers: + * Retry-After: + * schema: + * type: integer + * example: 30 + * description: Seconds to wait before retrying + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "RATE_LIMIT_EXCEEDED" + * message: + * type: string + * example: "Too many requests, please try again later" + * details: + * type: object + * properties: + * limit: + * type: integer + * example: 100 + * windowMs: + * type: integer + * example: 60000 + * retryAfter: + * type: integer + * example: 30 + * 503: + * description: RPC provider or external service unavailable + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "RPC_ERROR" + * message: + * type: string + * example: "RPC provider unavailable" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + */ +router.get( + '/api/v1/balances/:chainId/:address', + balanceRateLimit, + validateBalanceRequest, + BalanceController.getBalances +); + +module.exports = router; diff --git a/src/routes/balances.js b/src/routes/balances.js new file mode 100644 index 0000000..98385bf --- /dev/null +++ b/src/routes/balances.js @@ -0,0 +1,237 @@ +const express = require('express'); +const BalanceController = require('../controllers/balanceController'); + +const router = express.Router(); + +/** + * @swagger + * tags: + * name: Balances + * description: Token balance management with Moralis API integration + */ + +/** + * @swagger + * /balances/chains: + * get: + * summary: Get supported chain IDs + * tags: [Balances] + * responses: + * 200: + * description: List of supported chains + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * chains: + * type: array + * items: + * type: string + * example: ["1", "137", "56", "42161", "10", "8453", "43114"] + * count: + * type: integer + */ +router.get('/chains', BalanceController.getSupportedChains); + +/** + * @swagger + * /balances/cache/stats: + * get: + * summary: Get cache statistics + * tags: [Balances] + * responses: + * 200: + * description: Cache statistics + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * hits: + * type: integer + * misses: + * type: integer + * sets: + * type: integer + * evictions: + * type: integer + * size: + * type: integer + * hitRate: + * type: string + * memoryUsage: + * type: string + */ +router.get('/cache/stats', BalanceController.getCacheStats); + +/** + * @swagger + * /balances/cache: + * delete: + * summary: Clear cache entries + * tags: [Balances] + * parameters: + * - in: query + * name: address + * schema: + * type: string + * description: Wallet address to clear cache for + * - in: query + * name: chainId + * schema: + * type: string + * description: Chain ID to clear cache for + * responses: + * 200: + * description: Cache cleared successfully + * 400: + * description: Invalid input - address or chainId required + */ +router.delete('/cache', BalanceController.clearCache); + +/** + * @swagger + * /balances/{chainId}/{address}: + * get: + * summary: Get ERC20 token balances for an address + * tags: [Balances] + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: string + * description: Chain ID (e.g., 1 for Ethereum, 8453 for Base) + * - in: path + * name: address + * required: true + * schema: + * type: string + * description: Wallet address (0x...) + * - in: query + * name: tokens + * schema: + * type: string + * description: Comma-separated token addresses to filter + * example: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0x6B175474E89094C44Da98b954EedeAC495271d0F" + * - in: query + * name: skipCache + * schema: + * type: boolean + * description: Skip cache lookup and fetch fresh data + * responses: + * 200: + * description: Token balances retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * address: + * type: string + * chainId: + * type: string + * balances: + * type: array + * items: + * type: object + * properties: + * tokenAddress: + * type: string + * name: + * type: string + * symbol: + * type: string + * decimals: + * type: integer + * balance: + * type: string + * balanceFormatted: + * type: string + * logo: + * type: string + * thumbnail: + * type: string + * possibleSpam: + * type: boolean + * verifiedContract: + * type: boolean + * totalTokens: + * type: integer + * timestamp: + * type: integer + * cached: + * type: boolean + * 400: + * description: Invalid input parameters + * 503: + * description: Service unavailable or rate limited + */ +router.get('/:chainId/:address', BalanceController.getBalances); + +/** + * @swagger + * /balances/{chainId}/{address}/native: + * get: + * summary: Get native token balance (ETH, MATIC, etc.) + * tags: [Balances] + * parameters: + * - in: path + * name: chainId + * required: true + * schema: + * type: string + * description: Chain ID + * - in: path + * name: address + * required: true + * schema: + * type: string + * description: Wallet address (0x...) + * responses: + * 200: + * description: Native balance retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * address: + * type: string + * chainId: + * type: string + * balance: + * type: string + * balanceFormatted: + * type: string + * timestamp: + * type: integer + * 400: + * description: Invalid input parameters + * 503: + * description: Service unavailable + */ +router.get('/:chainId/:address/native', BalanceController.getNativeBalance); + +module.exports = router; diff --git a/src/services/__mocks__/balanceService.js b/src/services/__mocks__/balanceService.js new file mode 100644 index 0000000..eb98969 --- /dev/null +++ b/src/services/__mocks__/balanceService.js @@ -0,0 +1,25 @@ +/** + * Mock BalanceService for unit testing + */ + +const mockMethods = { + getBalances: jest.fn(), + getNativeBalance: jest.fn(), + getCacheStats: jest.fn(), + clearAddressCache: jest.fn(), + clearChainCache: jest.fn(), + getSupportedChains: jest.fn(), + normalizeChainId: jest.fn(), + isChainSupported: jest.fn(), +}; + +class MockBalanceService { + constructor() { + Object.assign(this, mockMethods); + } +} + +// Expose mock methods for easy access in tests +MockBalanceService.__mockMethods = mockMethods; + +module.exports = MockBalanceService; diff --git a/src/services/__tests__/balanceService.test.js b/src/services/__tests__/balanceService.test.js new file mode 100644 index 0000000..7f16e85 --- /dev/null +++ b/src/services/__tests__/balanceService.test.js @@ -0,0 +1,315 @@ +const BalanceService = require('../balanceService'); +const BalanceCache = require('../../utils/balanceCache'); + +// Mock axios +jest.mock('axios'); +const axios = require('axios'); + +describe('BalanceService', () => { + let balanceService; + + beforeEach(() => { + // Set required env var + process.env.MORALIS_API_KEY = 'test_api_key'; + balanceService = new BalanceService(); + jest.clearAllMocks(); + }); + + afterEach(() => { + balanceService.cache.stopCleanup(); + }); + + describe('Constructor', () => { + it('should initialize with correct config', () => { + expect(balanceService.apiKey).toBe('test_api_key'); + expect(balanceService.baseURL).toBe( + 'https://deep-index.moralis.io/api/v2.2' + ); + expect(balanceService.cache).toBeInstanceOf(BalanceCache); + }); + + it('should throw error if API key is missing', () => { + delete process.env.MORALIS_API_KEY; + expect(() => new BalanceService()).toThrow( + 'MORALIS_API_KEY environment variable is required' + ); + }); + }); + + describe('normalizeChainId', () => { + it('should convert decimal to hex', () => { + expect(balanceService.normalizeChainId(1)).toBe('0x1'); + expect(balanceService.normalizeChainId('137')).toBe('0x89'); + expect(balanceService.normalizeChainId(8453)).toBe('0x2105'); + }); + + it('should keep hex format unchanged', () => { + expect(balanceService.normalizeChainId('0x1')).toBe('0x1'); + expect(balanceService.normalizeChainId('0x89')).toBe('0x89'); + }); + + it('should throw error for unsupported chains', () => { + expect(() => balanceService.normalizeChainId(999)).toThrow( + 'Unsupported chain ID' + ); + }); + }); + + describe('getBalances', () => { + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockResponse = [ + { + token_address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + it('should validate address format', async () => { + await expect( + balanceService.getBalances('invalid_address', { chainId: 1 }) + ).rejects.toThrow('Invalid wallet address format'); + }); + + it('should require chainId', async () => { + await expect(balanceService.getBalances(mockAddress, {})).rejects.toThrow( + 'chainId is required' + ); + }); + + it('should fetch balances from Moralis API', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + skipCache: true, + }); + + const [, config] = axios.get.mock.calls[0]; + expect(config.headers).toEqual( + expect.objectContaining({ 'X-API-Key': 'test_api_key' }) + ); + expect(config.params).toBeInstanceOf(URLSearchParams); + expect(config.params.get('chain')).toBe('0x1'); + + expect(result.address).toBe(mockAddress.toLowerCase()); + expect(result.chainId).toBe('1'); + expect(result.balances).toHaveLength(1); + expect(result.balances[0].symbol).toBe('USDC'); + }); + + it('should use cache on second request', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + // First request - cache miss + const result1 = await balanceService.getBalances(mockAddress, { + chainId: 1, + }); + expect(result1.cacheHit).toBe(false); + expect(axios.get).toHaveBeenCalledTimes(1); + + // Second request - cache hit + const result2 = await balanceService.getBalances(mockAddress, { + chainId: 1, + }); + expect(result2.cacheHit).toBe(true); + expect(axios.get).toHaveBeenCalledTimes(1); // Still only 1 API call + }); + + it('should filter by token addresses', async () => { + const tokenAddresses = ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913']; + axios.get.mockResolvedValue({ data: mockResponse }); + + await balanceService.getBalances(mockAddress, { + chainId: 1, + tokenAddresses, + skipCache: true, + }); + + const [, config] = axios.get.mock.calls[0]; + expect(config.params.getAll('token_addresses')).toEqual(tokenAddresses); + }); + + it('should throw on invalid token address filter', async () => { + await expect( + balanceService.getBalances(mockAddress, { + chainId: 1, + tokenAddresses: ['not-a-token'], + }) + ).rejects.toThrow('Invalid token address format'); + }); + + it('should merge native balance when includeNative is true', async () => { + axios.get.mockImplementation(url => { + if (url.endsWith('/balance')) { + return Promise.resolve({ data: { balance: '500000000000000000' } }); + } + return Promise.resolve({ data: mockResponse }); + }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + includeNative: true, + skipCache: true, + }); + + expect(result.nativeBalance).toEqual( + expect.objectContaining({ + balance: '500000000000000000', + balanceFormatted: '0.5', + }) + ); + // Ensure both ERC20 and native endpoints were called + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('/balance'), + expect.objectContaining({ + params: { chain: '0x1' }, + }) + ); + }); + + it('should format balance correctly', async () => { + axios.get.mockResolvedValue({ data: mockResponse }); + + const result = await balanceService.getBalances(mockAddress, { + chainId: 1, + skipCache: true, + }); + + expect(result.balances[0].balanceFormatted).toBe('1000'); + expect(result.balances[0].decimals).toBe(6); + }); + }); + + describe('getNativeBalance', () => { + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockNativeResponse = { + balance: '1500000000000000000', + }; + + it('should fetch native balance', async () => { + axios.get.mockResolvedValue({ data: mockNativeResponse }); + + const result = await balanceService.getNativeBalance(mockAddress, 1); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('/balance'), + expect.objectContaining({ + params: { chain: '0x1' }, + }) + ); + + expect(result.balance).toBe('1500000000000000000'); + expect(result.balanceFormatted).toBe('1.5'); + }); + }); + + describe('Cache operations', () => { + it('should clear address cache', () => { + const address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const key = BalanceCache.generateKey(1, address); + + balanceService.cache.set(key, { test: 'data' }); + expect(balanceService.cache.get(key)).toBeTruthy(); + + const cleared = balanceService.clearAddressCache(address); + expect(cleared).toBeGreaterThan(0); + expect(balanceService.cache.get(key)).toBeNull(); + }); + + it('should clear chain cache', () => { + const key1 = BalanceCache.generateKey(1, '0xabc'); + const key2 = BalanceCache.generateKey(1, '0xdef'); + const key3 = BalanceCache.generateKey(137, '0xabc'); + + balanceService.cache.set(key1, { test: '1' }); + balanceService.cache.set(key2, { test: '2' }); + balanceService.cache.set(key3, { test: '3' }); + + const cleared = balanceService.clearChainCache(1); + expect(cleared).toBe(2); + expect(balanceService.cache.get(key1)).toBeNull(); + expect(balanceService.cache.get(key2)).toBeNull(); + expect(balanceService.cache.get(key3)).toBeTruthy(); + }); + + it('should return cache stats', () => { + const stats = balanceService.getCacheStats(); + + expect(stats).toHaveProperty('hits'); + expect(stats).toHaveProperty('misses'); + expect(stats).toHaveProperty('size'); + expect(stats).toHaveProperty('hitRate'); + }); + }); + + describe('Chain support', () => { + it('should return supported chains', () => { + const chains = balanceService.getSupportedChains(); + + expect(chains).toContain('1'); // Ethereum + expect(chains).toContain('137'); // Polygon + expect(chains).toContain('8453'); // Base + expect(chains).toContain('42161'); // Arbitrum + }); + + it('should check if chain is supported', () => { + expect(balanceService.isChainSupported(1)).toBe(true); + expect(balanceService.isChainSupported('137')).toBe(true); + expect(balanceService.isChainSupported('0x1')).toBe(true); + expect(balanceService.isChainSupported(999)).toBe(false); + }); + }); + + describe('Error handling', () => { + it('should retry on 5xx errors', async () => { + const error = new Error('Server error'); + error.response = { status: 500 }; + + axios.get + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ data: [] }); + + const result = await balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + { chainId: 1, skipCache: true } + ); + + expect(result).toBeTruthy(); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + it('should not retry on 400 errors', async () => { + const error = new Error('Bad request'); + error.response = { status: 400, data: { message: 'Invalid address' } }; + + axios.get.mockRejectedValue(error); + + await expect( + balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + { chainId: 1, skipCache: true } + ) + ).rejects.toThrow(); + + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('should handle invalid Moralis response', async () => { + axios.get.mockResolvedValue({ data: 'invalid' }); + + await expect( + balanceService.getBalances( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + { chainId: 1, skipCache: true } + ) + ).rejects.toThrow('Invalid Moralis response format'); + }); + }); +}); diff --git a/src/services/balanceService.js b/src/services/balanceService.js new file mode 100644 index 0000000..b96ba33 --- /dev/null +++ b/src/services/balanceService.js @@ -0,0 +1,430 @@ +const axios = require('axios'); +const { retryWithBackoff } = require('../utils/retry'); +const BalanceCache = require('../utils/balanceCache'); + +/** + * Moralis Balance Service + * + * Provides multi-chain ERC20 token balance retrieval with: + * - In-memory caching (3-5 min TTL) + * - Automatic retry with exponential backoff + * - Rate limiting awareness + * - Chain ID normalization (hex format) + * - Response standardization + * + * Supported Chains: Ethereum, Polygon, BSC, Arbitrum, Optimism, Base, Avalanche + * + * Environment Variables: + * - MORALIS_API_KEY: Required API key + * - BALANCE_CACHE_TTL: Cache TTL in ms (default: 180000 = 3 min) + * - MORALIS_TIMEOUT: Request timeout in ms (default: 10000) + */ +class BalanceService { + constructor() { + this.baseURL = 'https://deep-index.moralis.io/api/v2.2'; + this.apiKey = process.env.MORALIS_API_KEY; + this.timeout = parseInt(process.env.MORALIS_TIMEOUT) || 10000; + + // Initialize cache with configurable TTL + const cacheTTL = parseInt(process.env.BALANCE_CACHE_TTL) || 180000; // 3 min + this.cache = new BalanceCache(cacheTTL); + + // Chain ID mapping: decimal -> hex + this.chainMap = { + 1: '0x1', // Ethereum + 137: '0x89', // Polygon + 56: '0x38', // BSC + 42161: '0xa4b1', // Arbitrum + 10: '0xa', // Optimism + 8453: '0x2105', // Base + 43114: '0xa86a', // Avalanche + }; + + this.validateConfig(); + } + + /** + * Validate service configuration + * @throws {Error} - If API key is missing + */ + validateConfig() { + if (!this.apiKey) { + throw new Error('MORALIS_API_KEY environment variable is required'); + } + } + + /** + * Update the Moralis API key at runtime (primarily for tests) + * @param {string} apiKey - New API key value + */ + setApiKey(apiKey) { + this.apiKey = apiKey; + this.validateConfig(); + } + + /** + * Normalize chain ID to hex format required by Moralis + * @param {string|number} chainId - Chain ID (decimal or hex) + * @returns {string} - Hex chain ID with 0x prefix + * @throws {Error} - If chain is not supported + */ + normalizeChainId(chainId) { + const chainStr = String(chainId).toLowerCase(); + + // Already hex format + if (chainStr.startsWith('0x')) { + return chainStr; + } + + // Convert decimal to hex + const hexChain = this.chainMap[chainStr]; + if (!hexChain) { + throw new Error( + `Unsupported chain ID: ${chainId}. ` + + `Supported: ${Object.keys(this.chainMap).join(', ')}` + ); + } + + return hexChain; + } + + /** + * Get ERC20 token balances for a wallet address + * + * @param {string} address - Wallet address (0x...) + * @param {Object} options - Request options + * @param {string|number} options.chainId - Chain ID (required) + * @param {string[]} [options.tokenAddresses] - Specific token addresses to query + * @param {boolean} [options.skipCache=false] - Skip cache lookup + * @returns {Promise} - Standardized balance response + * + * @example + * const balances = await balanceService.getBalances( + * '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + * { chainId: 1, tokenAddresses: ['0xA0b...', '0x6B1...'] } + * ); + */ + async getBalances(address, options = {}) { + const { + chainId, + tokenAddresses = null, + includeNative = false, + skipCache = false, + } = options; + + // Validate inputs + if (!address || !address.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error('Invalid wallet address format'); + } + + if (!chainId) { + throw new Error('chainId is required'); + } + + // Normalize chain ID + const hexChainId = this.normalizeChainId(chainId); + + const sanitizedTokens = Array.isArray(tokenAddresses) + ? tokenAddresses + .map(addr => (typeof addr === 'string' ? addr.trim() : '')) + .filter(addr => addr && addr.toLowerCase() !== 'native') + : null; + + const tokenFilter = + sanitizedTokens && sanitizedTokens.length > 0 ? sanitizedTokens : null; + + const cacheKey = BalanceCache.generateKey(chainId, address, tokenFilter); + + // Check cache unless explicitly skipped + if (!skipCache) { + const cached = this.cache.get(cacheKey); + + if (cached) { + const cachedResponse = { + ...cached, + cached: true, + cacheHit: true, + }; + + if (includeNative) { + cachedResponse.nativeBalance = await this.getNativeBalance( + address, + chainId + ); + } + + return cachedResponse; + } + } + + // Fetch from Moralis API with retry + const balanceData = await this._fetchFromMoralis( + address, + hexChainId, + tokenFilter + ); + + // Standardize and cache response + const standardized = this._standardizeResponse( + balanceData, + chainId, + address + ); + + // Cache the result + this.cache.set(cacheKey, standardized); + + const response = { + ...standardized, + cached: false, + cacheHit: false, + }; + + if (includeNative) { + response.nativeBalance = await this.getNativeBalance(address, chainId); + } + + return response; + } + + /** + * Fetch balance data from Moralis API with retry logic + * @private + * @param {string} address - Wallet address + * @param {string} chain - Hex chain ID + * @param {string[]} tokenAddresses - Optional token filter + * @returns {Promise} - Raw Moralis response + */ + _fetchFromMoralis(address, chain, tokenAddresses = null) { + const fetchFn = async () => { + const params = new URLSearchParams(); + params.append('chain', chain); + + // Add token address filter if specified + if (tokenAddresses && tokenAddresses.length > 0) { + tokenAddresses.forEach(address => { + const normalizedAddress = address.trim(); + + if (!normalizedAddress.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error( + `Invalid token address format: ${normalizedAddress}` + ); + } + + params.append('token_addresses', normalizedAddress); + }); + } + + try { + const response = await axios.get(`${this.baseURL}/${address}/erc20`, { + params, + headers: { + 'X-API-Key': this.apiKey, + Accept: 'application/json', + }, + timeout: this.timeout, + }); + + return response.data; + } catch (error) { + // Enhance error with context + if (error.response) { + const moralisError = new Error( + `Moralis API error: ${error.response.status} - ${ + error.response.data?.message || error.message + }` + ); + moralisError.status = error.response.status; + moralisError.response = error.response; + throw moralisError; + } + throw error; + } + }; + + // Retry with backoff + return retryWithBackoff( + fetchFn, + { + retries: 3, + minTimeout: 2000, + maxTimeout: 8000, + factor: 2, + context: 'Moralis Balance API', + }, + this._shouldRetry.bind(this) + ); + } + + /** + * Determine if error should trigger retry + * @private + * @param {Error} error - Error object + * @returns {boolean} - True if should retry + */ + _shouldRetry(error) { + // Don't retry on 4xx client errors (except 429 rate limit) + if (error.status >= 400 && error.status < 500) { + if (error.status === 429) { + console.warn('[BalanceService] Rate limit hit, retrying...'); + return true; + } + console.log(`[BalanceService] Not retrying HTTP ${error.status}`); + return false; + } + + // Retry on 5xx server errors and network issues + return true; + } + + /** + * Standardize Moralis response format + * @private + * @param {Array} rawData - Moralis API response + * @param {string|number} chainId - Original chain ID + * @param {string} address - Wallet address + * @returns {Object} - Standardized response + */ + _standardizeResponse(rawData, chainId, address) { + if (!Array.isArray(rawData)) { + const error = new Error('Invalid Moralis response format'); + error.status = 500; + throw error; + } + + const balances = rawData.map(token => ({ + tokenAddress: token.token_address, + name: token.name, + symbol: token.symbol, + decimals: parseInt(token.decimals), + balance: token.balance, + balanceFormatted: this._formatBalance(token.balance, token.decimals), + logo: token.logo || null, + thumbnail: token.thumbnail || null, + // Additional metadata if available + possibleSpam: token.possible_spam || false, + verifiedContract: token.verified_contract || false, + })); + + return { + address, + chainId: String(chainId), + balances, + totalTokens: balances.length, + timestamp: Date.now(), + }; + } + + /** + * Format raw balance to human-readable decimal + * @private + * @param {string} rawBalance - Raw balance string + * @param {number} decimals - Token decimals + * @returns {string} - Formatted balance + */ + _formatBalance(rawBalance, decimals) { + if (!rawBalance || rawBalance === '0') { + return '0'; + } + + const divisor = BigInt(10) ** BigInt(decimals); + const balanceBigInt = BigInt(rawBalance); + const wholePart = balanceBigInt / divisor; + const fractionalPart = balanceBigInt % divisor; + + if (fractionalPart === 0n) { + return wholePart.toString(); + } + + // Format with decimals + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + const trimmed = fractionalStr.replace(/0+$/, ''); + + return `${wholePart}.${trimmed}`; + } + + /** + * Get native token balance (ETH, MATIC, etc.) + * Note: Requires different Moralis endpoint + * + * @param {string} address - Wallet address + * @param {string|number} chainId - Chain ID + * @returns {Promise} - Native balance data + */ + async getNativeBalance(address, chainId) { + const hexChainId = this.normalizeChainId(chainId); + + const fetchFn = async () => { + const response = await axios.get(`${this.baseURL}/${address}/balance`, { + params: { chain: hexChainId }, + headers: { + 'X-API-Key': this.apiKey, + Accept: 'application/json', + }, + timeout: this.timeout, + }); + + return response.data; + }; + + const data = await retryWithBackoff( + fetchFn, + { retries: 3, minTimeout: 2000, context: 'Moralis Native Balance' }, + this._shouldRetry.bind(this) + ); + + return { + address, + chainId: String(chainId), + balance: data.balance, + balanceFormatted: this._formatBalance(data.balance, 18), // Most native tokens use 18 decimals + timestamp: Date.now(), + }; + } + + /** + * Clear cache for specific address + * @param {string} address - Wallet address + * @returns {number} - Number of entries cleared + */ + clearAddressCache(address) { + return this.cache.clearAddress(address); + } + + /** + * Clear cache for specific chain + * @param {string|number} chainId - Chain ID + * @returns {number} - Number of entries cleared + */ + clearChainCache(chainId) { + return this.cache.clearChain(chainId); + } + + /** + * Get cache statistics + * @returns {Object} - Cache stats + */ + getCacheStats() { + return this.cache.getStats(); + } + + /** + * Get supported chain IDs + * @returns {Array} - List of supported decimal chain IDs + */ + getSupportedChains() { + return Object.keys(this.chainMap); + } + + /** + * Check if chain is supported + * @param {string|number} chainId - Chain ID to check + * @returns {boolean} - True if supported + */ + isChainSupported(chainId) { + const chainStr = String(chainId); + return chainStr in this.chainMap || chainStr.startsWith('0x'); + } +} + +module.exports = BalanceService; diff --git a/src/services/priceService/PriceCache.enhanced.js b/src/services/priceService/PriceCache.enhanced.js new file mode 100644 index 0000000..24a9671 --- /dev/null +++ b/src/services/priceService/PriceCache.enhanced.js @@ -0,0 +1,205 @@ +/** + * Enhanced Price Cache - Handles caching concerns for price data with monitoring + * + * IMPROVEMENTS: + * - Aligned TTL with balance cache (3 minutes) + * - Added cache monitoring and metrics + * - Stale data detection + * - Improved logging + */ + +class PriceCacheMonitor { + constructor() { + this.metrics = { + hits: 0, + misses: 0, + staleHits: 0, + sets: 0, + totalCacheAge: 0, + }; + this.staleThreshold = 300000; // 5 minutes + } + + recordHit(cacheAge) { + this.metrics.hits++; + this.metrics.totalCacheAge += cacheAge; + + if (cacheAge > this.staleThreshold) { + this.metrics.staleHits++; + console.warn( + `[PriceCache] Stale price hit: ${(cacheAge / 1000).toFixed(1)}s old` + ); + } + } + + recordMiss() { + this.metrics.misses++; + } + + recordSet() { + this.metrics.sets++; + } + + getHitRate() { + const total = this.metrics.hits + this.metrics.misses; + return total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) : '0.00'; + } + + getAvgCacheAge() { + return this.metrics.hits > 0 + ? Math.round(this.metrics.totalCacheAge / this.metrics.hits) + : 0; + } + + getStats() { + return { + ...this.metrics, + hitRate: `${this.getHitRate()}%`, + avgCacheAge: `${(this.getAvgCacheAge() / 1000).toFixed(1)}s`, + staleHitRate: + this.metrics.hits > 0 + ? `${((this.metrics.staleHits / this.metrics.hits) * 100).toFixed(2)}%` + : '0.00%', + }; + } + + reset() { + this.metrics = { + hits: 0, + misses: 0, + staleHits: 0, + sets: 0, + totalCacheAge: 0, + }; + } +} + +class PriceCache { + constructor() { + this.cache = new Map(); + this.cacheTimeouts = new Map(); + this.monitor = new PriceCacheMonitor(); + } + + /** + * Get cached price if available and not expired (with monitoring) + * @param {string} symbol - Token symbol + * @returns {Object|null} - Cached price data or null + */ + get(symbol) { + const cacheKey = symbol.toLowerCase(); + const cached = this.cache.get(cacheKey); + const timeout = this.cacheTimeouts.get(cacheKey); + + if (cached && timeout && Date.now() < timeout) { + const cacheAge = Date.now() - (cached.timestamp || 0); + this.monitor.recordHit(cacheAge); + return cached; + } + + // Clean up expired cache entries + if (cached) { + this.cache.delete(cacheKey); + this.cacheTimeouts.delete(cacheKey); + } + + this.monitor.recordMiss(); + return null; + } + + /** + * Set price in cache with TTL (aligned with balance cache: 3 minutes) + * @param {string} symbol - Token symbol + * @param {Object} priceData - Price data to cache + * @param {number} ttl - Time to live in seconds (default: 180s = 3 minutes) + */ + set(symbol, priceData, ttl = 180) { + const cacheKey = symbol.toLowerCase(); + + // Add timestamp to price data + const dataWithTimestamp = { + ...priceData, + timestamp: Date.now(), + }; + + this.cache.set(cacheKey, dataWithTimestamp); + this.cacheTimeouts.set(cacheKey, Date.now() + ttl * 1000); + this.monitor.recordSet(); + } + + /** + * Get cache statistics with monitoring data + * @returns {Object} - Enhanced cache info + */ + getStats() { + const monitorStats = this.monitor.getStats(); + + return { + size: this.cache.size, + entries: Array.from(this.cache.keys()), + ...monitorStats, + }; + } + + /** + * Clear cache + */ + clear() { + this.cache.clear(); + this.cacheTimeouts.clear(); + } + + /** + * Get cached prices for multiple symbols + * @param {Array} symbols - Array of token symbols + * @returns {Object} - Object with results and remaining symbols + */ + getBulk(symbols) { + const results = {}; + const remaining = new Set(symbols.map(s => s.toLowerCase())); + + for (const symbol of symbols) { + const cached = this.get(symbol); + if (cached) { + results[symbol.toLowerCase()] = { + ...cached, + fromCache: true, + }; + remaining.delete(symbol.toLowerCase()); + } + } + + return { + results, + remaining: Array.from(remaining), + }; + } + + /** + * Reset monitoring statistics + */ + resetStats() { + this.monitor.reset(); + } + + /** + * Log detailed cache health report + */ + logHealthReport() { + const stats = this.getStats(); + + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(' Price Cache Health Report'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(` Cache Size: ${stats.size} tokens`); + console.log(` Hit Rate: ${stats.hitRate}`); + console.log(` Avg Cache Age: ${stats.avgCacheAge}`); + console.log(` Stale Hit Rate: ${stats.staleHitRate}`); + console.log(` Total Hits: ${stats.hits}`); + console.log(` Total Misses: ${stats.misses}`); + console.log(` Total Sets: ${stats.sets}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + } +} + +module.exports = PriceCache; diff --git a/src/utils/__tests__/balanceCache.enhanced.test.js b/src/utils/__tests__/balanceCache.enhanced.test.js new file mode 100644 index 0000000..33e3d42 --- /dev/null +++ b/src/utils/__tests__/balanceCache.enhanced.test.js @@ -0,0 +1,310 @@ +/** + * Enhanced Balance Cache Tests + * Testing TTL alignment, monitoring, partial updates, and cache invalidation + */ + +const BalanceCache = require('../balanceCache.enhanced'); + +describe('Enhanced BalanceCache', () => { + let cache; + + beforeEach(() => { + jest.clearAllMocks(); + cache = new BalanceCache(180000); // 3 minutes TTL + }); + + afterEach(() => { + cache.stopCleanup(); + }); + + describe('Cache TTL Alignment', () => { + it('should expire cache after 3 minutes (aligned with price cache)', done => { + const key = BalanceCache.generateKey('1', '0x123', null); + const data = { + balances: [{ tokenAddress: '0xAAA', balance: '1000' }], + timestamp: Date.now(), + }; + + cache.set(key, data); + + // Should be cached immediately + expect(cache.get(key)).toBeTruthy(); + + // Should still be cached after 2 minutes + setTimeout(() => { + expect(cache.get(key)).toBeTruthy(); + }, 120000); + + // Should be expired after 3 minutes + setTimeout(() => { + expect(cache.get(key)).toBeNull(); + done(); + }, 180000); + }, 200000); + + it('should align with backend TTL (180000ms = 180 seconds)', () => { + expect(cache.defaultTTL).toBe(180000); + }); + }); + + describe('Monitoring and Metrics', () => { + it('should track cache hits and misses', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + const data = { balances: [], timestamp: Date.now() }; + + // Miss - no data + expect(cache.get(key)).toBeNull(); + + // Set data + cache.set(key, data); + + // Hit - data exists + expect(cache.get(key)).toBeTruthy(); + expect(cache.get(key)).toBeTruthy(); + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(1); + expect(stats.sets).toBe(1); + }); + + it('should detect stale cache hits', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const key = BalanceCache.generateKey('1', '0x123', null); + const staleData = { + balances: [], + timestamp: Date.now() - 600000, // 10 minutes old + }; + + cache.set(key, staleData); + cache.get(key); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Stale cache hit') + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should calculate hit rate correctly', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + cache.set(key, { balances: [], timestamp: Date.now() }); + + cache.get(key); // hit + cache.get(key); // hit + cache.get('nonexistent'); // miss + + const stats = cache.getStats(); + expect(stats.hitRate).toBe('66.67%'); // 2 hits / 3 total + }); + + it('should track average cache age', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + const timestamp = Date.now() - 30000; // 30 seconds old + + cache.set(key, { balances: [], timestamp }); + cache.get(key); + + const stats = cache.getStats(); + expect(stats.avgCacheAge).toMatch(/30\./); // ~30 seconds + }); + }); + + describe('Partial Cache Updates', () => { + it('should update specific token balance without full invalidation', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + const originalData = { + balances: [ + { tokenAddress: '0xAAA', balance: '1000' }, + { tokenAddress: '0xBBB', balance: '2000' }, + ], + timestamp: Date.now(), + }; + + cache.set(key, originalData); + + // Update single token + const updated = cache.updateTokenBalance('1', '0x123', '0xAAA', '1500'); + expect(updated).toBe(true); + + const cachedData = cache.get(key); + expect(cachedData.balances[0].balance).toBe('1500'); + expect(cachedData.balances[1].balance).toBe('2000'); // Unchanged + expect(cachedData.partialUpdate).toBe(true); + + const stats = cache.getStats(); + expect(stats.partialUpdates).toBe(1); + }); + + it('should handle partial update for non-existent token', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + const data = { + balances: [{ tokenAddress: '0xAAA', balance: '1000' }], + timestamp: Date.now(), + }; + + cache.set(key, data); + + const updated = cache.updateTokenBalance('1', '0x123', '0xCCC', '3000'); + expect(updated).toBe(false); // Token not found, no update + }); + + it('should refresh specific tokens without full cache invalidation', async () => { + const key = BalanceCache.generateKey('1', '0x123', null); + const originalData = { + balances: [ + { tokenAddress: '0xAAA', balance: '1000' }, + { tokenAddress: '0xBBB', balance: '2000' }, + ], + timestamp: Date.now() - 60000, // 1 minute old + }; + + cache.set(key, originalData); + + // Mock fetch function + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xAAA', balance: '1500' }], + timestamp: Date.now(), + }); + + const refreshed = await cache.refreshTokens( + '1', + '0x123', + ['0xAAA'], + fetchFn + ); + + expect(fetchFn).toHaveBeenCalledWith('1', '0x123', ['0xAAA']); + expect(refreshed.balances).toHaveLength(2); // Merged result + expect(refreshed.partialUpdate).toBe(true); + expect( + refreshed.balances.find(b => b.tokenAddress === '0xAAA').balance + ).toBe('1500'); + expect( + refreshed.balances.find(b => b.tokenAddress === '0xBBB').balance + ).toBe('2000'); + }); + }); + + describe('Cache Invalidation', () => { + it('should invalidate cache after transaction', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + const key = BalanceCache.generateKey('1', '0x123', null); + cache.set(key, { balances: [], timestamp: Date.now() }); + + const cleared = cache.invalidateAfterTransaction('1', '0x123', 'zapIn'); + + expect(cleared).toBe(1); + expect(cache.get(key)).toBeNull(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalidated: address 0x123 (transaction:zapIn)' + ) + ); + + consoleLogSpy.mockRestore(); + }); + + it('should clear all caches for an address with reason', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + cache.set(BalanceCache.generateKey('1', '0x123', ['0xAAA']), { + balances: [], + }); + cache.set(BalanceCache.generateKey('1', '0x123', ['0xBBB']), { + balances: [], + }); + cache.set(BalanceCache.generateKey('42161', '0x123', null), { + balances: [], + }); + + const cleared = cache.clearAddress('0x123', 'balance_changed'); + + expect(cleared).toBe(3); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalidated: address 0x123 (balance_changed)') + ); + + consoleLogSpy.mockRestore(); + }); + + it('should clear all caches for a chain with reason', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + cache.set(BalanceCache.generateKey('1', '0x123', null), { balances: [] }); + cache.set(BalanceCache.generateKey('1', '0x456', null), { balances: [] }); + cache.set(BalanceCache.generateKey('42161', '0x123', null), { + balances: [], + }); + + const cleared = cache.clearChain('1', 'chain_reorg'); + + expect(cleared).toBe(2); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalidated: chain 1 (chain_reorg)') + ); + + consoleLogSpy.mockRestore(); + }); + }); + + describe('Cache Health Reporting', () => { + it('should log comprehensive health report', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + const key = BalanceCache.generateKey('1', '0x123', null); + cache.set(key, { balances: [], timestamp: Date.now() }); + cache.get(key); + + cache.logHealthReport(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Balance Cache Health Report') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Cache Size:') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Hit Rate:') + ); + + consoleLogSpy.mockRestore(); + }); + + it('should provide detailed stats', () => { + const key = BalanceCache.generateKey('1', '0x123', null); + cache.set(key, { balances: [], timestamp: Date.now() }); + cache.get(key); + cache.get('nonexistent'); + + const stats = cache.getStats(); + + expect(stats).toHaveProperty('size', 1); + expect(stats).toHaveProperty('hits', 1); + expect(stats).toHaveProperty('misses', 1); + expect(stats).toHaveProperty('sets', 1); + expect(stats).toHaveProperty('hitRate', '50.00%'); + expect(stats).toHaveProperty('avgCacheAge'); + expect(stats).toHaveProperty('memoryUsage'); + expect(stats).toHaveProperty('partialUpdates', 0); + expect(stats).toHaveProperty('invalidations', 0); + }); + }); + + describe('Backward Compatibility', () => { + it('should maintain same key generation logic', () => { + const key1 = BalanceCache.generateKey('1', '0xAbC', null); + const key2 = BalanceCache.generateKey('1', '0xabc', null); + + expect(key1).toBe(key2); // Case insensitive + expect(key1).toBe('balance:1:0xabc:all'); + }); + + it('should maintain same key format for token arrays', () => { + const key = BalanceCache.generateKey('1', '0x123', ['0xBBB', '0xAAA']); + expect(key).toBe('balance:1:0x123:0xaaa,0xbbb'); // Sorted + }); + }); +}); diff --git a/src/utils/__tests__/balanceCache.test.js b/src/utils/__tests__/balanceCache.test.js new file mode 100644 index 0000000..eff4395 --- /dev/null +++ b/src/utils/__tests__/balanceCache.test.js @@ -0,0 +1,284 @@ +const BalanceCache = require('../balanceCache'); + +describe('BalanceCache', () => { + let cache; + + beforeEach(() => { + cache = new BalanceCache(1000); // 1 second TTL for testing + }); + + afterEach(() => { + cache.stopCleanup(); + }); + + describe('Cache key generation', () => { + it('should generate consistent keys', () => { + const key1 = BalanceCache.generateKey(1, '0xABC'); + const key2 = BalanceCache.generateKey('1', '0xabc'); + + expect(key1).toBe(key2); + expect(key1).toBe('balance:1:0xabc:all'); + }); + + it('should include token addresses in key', () => { + const tokens = ['0xDEF', '0xABC']; + const key = BalanceCache.generateKey(1, '0x123', tokens); + + // Should be sorted + expect(key).toBe('balance:1:0x123:0xabc,0xdef'); + }); + + it('should handle empty token array', () => { + const key = BalanceCache.generateKey(1, '0x123', []); + expect(key).toBe('balance:1:0x123:all'); + }); + }); + + describe('Set and Get', () => { + it('should store and retrieve data', () => { + const key = 'test:key'; + const data = { balance: '1000' }; + + cache.set(key, data); + const retrieved = cache.get(key); + + expect(retrieved).toEqual(data); + expect(cache.getStats().sets).toBe(1); + expect(cache.getStats().hits).toBe(1); + }); + + it('should return null for missing keys', () => { + const result = cache.get('nonexistent'); + + expect(result).toBeNull(); + expect(cache.getStats().misses).toBe(1); + }); + + it('should expire entries after TTL', async () => { + const key = 'test:expire'; + cache.set(key, { test: 'data' }, 100); // 100ms TTL + + // Should exist immediately + expect(cache.get(key)).toBeTruthy(); + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 150)); + + // Should be expired + expect(cache.get(key)).toBeNull(); + }); + + it('should use default TTL if not specified', () => { + const key = 'test:default'; + cache.set(key, { data: 'test' }); + + const expiration = cache.expirations.get(key); + const now = Date.now(); + + expect(expiration).toBeGreaterThan(now); + expect(expiration).toBeLessThanOrEqual(now + 1000); // Default 1s + }); + }); + + describe('Delete', () => { + it('should delete specific key', () => { + const key = 'test:delete'; + cache.set(key, { data: 'test' }); + + expect(cache.get(key)).toBeTruthy(); + + const deleted = cache.delete(key); + expect(deleted).toBe(true); + expect(cache.get(key)).toBeNull(); + }); + + it('should return false for non-existent key', () => { + const deleted = cache.delete('nonexistent'); + expect(deleted).toBe(false); + }); + }); + + describe('Clear operations', () => { + beforeEach(() => { + // Setup test data + cache.set(BalanceCache.generateKey(1, '0xabc'), { data: '1' }); + cache.set(BalanceCache.generateKey(1, '0xdef'), { data: '2' }); + cache.set(BalanceCache.generateKey(137, '0xabc'), { data: '3' }); + cache.set(BalanceCache.generateKey(137, '0xghi'), { data: '4' }); + }); + + it('should clear all entries for an address', () => { + const cleared = cache.clearAddress('0xabc'); + + expect(cleared).toBe(2); // Both chain 1 and 137 + expect(cache.cache.size).toBe(2); // 0xdef and 0xghi remain + }); + + it('should clear all entries for a chain', () => { + const cleared = cache.clearChain(1); + + expect(cleared).toBe(2); // Both addresses on chain 1 + expect(cache.cache.size).toBe(2); // Chain 137 entries remain + }); + + it('should clear all entries', () => { + cache.clear(); + + expect(cache.cache.size).toBe(0); + expect(cache.expirations.size).toBe(0); + }); + + it('should be case-insensitive for addresses', () => { + const cleared = cache.clearAddress('0xABC'); + expect(cleared).toBe(2); + }); + }); + + describe('Cleanup', () => { + it('should remove expired entries', async () => { + cache.set('key1', { data: '1' }, 50); // Expire in 50ms + cache.set('key2', { data: '2' }, 50); + cache.set('key3', { data: '3' }, 5000); // Expire in 5s + + expect(cache.cache.size).toBe(3); + + // Wait for first two to expire + await new Promise(resolve => setTimeout(resolve, 100)); + + const cleaned = cache.cleanup(); + expect(cleaned).toBe(2); + expect(cache.cache.size).toBe(1); + expect(cache.get('key3')).toBeTruthy(); + }); + + it('should not clean unexpired entries', () => { + cache.set('key1', { data: '1' }, 5000); + cache.set('key2', { data: '2' }, 5000); + + const cleaned = cache.cleanup(); + expect(cleaned).toBe(0); + expect(cache.cache.size).toBe(2); + }); + }); + + describe('Statistics', () => { + it('should track cache hits and misses', () => { + cache.set('key1', { data: '1' }); + + cache.get('key1'); // Hit + cache.get('key1'); // Hit + cache.get('key2'); // Miss + cache.get('key3'); // Miss + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(2); + expect(stats.hitRate).toBe('50.00%'); + }); + + it('should track sets and evictions', () => { + cache.set('key1', { data: '1' }); + cache.set('key2', { data: '2' }); + + cache.delete('key1'); + + const stats = cache.getStats(); + expect(stats.sets).toBe(2); + expect(stats.size).toBe(1); + }); + + it('should calculate hit rate correctly', () => { + cache.set('key', { data: 'test' }); + + // 3 hits, 1 miss = 75% + cache.get('key'); + cache.get('key'); + cache.get('key'); + cache.get('missing'); + + const stats = cache.getStats(); + expect(stats.hitRate).toBe('75.00%'); + }); + + it('should handle zero requests', () => { + const stats = cache.getStats(); + expect(stats.hitRate).toBe('0%'); + }); + + it('should estimate memory usage', () => { + cache.set('key1', { data: '1' }); + cache.set('key2', { data: '2' }); + + const stats = cache.getStats(); + expect(stats.memoryUsage).toContain('KB'); + }); + + it('should reset statistics', () => { + cache.set('key', { data: 'test' }); + cache.get('key'); + cache.get('missing'); + + cache.resetStats(); + + const stats = cache.getStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + expect(stats.sets).toBe(0); + }); + }); + + describe('Automatic cleanup', () => { + it('should start cleanup interval by default', () => { + const newCache = new BalanceCache(); + expect(newCache.cleanupInterval).toBeTruthy(); + newCache.stopCleanup(); + }); + + it('should stop cleanup interval', () => { + cache.stopCleanup(); + expect(cache.cleanupInterval).toBeNull(); + }); + + it('should restart cleanup interval', () => { + cache.stopCleanup(); + cache.startCleanup(100); + expect(cache.cleanupInterval).toBeTruthy(); + }); + }); + + describe('Edge cases', () => { + it('should handle very large numbers', () => { + const key = 'large'; + const data = { balance: '999999999999999999999999' }; + + cache.set(key, data); + expect(cache.get(key)).toEqual(data); + }); + + it('should handle concurrent access', () => { + const key = 'concurrent'; + + cache.set(key, { value: 1 }); + cache.set(key, { value: 2 }); + cache.set(key, { value: 3 }); + + const result = cache.get(key); + expect(result.value).toBe(3); + }); + + it('should handle null and undefined values', () => { + cache.set('null', null); + cache.set('undefined', undefined); + + expect(cache.get('null')).toBeNull(); + expect(cache.get('undefined')).toBeUndefined(); + }); + + it('should handle special characters in keys', () => { + const key = 'balance:1:0x123:token,token2'; + cache.set(key, { data: 'test' }); + + expect(cache.get(key)).toEqual({ data: 'test' }); + }); + }); +}); diff --git a/src/utils/balanceCache.enhanced.js b/src/utils/balanceCache.enhanced.js new file mode 100644 index 0000000..197d99c --- /dev/null +++ b/src/utils/balanceCache.enhanced.js @@ -0,0 +1,468 @@ +/** + * Enhanced In-Memory Cache Manager for Token Balances + * + * NEW FEATURES: + * - Partial cache updates (update single tokens without full invalidation) + * - Cache monitoring and metrics + * - Stale data detection and warnings + * - Improved logging and observability + * - Cache age tracking + * + * Cache Key Format: balance:{chainId}:{address}:{tokenAddresses} + * Default TTL: 3 minutes (aligned with price cache) + */ + +class BalanceCacheMonitor { + constructor() { + this.metrics = { + hits: 0, + misses: 0, + staleHits: 0, + sets: 0, + evictions: 0, + invalidations: 0, + partialUpdates: 0, + totalCacheAge: 0, + }; + this.staleThreshold = 300000; // 5 minutes - warn if cache older than this + } + + recordHit(cacheAge) { + this.metrics.hits++; + this.metrics.totalCacheAge += cacheAge; + + if (cacheAge > this.staleThreshold) { + this.metrics.staleHits++; + console.warn( + `[BalanceCache] Stale cache hit: ${(cacheAge / 1000).toFixed(1)}s old` + ); + } + } + + recordMiss() { + this.metrics.misses++; + } + + recordSet() { + this.metrics.sets++; + } + + recordEviction() { + this.metrics.evictions++; + } + + recordInvalidation(reason) { + this.metrics.invalidations++; + console.log(`[BalanceCache] Invalidated: ${reason}`); + } + + recordPartialUpdate() { + this.metrics.partialUpdates++; + } + + getHitRate() { + const total = this.metrics.hits + this.metrics.misses; + return total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) : '0.00'; + } + + getAvgCacheAge() { + return this.metrics.hits > 0 + ? Math.round(this.metrics.totalCacheAge / this.metrics.hits) + : 0; + } + + getStats() { + return { + ...this.metrics, + hitRate: `${this.getHitRate()}%`, + avgCacheAge: `${(this.getAvgCacheAge() / 1000).toFixed(1)}s`, + staleHitRate: + this.metrics.hits > 0 + ? `${((this.metrics.staleHits / this.metrics.hits) * 100).toFixed(2)}%` + : '0.00%', + }; + } + + reset() { + this.metrics = { + hits: 0, + misses: 0, + staleHits: 0, + sets: 0, + evictions: 0, + invalidations: 0, + partialUpdates: 0, + totalCacheAge: 0, + }; + } +} + +class BalanceCache { + constructor(defaultTTL = 180000) { + // 3 minutes default (aligned with price cache) + this.cache = new Map(); + this.expirations = new Map(); + this.defaultTTL = defaultTTL; + this.cleanupInterval = null; + this.monitor = new BalanceCacheMonitor(); + + // Start cleanup interval (runs every minute) + this.startCleanup(); + } + + /** + * Generate cache key from balance request parameters + * @param {string|number} chainId - Chain ID + * @param {string} address - Wallet address + * @param {string[]} [tokenAddresses] - Optional array of token addresses + * @returns {string} - Cache key + */ + static generateKey(chainId, address, tokenAddresses = null) { + const normalizedChain = String(chainId).toLowerCase(); + const normalizedAddress = address.toLowerCase(); + + if (tokenAddresses && tokenAddresses.length > 0) { + // Sort token addresses for consistent keys + const sortedTokens = [...tokenAddresses] + .map(t => t.toLowerCase()) + .sort() + .join(','); + return `balance:${normalizedChain}:${normalizedAddress}:${sortedTokens}`; + } + + return `balance:${normalizedChain}:${normalizedAddress}:all`; + } + + /** + * Get cached balance data with monitoring + * @param {string} key - Cache key + * @returns {Object|null} - Cached balance data or null if expired/missing + */ + get(key) { + const expiresAt = this.expirations.get(key); + + // Check if expired + if (!expiresAt || Date.now() >= expiresAt) { + if (this.cache.has(key)) { + this.delete(key); + this.monitor.recordEviction(); + } + this.monitor.recordMiss(); + return null; + } + + const data = this.cache.get(key); + if (data) { + const cacheAge = Date.now() - data.timestamp; + this.monitor.recordHit(cacheAge); + return data; + } + + this.monitor.recordMiss(); + return null; + } + + /** + * Set balance data in cache with TTL + * @param {string} key - Cache key + * @param {Object} data - Balance data to cache + * @param {number} [ttl] - Time to live in milliseconds (optional) + */ + set(key, data, ttl = null) { + const expiresAt = Date.now() + (ttl || this.defaultTTL); + + // Add timestamp to data if not present + const dataWithTimestamp = { + ...data, + timestamp: data.timestamp || Date.now(), + }; + + this.cache.set(key, dataWithTimestamp); + this.expirations.set(key, expiresAt); + this.monitor.recordSet(); + } + + /** + * Update specific token balance without clearing entire cache + * @param {string|number} chainId - Chain ID + * @param {string} address - Wallet address + * @param {string} tokenAddress - Token address to update + * @param {string} newBalance - New balance value + * @returns {boolean} - True if update successful, false if cache miss + */ + updateTokenBalance(chainId, address, tokenAddress, newBalance) { + const cacheKey = BalanceCache.generateKey(chainId, address, null); + const cached = this.get(cacheKey); + + if (!cached || !cached.balances) { + return false; + } + + // Update specific token in cached balances + const updatedBalances = cached.balances.map(bal => + bal.tokenAddress.toLowerCase() === tokenAddress.toLowerCase() + ? { ...bal, balance: newBalance, lastUpdated: Date.now() } + : bal + ); + + // Check if token was found and updated + const wasUpdated = updatedBalances.some( + bal => bal.lastUpdated && bal.lastUpdated === Date.now() + ); + + if (wasUpdated) { + this.set(cacheKey, { + ...cached, + balances: updatedBalances, + partialUpdate: true, + lastPartialUpdate: Date.now(), + }); + + this.monitor.recordPartialUpdate(); + console.log( + `[BalanceCache] Partial update: ${tokenAddress} on chain ${chainId}` + ); + } + + return wasUpdated; + } + + /** + * Refresh specific tokens without full cache invalidation + * @param {string|number} chainId - Chain ID + * @param {string} address - Wallet address + * @param {string[]} tokenAddresses - Array of token addresses to refresh + * @param {Function} fetchFn - Async function to fetch fresh token balances + * @returns {Promise} - Updated cache data + */ + async refreshTokens(chainId, address, tokenAddresses, fetchFn) { + const cacheKey = BalanceCache.generateKey(chainId, address, null); + const cached = this.get(cacheKey); + + // Fetch fresh data for specified tokens + const freshData = await fetchFn(chainId, address, tokenAddresses); + + if (!cached) { + // No existing cache, set fresh data + this.set(cacheKey, freshData); + return freshData; + } + + // Merge fresh data with cached data + const tokenAddressSet = new Set(tokenAddresses.map(t => t.toLowerCase())); + const mergedBalances = [ + // Keep cached balances for tokens not being refreshed + ...cached.balances.filter( + bal => !tokenAddressSet.has(bal.tokenAddress.toLowerCase()) + ), + // Add fresh balances for refreshed tokens + ...freshData.balances, + ]; + + const updated = { + ...cached, + balances: mergedBalances, + partialUpdate: true, + lastPartialUpdate: Date.now(), + timestamp: Date.now(), + }; + + this.set(cacheKey, updated); + this.monitor.recordPartialUpdate(); + + console.log( + `[BalanceCache] Refreshed ${tokenAddresses.length} tokens on chain ${chainId}` + ); + return updated; + } + + /** + * Delete specific cache entry + * @param {string} key - Cache key + * @returns {boolean} - True if deleted, false if not found + */ + delete(key) { + const deleted = this.cache.delete(key); + this.expirations.delete(key); + return deleted; + } + + /** + * Clear all cache entries for a specific address (with reason logging) + * @param {string} address - Wallet address + * @param {string} reason - Reason for invalidation + * @returns {number} - Number of entries cleared + */ + clearAddress(address, reason = 'manual') { + const normalizedAddress = address.toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.includes(`:${normalizedAddress}:`)) { + this.delete(key); + cleared++; + } + } + + if (cleared > 0) { + this.monitor.recordInvalidation(`address ${address} (${reason})`); + } + + return cleared; + } + + /** + * Clear all cache entries for a specific chain + * @param {string|number} chainId - Chain ID + * @param {string} reason - Reason for invalidation + * @returns {number} - Number of entries cleared + */ + clearChain(chainId, reason = 'manual') { + const normalizedChain = String(chainId).toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.startsWith(`balance:${normalizedChain}:`)) { + this.delete(key); + cleared++; + } + } + + if (cleared > 0) { + this.monitor.recordInvalidation(`chain ${chainId} (${reason})`); + } + + return cleared; + } + + /** + * Invalidate cache after transaction completion + * @param {string|number} chainId - Chain ID where transaction occurred + * @param {string} address - Wallet address + * @param {string} txType - Transaction type (zapIn, zapOut, rebalance, etc.) + * @returns {number} - Number of entries cleared + */ + invalidateAfterTransaction(chainId, address, txType) { + const reason = `transaction:${txType}`; + return this.clearAddress(address, reason); + } + + /** + * Clear all cache entries + */ + clear() { + const size = this.cache.size; + this.cache.clear(); + this.expirations.clear(); + this.monitor.recordInvalidation(`full clear (${size} entries)`); + } + + /** + * Clean up expired entries + * @returns {number} - Number of entries cleaned + */ + cleanup() { + const now = Date.now(); + let cleaned = 0; + + for (const [key, expiresAt] of this.expirations.entries()) { + if (now >= expiresAt) { + this.delete(key); + cleaned++; + this.monitor.recordEviction(); + } + } + + return cleaned; + } + + /** + * Start automatic cleanup interval + * @param {number} [interval] - Cleanup interval in ms (default: 60000) + */ + startCleanup(interval = 60000) { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + this.cleanupInterval = setInterval(() => { + const cleaned = this.cleanup(); + if (cleaned > 0) { + console.log(`[BalanceCache] Cleaned up ${cleaned} expired entries`); + } + }, interval); + + // Prevent cleanup interval from keeping process alive + if (this.cleanupInterval.unref) { + this.cleanupInterval.unref(); + } + } + + /** + * Stop automatic cleanup interval + */ + stopCleanup() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Get cache statistics with monitoring data + * @returns {Object} - Enhanced cache stats + */ + getStats() { + const monitorStats = this.monitor.getStats(); + + return { + size: this.cache.size, + defaultTTL: this.defaultTTL, + ...monitorStats, + memoryUsage: this.estimateMemoryUsage(), + }; + } + + /** + * Estimate memory usage (rough approximation) + * @returns {string} - Memory usage estimate + */ + estimateMemoryUsage() { + const sizeKB = Math.ceil(this.cache.size * 0.5); // Rough estimate: ~0.5KB per entry + if (sizeKB < 1024) { + return `${sizeKB} KB`; + } + return `${(sizeKB / 1024).toFixed(2)} MB`; + } + + /** + * Reset cache statistics + */ + resetStats() { + this.monitor.reset(); + } + + /** + * Log detailed cache health report + */ + logHealthReport() { + const stats = this.getStats(); + + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(' Balance Cache Health Report'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(` Cache Size: ${stats.size} entries`); + console.log(` Hit Rate: ${stats.hitRate}`); + console.log(` Avg Cache Age: ${stats.avgCacheAge}`); + console.log(` Stale Hit Rate: ${stats.staleHitRate}`); + console.log(` Total Hits: ${stats.hits}`); + console.log(` Total Misses: ${stats.misses}`); + console.log(` Partial Updates: ${stats.partialUpdates}`); + console.log(` Invalidations: ${stats.invalidations}`); + console.log(` Memory Usage: ${stats.memoryUsage}`); + console.log(` TTL: ${stats.defaultTTL / 1000}s`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + } +} + +module.exports = BalanceCache; diff --git a/src/utils/balanceCache.js b/src/utils/balanceCache.js new file mode 100644 index 0000000..0726846 --- /dev/null +++ b/src/utils/balanceCache.js @@ -0,0 +1,249 @@ +/** + * In-Memory Cache Manager for Token Balances + * + * Provides TTL-based caching with automatic cleanup to prevent memory leaks. + * Designed for multi-chain token balance data with configurable expiration. + * + * Cache Key Format: balance:{chainId}:{address}:{tokenAddresses} + * Default TTL: 3-5 minutes (configurable) + */ + +class BalanceCache { + constructor(defaultTTL = 180000) { + // 3 minutes default + this.cache = new Map(); + this.expirations = new Map(); + this.defaultTTL = defaultTTL; + this.cleanupInterval = null; + this.stats = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0, + }; + + // Start cleanup interval (runs every minute) + this.startCleanup(); + } + + /** + * Generate cache key from balance request parameters + * @param {string|number} chainId - Chain ID + * @param {string} address - Wallet address + * @param {string[]} [tokenAddresses] - Optional array of token addresses + * @returns {string} - Cache key + */ + static generateKey(chainId, address, tokenAddresses = null) { + const normalizedChain = String(chainId).toLowerCase(); + const normalizedAddress = address.toLowerCase(); + + if (tokenAddresses && tokenAddresses.length > 0) { + // Sort token addresses for consistent keys + const sortedTokens = [...tokenAddresses] + .map(t => t.toLowerCase()) + .sort() + .join(','); + return `balance:${normalizedChain}:${normalizedAddress}:${sortedTokens}`; + } + + return `balance:${normalizedChain}:${normalizedAddress}:all`; + } + + /** + * Get cached balance data + * @param {string} key - Cache key + * @returns {Object|null} - Cached balance data or null if expired/missing + */ + get(key) { + const expiresAt = this.expirations.get(key); + + // Check if expired + if (!expiresAt || Date.now() >= expiresAt) { + if (this.cache.has(key)) { + this.delete(key); + this.stats.evictions++; + } + this.stats.misses++; + return null; + } + + const data = this.cache.get(key); + if (data) { + this.stats.hits++; + return data; + } + + this.stats.misses++; + return null; + } + + /** + * Set balance data in cache with TTL + * @param {string} key - Cache key + * @param {Object} data - Balance data to cache + * @param {number} [ttl] - Time to live in milliseconds (optional) + */ + set(key, data, ttl = null) { + const expiresAt = Date.now() + (ttl || this.defaultTTL); + + this.cache.set(key, data); + this.expirations.set(key, expiresAt); + this.stats.sets++; + } + + /** + * Delete specific cache entry + * @param {string} key - Cache key + * @returns {boolean} - True if deleted, false if not found + */ + delete(key) { + const deleted = this.cache.delete(key); + this.expirations.delete(key); + return deleted; + } + + /** + * Clear all cache entries for a specific address + * @param {string} address - Wallet address + * @returns {number} - Number of entries cleared + */ + clearAddress(address) { + const normalizedAddress = address.toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.includes(`:${normalizedAddress}:`)) { + this.delete(key); + cleared++; + } + } + + return cleared; + } + + /** + * Clear all cache entries for a specific chain + * @param {string|number} chainId - Chain ID + * @returns {number} - Number of entries cleared + */ + clearChain(chainId) { + const normalizedChain = String(chainId).toLowerCase(); + let cleared = 0; + + for (const key of this.cache.keys()) { + if (key.startsWith(`balance:${normalizedChain}:`)) { + this.delete(key); + cleared++; + } + } + + return cleared; + } + + /** + * Clear all cache entries + */ + clear() { + this.cache.clear(); + this.expirations.clear(); + this.stats.evictions += this.cache.size; + } + + /** + * Clean up expired entries + * @returns {number} - Number of entries cleaned + */ + cleanup() { + const now = Date.now(); + let cleaned = 0; + + for (const [key, expiresAt] of this.expirations.entries()) { + if (now >= expiresAt) { + this.delete(key); + cleaned++; + this.stats.evictions++; + } + } + + return cleaned; + } + + /** + * Start automatic cleanup interval + * @param {number} [interval] - Cleanup interval in ms (default: 60000) + */ + startCleanup(interval = 60000) { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + this.cleanupInterval = setInterval(() => { + const cleaned = this.cleanup(); + if (cleaned > 0) { + console.log(`[BalanceCache] Cleaned up ${cleaned} expired entries`); + } + }, interval); + + // Prevent cleanup interval from keeping process alive + if (this.cleanupInterval.unref) { + this.cleanupInterval.unref(); + } + } + + /** + * Stop automatic cleanup interval + */ + stopCleanup() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Get cache statistics + * @returns {Object} - Cache stats + */ + getStats() { + const hitRate = + this.stats.hits + this.stats.misses > 0 + ? ( + (this.stats.hits / (this.stats.hits + this.stats.misses)) * + 100 + ).toFixed(2) + : 0; + + return { + ...this.stats, + size: this.cache.size, + hitRate: `${hitRate}%`, + memoryUsage: this.estimateMemoryUsage(), + }; + } + + /** + * Estimate memory usage (rough approximation) + * @returns {string} - Memory usage estimate + */ + estimateMemoryUsage() { + const sizeKB = Math.ceil(this.cache.size * 0.5); // Rough estimate: ~0.5KB per entry + if (sizeKB < 1024) { + return `${sizeKB} KB`; + } + return `${(sizeKB / 1024).toFixed(2)} MB`; + } + + /** + * Reset cache statistics + */ + resetStats() { + this.stats = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0, + }; + } +} + +module.exports = BalanceCache; diff --git a/src/validators/__tests__/balanceValidator.test.js b/src/validators/__tests__/balanceValidator.test.js new file mode 100644 index 0000000..5a06162 --- /dev/null +++ b/src/validators/__tests__/balanceValidator.test.js @@ -0,0 +1,268 @@ +/** + * Tests for Balance Validator Middleware + */ + +const { + balanceValidationRules, + SUPPORTED_CHAIN_IDS, + SUPPORTED_CHAINS, + isValidAddress, + areValidAddresses, +} = require('../balanceValidator'); + +describe('Validation Helper Functions', () => { + describe('isValidAddress', () => { + it('should validate correct Ethereum addresses', () => { + expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb')).toBe( + true + ); + expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( + true + ); + }); + + it('should reject invalid addresses', () => { + expect(isValidAddress('not-an-address')).toBe(false); + expect(isValidAddress('0x123')).toBe(false); + expect(isValidAddress('')).toBe(false); + expect(isValidAddress(null)).toBe(false); + expect(isValidAddress(undefined)).toBe(false); + }); + + it('should handle checksummed addresses', () => { + expect(isValidAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7')).toBe( + true + ); + }); + }); + + describe('areValidAddresses', () => { + it('should validate comma-separated addresses', () => { + const valid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + expect(areValidAddresses(valid)).toBe(true); + }); + + it('should allow empty/undefined (optional field)', () => { + expect(areValidAddresses('')).toBe(true); + expect(areValidAddresses(null)).toBe(true); + expect(areValidAddresses(undefined)).toBe(true); + }); + + it('should reject lists with invalid addresses', () => { + const invalid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,invalid-address'; + expect(areValidAddresses(invalid)).toBe(false); + }); + + it('should reject lists with too many addresses (>50)', () => { + const tooMany = Array.from( + { length: 51 }, + (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` + ).join(','); + + expect(areValidAddresses(tooMany)).toBe(false); + }); + + it('should handle whitespace in addresses', () => { + const withSpaces = + ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; + expect(areValidAddresses(withSpaces)).toBe(true); + }); + }); +}); + +describe('Supported Chains', () => { + it('should include major chains', () => { + expect(SUPPORTED_CHAIN_IDS).toContain(1); // Ethereum + expect(SUPPORTED_CHAIN_IDS).toContain(10); // Optimism + expect(SUPPORTED_CHAIN_IDS).toContain(137); // Polygon + expect(SUPPORTED_CHAIN_IDS).toContain(8453); // Base + expect(SUPPORTED_CHAIN_IDS).toContain(42161); // Arbitrum + }); + + it('should have descriptive names', () => { + expect(SUPPORTED_CHAINS[1]).toBe('Ethereum Mainnet'); + expect(SUPPORTED_CHAINS[10]).toBe('Optimism'); + expect(SUPPORTED_CHAINS[137]).toBe('Polygon'); + expect(SUPPORTED_CHAINS[8453]).toBe('Base'); + expect(SUPPORTED_CHAINS[42161]).toBe('Arbitrum One'); + }); +}); + +describe('Balance Validator Integration', () => { + // Mock express-validator for integration tests + const mockValidate = (rules, req) => { + // Simplified mock - in real tests, use express-validator's test utilities + const errors = []; + + // Manually check chainId + if (!req.query.chainId) { + errors.push({ path: 'chainId', msg: 'chainId is required' }); + } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { + errors.push({ + path: 'chainId', + msg: 'chainId must be one of supported chains', + }); + } + + // Manually check wallet + if (!req.query.wallet) { + errors.push({ path: 'wallet', msg: 'wallet address is required' }); + } else if (!isValidAddress(req.query.wallet)) { + errors.push({ + path: 'wallet', + msg: 'wallet must be a valid Ethereum address', + }); + } + + // Manually check tokens (optional) + if (req.query.tokens && !areValidAddresses(req.query.tokens)) { + errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); + } + + return errors; + }; + + it('should validate correct request', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + }); + + it('should require chainId', async () => { + const req = { + query: { + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'chainId')).toBe(true); + }); + + it('should require wallet', async () => { + const req = { + query: { + chainId: '1', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'wallet')).toBe(true); + }); + + it('should reject unsupported chainId', async () => { + const req = { + query: { + chainId: '999', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'chainId')).toBe(true); + }); + + it('should reject invalid wallet address', async () => { + const req = { + query: { + chainId: '1', + wallet: 'not-a-valid-address', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'wallet')).toBe(true); + }); + + it('should validate optional tokens parameter', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + }); + + it('should reject invalid token addresses', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'tokens')).toBe(true); + }); + + it('should validate all supported chains', async () => { + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + for (const chainId of SUPPORTED_CHAIN_IDS) { + const req = { query: { chainId: chainId.toString(), wallet } }; + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + } + }); +}); + +describe('Error Message Quality', () => { + it('should provide helpful error messages', async () => { + const req = { + query: { + chainId: '999', + wallet: 'invalid', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + + // Errors should be descriptive + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.msg.includes('chainId'))).toBe(true); + expect(errors.some(e => e.msg.includes('wallet'))).toBe(true); + }); +}); + +// Helper function for mock validation +function mockValidate(rules, req) { + const errors = []; + + if (!req.query.chainId) { + errors.push({ path: 'chainId', msg: 'chainId is required' }); + } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { + errors.push({ + path: 'chainId', + msg: 'chainId must be one of supported chains', + }); + } + + if (!req.query.wallet) { + errors.push({ path: 'wallet', msg: 'wallet address is required' }); + } else if (!isValidAddress(req.query.wallet)) { + errors.push({ + path: 'wallet', + msg: 'wallet must be a valid Ethereum address', + }); + } + + if (req.query.tokens && !areValidAddresses(req.query.tokens)) { + errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); + } + + return errors; +} diff --git a/src/validators/balanceValidator.js b/src/validators/balanceValidator.js new file mode 100644 index 0000000..8349255 --- /dev/null +++ b/src/validators/balanceValidator.js @@ -0,0 +1,169 @@ +/** + * Balance Endpoint Validation Middleware + * + * Validates: + * - chainId: Must be one of the supported chains (1, 137, 42161, 8453, 10) + * - wallet: Must be a valid Ethereum address + * - tokens: Optional array of valid token addresses + * + * Uses express-validator for validation rules. + */ + +const { query, validationResult } = require('express-validator'); +const { isAddress } = require('ethers'); + +/** + * Supported blockchain networks + */ +const SUPPORTED_CHAINS = { + 1: 'Ethereum Mainnet', + 10: 'Optimism', + 137: 'Polygon', + 8453: 'Base', + 42161: 'Arbitrum One', +}; + +const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS).map(Number); + +/** + * Custom validator: Check if value is a valid Ethereum address + */ +const isValidAddress = value => { + if (!value) { + return false; + } + try { + return isAddress(value); + } catch { + return false; + } +}; + +/** + * Custom validator: Check if all items in comma-separated list are valid addresses + */ +const areValidAddresses = value => { + if (!value) { + return true; // Optional field + } + + const addresses = value + .split(',') + .map(addr => addr.trim()) + .filter(Boolean); + + if (addresses.length === 0) { + return true; + } + if (addresses.length > 50) { + return false; // Prevent abuse with too many tokens + } + + return addresses.every(isValidAddress); +}; + +/** + * Validation rules for balance endpoint + */ +const balanceValidationRules = [ + query('chainId') + .exists() + .withMessage('chainId is required') + .isInt() + .withMessage('chainId must be an integer') + .toInt() + .custom(value => SUPPORTED_CHAIN_IDS.includes(value)) + .withMessage( + `chainId must be one of: ${SUPPORTED_CHAIN_IDS.join(', ')} (${Object.entries( + SUPPORTED_CHAINS + ) + .map(([id, name]) => `${id}=${name}`) + .join(', ')})` + ), + + query('wallet') + .exists() + .withMessage('wallet address is required') + .trim() + .notEmpty() + .withMessage('wallet address cannot be empty') + .custom(isValidAddress) + .withMessage('wallet must be a valid Ethereum address (0x... format)') + .customSanitizer(value => value.toLowerCase()), + + query('tokens') + .optional() + .trim() + .custom(areValidAddresses) + .withMessage( + 'tokens must be a comma-separated list of valid Ethereum addresses (max 50 tokens)' + ) + .customSanitizer(value => { + if (!value) { + return undefined; + } + // Normalize to lowercase array + return value + .split(',') + .map(addr => addr.trim().toLowerCase()) + .filter(Boolean); + }), +]; + +/** + * Middleware to handle validation errors + */ +const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req); + + if (!errors.isEmpty()) { + const formattedErrors = errors.array().map(err => ({ + field: err.path || err.param, + message: err.msg, + value: err.value, + })); + + return res.status(400).json({ + error: 'Validation Error', + message: 'Invalid request parameters', + errors: formattedErrors, + details: { + supportedChains: SUPPORTED_CHAINS, + example: { + chainId: 1, + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }, + }); + } + + next(); +}; + +/** + * Combined validation middleware for balance endpoint + * + * Usage: + * router.get('/balance', balanceRateLimit, balanceValidator, getBalance); + * + * Query Parameters: + * - chainId: number (required) - One of 1, 10, 137, 8453, 42161 + * - wallet: string (required) - Valid Ethereum address + * - tokens: string (optional) - Comma-separated list of token addresses + * + * Example: + * GET /balance?chainId=1&wallet=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb&tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + */ +const balanceValidator = [...balanceValidationRules, handleValidationErrors]; + +module.exports = balanceValidator; + +// Export for testing and reuse +module.exports.balanceValidationRules = balanceValidationRules; +module.exports.handleValidationErrors = handleValidationErrors; +module.exports.SUPPORTED_CHAINS = SUPPORTED_CHAINS; +module.exports.SUPPORTED_CHAIN_IDS = SUPPORTED_CHAIN_IDS; +module.exports.isValidAddress = isValidAddress; +module.exports.areValidAddresses = areValidAddresses; diff --git a/test/app.test.js b/test/app.test.js index b98c6df..269e059 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,4 +1,9 @@ const request = require('supertest'); + +if (!process.env.MORALIS_API_KEY) { + process.env.MORALIS_API_KEY = 'test-api-key'; +} + const app = require('../src/app'); // Mock the modules diff --git a/test/balance.integration.test.js b/test/balance.integration.test.js new file mode 100644 index 0000000..db7f27c --- /dev/null +++ b/test/balance.integration.test.js @@ -0,0 +1,755 @@ +/** + * Balance API Integration Tests + * + * Tests the complete balance API flow including: + * - HTTP endpoint functionality with supertest + * - Real Moralis API integration (mocked for deterministic tests) + * - Rate limiting (per-wallet and global) + * - Caching behavior (cache hit/miss) + * - Error scenarios (invalid inputs, unsupported chains, API failures) + * - Response format validation + */ + +const request = require('supertest'); +const app = require('../src/app'); +const BalanceController = require('../src/controllers/balanceController'); +const axios = require('axios'); + +// Mock axios for controlled Moralis API responses +jest.mock('axios'); + +const { TEST_ADDRESSES } = require('./utils/testHelpers'); + +function readParam(params, key) { + if (!params) { + return undefined; + } + + if (params instanceof URLSearchParams) { + return params.get(key); + } + + return params[key]; +} + +function readParamValues(params, key) { + if (!params) { + return []; + } + + if (params instanceof URLSearchParams) { + return params.getAll(key); + } + + const value = params[key]; + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + return [value]; +} + +describe('Balance API Integration Tests', () => { + let originalApiKey; + + beforeAll(() => { + // Store original API key + originalApiKey = process.env.MORALIS_API_KEY; + // Set test API key + process.env.MORALIS_API_KEY = 'test-api-key'; + BalanceController.balanceService.setApiKey(process.env.MORALIS_API_KEY); + }); + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Access the singleton service instance to clear cache + if (BalanceController.balanceService) { + BalanceController.balanceService.cache.clear(); + BalanceController.balanceService.cache.resetStats(); + BalanceController.balanceService.setApiKey(process.env.MORALIS_API_KEY); + } + }); + + afterAll(() => { + // Restore original API key + if (originalApiKey) { + process.env.MORALIS_API_KEY = originalApiKey; + BalanceController.balanceService.setApiKey(originalApiKey); + } + jest.clearAllTimers(); + }); + + describe('GET /api/v1/balances/:chainId/:address', () => { + const validAddress = TEST_ADDRESSES.VALID_USER; + const validChainId = '1'; // Ethereum + + describe('Success Cases', () => { + test('should return token balances for valid address and chain', async () => { + // Mock Moralis API response + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: '18', + balance: '500000000000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + // Validate response structure + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('cached'); + + const { data } = response.body; + expect(data).toHaveProperty('address'); + expect(data.address.toLowerCase()).toBe(validAddress.toLowerCase()); + expect(data).toHaveProperty('chainId', validChainId); + expect(data).toHaveProperty('balances'); + expect(data).toHaveProperty('totalTokens', 2); + expect(data).toHaveProperty('timestamp'); + + // Validate balance structure + const balance = data.balances[0]; + expect(balance).toMatchObject({ + tokenAddress: expect.any(String), + name: expect.any(String), + symbol: expect.any(String), + decimals: expect.any(Number), + balance: expect.any(String), + balanceFormatted: expect.any(String), + }); + + // Verify first request is not cached + expect(response.body.cached).toBe(false); + + // Verify Moralis API was called with correct parameters + const [moralisUrl, moralisConfig] = axios.get.mock.calls[0]; + expect(moralisUrl.toLowerCase()).toContain(validAddress.toLowerCase()); + expect(moralisConfig.headers).toEqual( + expect.objectContaining({ + 'X-API-Key': 'test-api-key', + }) + ); + expect(readParam(moralisConfig.params, 'chain')).toBe('0x1'); + }); + + test('should return cached response on subsequent requests', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + // Mock will be called only once + axios.get.mockResolvedValue({ data: mockBalances }); + + // First request - should hit API + await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + const initialCallCount = axios.get.mock.calls.length; + + // Second request - should hit cache (use different address to avoid rate limiting) + const response2 = await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + expect(response2.body.cached).toBe(true); + // Moralis API should not be called again + expect(axios.get.mock.calls.length).toBe(initialCallCount); + }); + + test('should support skipCache query parameter', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValue({ data: mockBalances }); + + // First request + await request(app) + .get(`/api/v1/balances/${validChainId}/${validAddress}`) + .expect(200); + + const initialCallCount = axios.get.mock.calls.length; + + // Second request with skipCache=true should hit API again + const response = await request(app) + .get( + `/api/v1/balances/${validChainId}/${validAddress}?skipCache=true` + ) + .expect(200); + + expect(response.body.cached).toBe(false); + expect(axios.get.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + + test('should filter balances by specific token addresses', async () => { + const token1 = '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037'; + const token2 = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + const mockBalances = [ + { + token_address: token1, + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1000000000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get( + `/api/v1/balances/${validChainId}/${validAddress}?tokens=${token1},${token2}` + ) + .expect(200); + + expect(response.body.success).toBe(true); + + // Verify Moralis was called with token_addresses parameter + const lastCall = axios.get.mock.calls[axios.get.mock.calls.length - 1]; + const tokenParams = readParamValues( + lastCall[1].params, + 'token_addresses' + ); + expect(tokenParams.length).toBeGreaterThan(0); + const normalizedTokens = tokenParams.map(addr => addr.toLowerCase()); + expect(normalizedTokens).toContain(token1.toLowerCase()); + expect(normalizedTokens).toContain(token2.toLowerCase()); + }); + + test('should support multiple chain IDs', async () => { + const chains = [ + { id: '1', hex: '0x1', name: 'Ethereum' }, + { id: '137', hex: '0x89', name: 'Polygon' }, + { id: '42161', hex: '0xa4b1', name: 'Arbitrum' }, + { id: '8453', hex: '0x2105', name: 'Base' }, + ]; + + axios.get.mockResolvedValue({ data: [] }); + + for (const chain of chains) { + // Clear mocks between chain tests + jest.clearAllMocks(); + + const response = await request(app) + .get(`/api/v1/balances/${chain.id}/${validAddress}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.chainId).toBe(chain.id); + + // Verify correct hex chain ID was used in the most recent call + if (axios.get.mock.calls.length > 0) { + const lastCall = + axios.get.mock.calls[axios.get.mock.calls.length - 1]; + expect(readParam(lastCall[1].params, 'chain')).toBe(chain.hex); + } + } + }); + }); + + describe('Rate Limiting', () => { + beforeEach(() => { + // Note: Rate limiter is a singleton, so we can't fully reset between tests + // Tests should use unique addresses to avoid conflicts + axios.get.mockResolvedValue({ data: [] }); + }); + + test('should include rate limit headers in successful responses', async () => { + const uniqueAddress = '0x1234567890123456789012345678901234567890'; + + const response = await request(app) + .get(`/api/v1/balances/1/${uniqueAddress}`) + .expect(200); + + const headers = response.headers; + const walletLimit = + headers['x-ratelimit-limit-wallet'] || headers['x-ratelimit-limit']; + const walletRemaining = + headers['x-ratelimit-remaining-wallet'] || + headers['x-ratelimit-remaining']; + const walletReset = + headers['x-ratelimit-reset-wallet'] || headers['x-ratelimit-reset']; + + expect(walletLimit).toBeDefined(); + expect(walletRemaining).toBeDefined(); + expect(walletReset).toBeDefined(); + }); + }); + + describe('Error Scenarios', () => { + test('should return 400 for invalid wallet address', async () => { + const response = await request(app) + .get('/api/v1/balances/1/0xinvalid') + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid address'), + }, + }); + }); + + test('should return 400 for invalid chainId', async () => { + const response = await request(app) + .get(`/api/v1/balances/abc/${TEST_ADDRESSES.VALID_USER}`) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid chainId'), + }, + }); + }); + + test('should return 400 for unsupported chain', async () => { + // Mock the service to throw unsupported chain error + axios.get.mockRejectedValue( + new Error( + 'Unsupported chain ID: 999. Supported: 1, 137, 56, 42161, 10, 8453, 43114' + ) + ); + + const response = await request(app) + .get(`/api/v1/balances/999/${TEST_ADDRESSES.VALID_USER}`) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.stringMatching( + /UNSUPPORTED_CHAIN|INTERNAL_SERVER_ERROR/ + ), + message: expect.stringContaining('Unsupported chain'), + }, + }); + }); + + test('should return 400 for invalid token address in query', async () => { + const response = await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=0xinvalid` + ) + .expect(400); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INVALID_INPUT', + message: expect.stringContaining('Invalid token address'), + }, + }); + }); + + test('should handle Moralis API timeout errors', async () => { + // Mock timeout error + const timeoutError = new Error('timeout of 10000ms exceeded'); + timeoutError.code = 'ECONNABORTED'; + axios.get.mockRejectedValue(timeoutError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + test('should handle Moralis API 429 rate limit', async () => { + const rateLimitError = new Error( + 'Moralis API error: 429 - Rate limit exceeded' + ); + rateLimitError.response = { + status: 429, + data: { message: 'Rate limit exceeded' }, + }; + rateLimitError.status = 429; + + axios.get.mockRejectedValue(rateLimitError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(429); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: expect.stringContaining('429'), + }, + }); + }); + + test('should handle Moralis API 503 service unavailable', async () => { + const serviceError = new Error( + 'Moralis API error: 503 - Service Unavailable' + ); + serviceError.response = { + status: 503, + data: { message: 'Service Unavailable' }, + }; + serviceError.status = 503; + + axios.get.mockRejectedValue(serviceError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(503); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'RPC_ERROR', + message: expect.any(String), + }, + }); + }); + + test('should handle network errors', async () => { + const networkError = new Error('Network Error'); + networkError.code = 'ENOTFOUND'; + axios.get.mockRejectedValue(networkError); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + test('should handle invalid Moralis response format', async () => { + // Mock invalid response (not an array) + axios.get.mockResolvedValueOnce({ data: { invalid: 'format' } }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: expect.stringContaining('Invalid Moralis response'), + }, + }); + }); + }); + + describe('Response Format Validation', () => { + test('should return correctly formatted balance data', async () => { + const mockBalances = [ + { + token_address: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: '6', + balance: '1234567890', + logo: 'https://example.com/usdc.png', + thumbnail: 'https://example.com/usdc-thumb.png', + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balance = response.body.data.balances[0]; + + // Verify all fields are present and correct types + expect(balance).toMatchObject({ + tokenAddress: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '1234567890', + balanceFormatted: '1234.56789', + logo: 'https://example.com/usdc.png', + thumbnail: 'https://example.com/usdc-thumb.png', + possibleSpam: false, + verifiedContract: true, + }); + }); + + test('should format balances with correct decimal places', async () => { + const mockBalances = [ + { + token_address: '0xToken1', + name: 'Token 18 Decimals', + symbol: 'T18', + decimals: '18', + balance: '1000000000000000000', // 1.0 + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xToken2', + name: 'Token 6 Decimals', + symbol: 'T6', + decimals: '6', + balance: '1000000', // 1.0 + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + { + token_address: '0xToken3', + name: 'Zero Balance', + symbol: 'T0', + decimals: '18', + balance: '0', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: true, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balances = response.body.data.balances; + + expect(balances[0].balanceFormatted).toBe('1'); + expect(balances[1].balanceFormatted).toBe('1'); + expect(balances[2].balanceFormatted).toBe('0'); + }); + + test('should handle tokens with no logo/thumbnail', async () => { + const mockBalances = [ + { + token_address: '0xToken', + name: 'No Logo Token', + symbol: 'NLT', + decimals: '18', + balance: '1000', + logo: null, + thumbnail: null, + possible_spam: false, + verified_contract: false, + }, + ]; + + axios.get.mockResolvedValueOnce({ data: mockBalances }); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const balance = response.body.data.balances[0]; + expect(balance.logo).toBeNull(); + expect(balance.thumbnail).toBeNull(); + }); + + test('should include timestamp in response', async () => { + axios.get.mockResolvedValueOnce({ data: [] }); + + const beforeRequest = Date.now(); + + const response = await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const afterRequest = Date.now(); + + expect(response.body.data.timestamp).toBeDefined(); + expect(response.body.data.timestamp).toBeGreaterThanOrEqual( + beforeRequest + ); + expect(response.body.data.timestamp).toBeLessThanOrEqual(afterRequest); + }); + }); + + describe('Cache Behavior', () => { + test('should cache responses with different token filters separately', async () => { + const token1 = '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037'; + const token2 = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + axios.get.mockResolvedValue({ data: [] }); + + // Request with token1 + await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=${token1}` + ) + .expect(200); + + // Request with token2 - should hit API again (different cache key) + await request(app) + .get( + `/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}?tokens=${token2}` + ) + .expect(200); + + // Verify API was called twice + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('should cache responses per chain ID', async () => { + axios.get.mockResolvedValue({ data: [] }); + + // Same address, different chains + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + await request(app) + .get(`/api/v1/balances/137/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + // Verify API was called twice (different chains) + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('should normalize address case for cache keys', async () => { + const lowerAddress = TEST_ADDRESSES.VALID_USER.toLowerCase(); + const upperAddress = TEST_ADDRESSES.VALID_USER.toUpperCase(); + const mixedAddress = TEST_ADDRESSES.VALID_USER; + + axios.get.mockResolvedValue({ data: [] }); + + // Request with lowercase address + await request(app) + .get(`/api/v1/balances/1/${lowerAddress}`) + .expect(200); + + // Request with uppercase - should hit cache + const response2 = await request(app) + .get(`/api/v1/balances/1/${upperAddress}`) + .expect(200); + + // Request with mixed case - should hit cache + const response3 = await request(app) + .get(`/api/v1/balances/1/${mixedAddress}`) + .expect(200); + + // API should only be called once + expect(axios.get).toHaveBeenCalledTimes(1); + expect(response2.body.cached).toBe(true); + expect(response3.body.cached).toBe(true); + }); + }); + + describe('Performance', () => { + test('should respond within reasonable time for cached requests', async () => { + axios.get.mockResolvedValue({ data: [] }); + + // First request to populate cache + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + // Measure cached request time + const startTime = Date.now(); + + await request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200); + + const responseTime = Date.now() - startTime; + + // Cached response should be very fast (< 100ms) + expect(responseTime).toBeLessThan(100); + }); + + test('should handle concurrent requests efficiently', async () => { + axios.get.mockResolvedValue({ data: [] }); + + const requests = Array(5) + .fill() + .map(() => + request(app) + .get(`/api/v1/balances/1/${TEST_ADDRESSES.VALID_USER}`) + .expect(200) + ); + + const responses = await Promise.all(requests); + + // All responses should succeed + responses.forEach(response => { + expect(response.body.success).toBe(true); + }); + + // API should only be called once (subsequent requests hit cache) + expect(axios.get).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/test/balanceController.test.js b/test/balanceController.test.js new file mode 100644 index 0000000..6a3d29d --- /dev/null +++ b/test/balanceController.test.js @@ -0,0 +1,800 @@ +/** + * Balance Controller Unit Tests + * + * Tests the controller layer in isolation with mocked BalanceService. + * Focuses on: + * - Request parameter parsing + * - Service method invocation with correct parameters + * - Response formatting and status codes + * - Error handling and error code mapping + * - Edge cases and input validation + */ + +// Mock BalanceService before requiring the controller +jest.mock('../src/services/balanceService'); + +const BalanceController = require('../src/controllers/balanceController'); + +describe('BalanceController Unit Tests', () => { + let mockRequest; + let mockResponse; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Create mock request object + mockRequest = { + params: {}, + query: {}, + body: {}, + }; + + // Create mock response object + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + }; + + // Get the mock service from the controller + const mockService = BalanceController.balanceService; + + // Reset mock method implementations + mockService.getBalances.mockReset(); + mockService.getNativeBalance.mockReset(); + mockService.getCacheStats.mockReset(); + mockService.clearAddressCache.mockReset(); + mockService.clearChainCache.mockReset(); + mockService.getSupportedChains.mockReset(); + }); + + describe('getBalances', () => { + beforeEach(() => { + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + }); + + test('should return balances for valid request', async () => { + const mockBalanceData = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + chainId: '1', + balances: [ + { + tokenAddress: '0xA0b86a33E6441c8d59fb4b4df95c4FfAfFd46037', + symbol: 'USDC', + decimals: 6, + balance: '1000000', + balanceFormatted: '1', + }, + ], + totalTokens: 1, + timestamp: Date.now(), + cacheHit: false, + }; + + const mockService = BalanceController.balanceService; + mockService.getBalances.mockResolvedValue(mockBalanceData); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify service was called with correct parameters + expect(mockService.getBalances).toHaveBeenCalledWith( + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + { + chainId: '1', + tokenAddresses: null, + includeNative: false, + skipCache: false, + } + ); + + // Verify response format + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: false, + }); + }); + + test('should parse tokens query parameter correctly', async () => { + mockRequest.query.tokens = '0xToken1,0xToken2,0xToken3'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify tokens were parsed and passed to service + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + includeNative: false, + }) + ); + }); + + test('should detect native flag in tokens query parameter', async () => { + mockRequest.query.tokens = 'native,0xToken1,0xToken2'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2'], + includeNative: true, + }) + ); + }); + + test('should handle skipCache query parameter', async () => { + mockRequest.query.skipCache = 'true'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + includeNative: false, + skipCache: true, + }) + ); + }); + + test('should filter out empty token addresses', async () => { + mockRequest.query.tokens = '0xToken1,,0xToken2, ,0xToken3'; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Verify empty strings were filtered out + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + includeNative: false, + }) + ); + }); + + test('should set cached flag from cacheHit in response', async () => { + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: true, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: true, + }); + }); + + test('should default cached to false if cacheHit is undefined', async () => { + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + // cacheHit not provided + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockBalanceData, + cached: false, + }); + }); + + describe('Error Handling', () => { + test('should return 400 for invalid address error', async () => { + const error = new Error('Invalid wallet address format'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Invalid wallet address format', + details: undefined, + }, + }); + }); + + test('should return 400 for unsupported chain error', async () => { + const error = new Error('Chain ID 999 is not supported'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'UNSUPPORTED_CHAIN', + message: expect.stringContaining('not supported'), + details: undefined, + }, + }); + }); + + test('should return 429 for rate limit error', async () => { + const error = new Error('Moralis API rate limit exceeded'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(429); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Moralis API rate limit exceeded', + details: undefined, + }, + }); + }); + + test('should return 503 for RPC/provider errors', async () => { + const error = new Error('RPC provider unavailable'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(503); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'RPC_ERROR', + message: 'RPC provider unavailable', + details: undefined, + }, + }); + }); + + test('should return 500 for unknown errors', async () => { + const error = new Error('Unknown error occurred'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Unknown error occurred', + details: undefined, + }, + }); + }); + + test('should use error code if already present', async () => { + const error = new Error('Custom error message'); + error.code = 'CUSTOM_ERROR_CODE'; + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'CUSTOM_ERROR_CODE', + message: 'Custom error message', + details: undefined, + }, + }); + }); + + test('should include error details if provided', async () => { + const error = new Error('Invalid address provided'); + error.details = { field: 'address', reason: 'Invalid format' }; + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Invalid address provided', + details: { field: 'address', reason: 'Invalid format' }, + }, + }); + }); + }); + }); + + describe('getNativeBalance', () => { + beforeEach(() => { + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + }); + + test('should return native balance successfully', async () => { + const mockNativeBalance = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + chainId: '1', + balance: '1000000000000000000', + balanceFormatted: '1', + timestamp: Date.now(), + }; + + BalanceController.balanceService.getNativeBalance.mockResolvedValue( + mockNativeBalance + ); + + await BalanceController.getNativeBalance(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.getNativeBalance + ).toHaveBeenCalledWith('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', '1'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockNativeBalance, + }); + }); + + test('should handle errors for native balance', async () => { + const error = new Error('Failed to fetch native balance'); + BalanceController.balanceService.getNativeBalance.mockRejectedValue( + error + ); + + await BalanceController.getNativeBalance(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch native balance', + details: undefined, + }, + }); + }); + }); + + describe('getCacheStats', () => { + test('should return cache statistics', async () => { + const mockStats = { + hits: 100, + misses: 20, + sets: 20, + evictions: 5, + size: 15, + hitRate: '83.33%', + memoryUsage: '7.5 KB', + }; + + BalanceController.balanceService.getCacheStats.mockReturnValue(mockStats); + + await BalanceController.getCacheStats(mockRequest, mockResponse); + + expect(BalanceController.balanceService.getCacheStats).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: mockStats, + }); + }); + + test('should handle errors when getting cache stats', async () => { + const error = new Error('Failed to get cache stats'); + BalanceController.balanceService.getCacheStats.mockImplementation(() => { + throw error; + }); + + await BalanceController.getCacheStats(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get cache stats', + }, + }); + }); + }); + + describe('clearCache', () => { + test('should clear cache by address', async () => { + mockRequest.query.address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + BalanceController.balanceService.clearAddressCache.mockReturnValue(5); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.clearAddressCache + ).toHaveBeenCalledWith('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + cleared: 5, + message: 'Cleared 5 cache entries', + }, + }); + }); + + test('should clear cache by chainId', async () => { + mockRequest.query.chainId = '1'; + BalanceController.balanceService.clearChainCache.mockReturnValue(10); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.clearChainCache + ).toHaveBeenCalledWith('1'); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + cleared: 10, + message: 'Cleared 10 cache entries', + }, + }); + }); + + test('should return 400 if neither address nor chainId provided', async () => { + await BalanceController.clearCache(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INVALID_INPUT', + message: 'Either address or chainId parameter is required', + }, + }); + + // Verify cache methods were not called + expect( + BalanceController.balanceService.clearAddressCache + ).not.toHaveBeenCalled(); + expect( + BalanceController.balanceService.clearChainCache + ).not.toHaveBeenCalled(); + }); + + test('should handle errors when clearing cache', async () => { + mockRequest.query.address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const error = new Error('Failed to clear cache'); + BalanceController.balanceService.clearAddressCache.mockImplementation( + () => { + throw error; + } + ); + + await BalanceController.clearCache(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clear cache', + }, + }); + }); + }); + + describe('getSupportedChains', () => { + test('should return list of supported chains', async () => { + const mockChains = ['1', '137', '56', '42161', '10', '8453', '43114']; + BalanceController.balanceService.getSupportedChains.mockReturnValue( + mockChains + ); + + await BalanceController.getSupportedChains(mockRequest, mockResponse); + + expect( + BalanceController.balanceService.getSupportedChains + ).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: { + chains: mockChains, + count: 7, + }, + }); + }); + + test('should handle errors when getting supported chains', async () => { + const error = new Error('Failed to get chains'); + BalanceController.balanceService.getSupportedChains.mockImplementation( + () => { + throw error; + } + ); + + await BalanceController.getSupportedChains(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get chains', + }, + }); + }); + }); + + describe('Error Code Mapping', () => { + test('should map "Invalid" messages to INVALID_INPUT', async () => { + const error = new Error('Invalid input detected'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'INVALID_INPUT', + }), + }) + ); + }); + + test('should map "not supported" messages to UNSUPPORTED_CHAIN', async () => { + const error = new Error('Chain not supported'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'UNSUPPORTED_CHAIN', + }), + }) + ); + }); + + test('should map RPC/provider messages to RPC_ERROR', async () => { + const error = new Error('RPC call failed'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'RPC_ERROR', + }), + }) + ); + }); + + test('should map rate limit messages to RATE_LIMIT_EXCEEDED', async () => { + const error = new Error('rate limit hit'); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + code: 'RATE_LIMIT_EXCEEDED', + }), + }) + ); + }); + }); + + describe('Status Code Mapping', () => { + test('should return 400 for validation errors', async () => { + const errors = [ + 'Invalid address', + 'chainId is required', + 'address must be valid', + ]; + + for (const errorMsg of errors) { + jest.clearAllMocks(); + const error = new Error(errorMsg); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + } + }); + + test('should return appropriate status for network/provider errors', async () => { + const cases = [ + { message: 'RPC endpoint unavailable', expected: 503 }, + { message: 'provider connection failed', expected: 503 }, + { message: 'network timeout', expected: 500 }, + ]; + + for (const { message, expected } of cases) { + jest.clearAllMocks(); + const error = new Error(message); + BalanceController.balanceService.getBalances.mockRejectedValue(error); + + await BalanceController.getBalances(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(expected); + } + }); + }); + + describe('Edge Cases', () => { + test('should handle empty tokens query parameter', async () => { + mockRequest.query.tokens = ''; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Empty string should result in null tokenAddresses + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: null, + }) + ); + }); + + test('should handle skipCache with non-true values', async () => { + const testValues = ['false', 'no', '0', undefined, null]; + + for (const value of testValues) { + jest.clearAllMocks(); + mockRequest.query.skipCache = value; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Non-'true' values should result in skipCache: false + expect( + BalanceController.balanceService.getBalances + ).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + skipCache: false, + }) + ); + } + }); + + test('should handle whitespace in token addresses', async () => { + mockRequest.query.tokens = ' 0xToken1 , 0xToken2 , 0xToken3 '; + mockRequest.params = { + chainId: '1', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }; + + const mockBalanceData = { + address: mockRequest.params.address, + chainId: '1', + balances: [], + totalTokens: 0, + timestamp: Date.now(), + cacheHit: false, + }; + + BalanceController.balanceService.getBalances.mockResolvedValue( + mockBalanceData + ); + + await BalanceController.getBalances(mockRequest, mockResponse); + + // Whitespace should be trimmed + expect(BalanceController.balanceService.getBalances).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tokenAddresses: ['0xToken1', '0xToken2', '0xToken3'], + }) + ); + }); + }); +}); From ec38d86c2fde5e58ef0271a82df0bc2d86eb1148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 6 Oct 2025 16:37:12 +0900 Subject: [PATCH 04/29] fix: native address should be native --- src/services/balanceService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/balanceService.js b/src/services/balanceService.js index b96ba33..0f83814 100644 --- a/src/services/balanceService.js +++ b/src/services/balanceService.js @@ -374,7 +374,7 @@ class BalanceService { ); return { - address, + address: 'native', chainId: String(chainId), balance: data.balance, balanceFormatted: this._formatBalance(data.balance, 18), // Most native tokens use 18 decimals From 7bc868133a184ef9ce1165f3917822a267df2377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 6 Oct 2025 22:03:17 +0900 Subject: [PATCH 05/29] wip: stablecoin config works and we can supply to aave now --- src/config/tokenConfig.js | 31 +++++ src/config/unifiedZapConfig.js | 104 ++++++++-------- src/executors/UnifiedZapExecutor.js | 171 ++++++++++++++++++++++++-- src/validators/UnifiedZapValidator.js | 79 +++++++++--- 4 files changed, 311 insertions(+), 74 deletions(-) diff --git a/src/config/tokenConfig.js b/src/config/tokenConfig.js index 9270ad6..7ed1523 100644 --- a/src/config/tokenConfig.js +++ b/src/config/tokenConfig.js @@ -178,6 +178,37 @@ class TokenConfigService { return chain[symbol.toUpperCase()] || null; } + /** + * Get token metadata by address for a specific chain + * @param {number} chainId - Chain ID + * @param {string} address - Token contract address + * @returns {Object|null} - Token metadata or null if not found + */ + static getTokenByAddress(chainId, address) { + if (!address || typeof address !== 'string') { + return null; + } + + const chain = TOKEN_REGISTRY[chainId]; + if (!chain) { + return null; + } + + const normalizedAddress = address.toLowerCase(); + + for (const token of Object.values(chain)) { + if ( + token?.address && + typeof token.address === 'string' && + token.address.toLowerCase() === normalizedAddress + ) { + return token; + } + } + + return null; + } + /** * Get WETH address for a specific chain * @param {number} chainId - Chain ID diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index 9500a80..a731b38 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -49,58 +49,58 @@ const UNIFIED_ZAP_CONFIG = { assetDecimals: 6, }, }, - // // Pendle PT gUSDC on Arbitrum - // { - // id: 'pendle-pt-gusdc-arbitrum', - // name: 'Pendle PT gUSDC (Arbitrum)', - // implementation: 'PendlePTProtocol', - // chain: 'arbitrum', - // chainId: 42161, - // weight: 25, - // enabled: true, - // config: { - // mode: 'single', - // marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', - // assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', - // protocolAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', // Add this line - using marketAddress - // ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', - // assetDecimals: 6, - // symbolOfBestTokenToZapOut: 'usdc', - // bestTokenAddressToZapOut: - // '0xaf88d065e77c8cc2239327c5edb3a432268e5831', - // decimalOfBestTokenToZapOut: 6, - // }, - // }, - // // Velodrome BOLD/USDC LP on Base - // { - // id: 'velodrome-bold-usdc-base', - // name: 'Velodrome BOLD/USDC LP (Base)', - // implementation: 'VelodromeProtocol', - // chain: 'base', - // chainId: 8453, - // weight: 30, - // enabled: true, - // config: { - // mode: 'LP', - // protocolName: 'aerodrome', - // protocolVersion: '0', - // assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', - // assetDecimals: 18, - // routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', - // guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', - // lpTokens: [ - // ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], - // ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], - // ], - // rewards: [ - // { - // symbol: 'aero', - // address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', - // decimals: 18, - // }, - // ], - // }, - // }, + // Pendle PT gUSDC on Arbitrum + { + id: 'pendle-pt-gusdc-arbitrum', + name: 'Pendle PT gUSDC (Arbitrum)', + implementation: 'PendlePTProtocol', + chain: 'arbitrum', + chainId: 42161, + weight: 25, + enabled: true, + config: { + mode: 'single', + marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', + assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', + protocolAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', // Add this line - using marketAddress + ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', + assetDecimals: 6, + symbolOfBestTokenToZapOut: 'usdc', + bestTokenAddressToZapOut: + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimalOfBestTokenToZapOut: 6, + }, + }, + // Velodrome BOLD/USDC LP on Base + { + id: 'velodrome-bold-usdc-base', + name: 'Velodrome BOLD/USDC LP (Base)', + implementation: 'VelodromeProtocol', + chain: 'base', + chainId: 8453, + weight: 30, + enabled: true, + config: { + mode: 'LP', + protocolName: 'aerodrome', + protocolVersion: '0', + assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', + assetDecimals: 18, + routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', + guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', + lpTokens: [ + ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], + ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], + ], + rewards: [ + { + symbol: 'aero', + address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', + decimals: 18, + }, + ], + }, + }, // // Velodrome USDC/sUSD LP on Optimism // { // id: 'velodrome-usdc-susd-optimism', diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index aaaa236..df1763d 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -5,6 +5,7 @@ const { protocolFactory } = require('../protocols'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const { TokenConfigService, CHAIN_METADATA } = require('../config/tokenConfig'); const SSEEventFactory = require('../services/SSEEventFactory'); class UnifiedZapExecutor { @@ -29,8 +30,8 @@ class UnifiedZapExecutor { slippage = UNIFIED_ZAP_CONFIG.DEFAULT_SLIPPAGE, } = params; - // Convert input amount to BigInt - const totalAmount = BigInt(inputAmount); + const { amount: totalAmount, decimals: inputTokenDecimals } = + this._normalizeInputAmount(inputAmount, inputToken, chainId); // Phase 1: Parse strategies into protocol allocations const protocolAllocations = await this._parseStrategyAllocations( @@ -57,6 +58,8 @@ class UnifiedZapExecutor { chainId, inputToken, inputAmount: totalAmount, + inputAmountRaw: inputAmount, + inputTokenDecimals, slippage, strategyAllocations, protocolAllocations: protocolsWithRequirements, @@ -239,6 +242,146 @@ class UnifiedZapExecutor { })); } + /** + * Normalize input amount string to token units + * @param {string} amountStr - Human-readable amount + * @param {string} inputToken - Input token address or native sentinel + * @param {number} chainId - Chain ID + * @returns {{amount: bigint, decimals: number}} - Normalized amount and decimals used + * @private + */ + _normalizeInputAmount(amountStr, inputToken, chainId) { + if (typeof amountStr !== 'string') { + throw new Error('inputAmount must be provided as a string'); + } + + const decimals = this._resolveTokenDecimals(chainId, inputToken); + const amount = this._parseAmountToUnits(amountStr, decimals); + + return { amount, decimals }; + } + + /** + * Resolve token decimals from configuration + * @param {number} chainId - Chain ID + * @param {string} tokenAddress - Token address or native sentinel + * @returns {number} - Token decimals (defaults to 18) + * @private + */ + _resolveTokenDecimals(chainId, tokenAddress) { + if (!tokenAddress) { + return 18; + } + + const normalized = tokenAddress.toLowerCase(); + + if (this._isNativeTokenAddress(normalized)) { + const nativeSymbol = CHAIN_METADATA[chainId]?.nativeToken; + const nativeMeta = + nativeSymbol && TokenConfigService.getToken(chainId, nativeSymbol); + return nativeMeta?.decimals ?? 18; + } + + const tokenMeta = TokenConfigService.getTokenByAddress( + chainId, + tokenAddress + ); + + if (tokenMeta?.decimals !== undefined) { + return tokenMeta.decimals; + } + + return 18; + } + + /** + * Convert decimal string to token units BigInt + * @param {string} amountStr - Human-readable amount (e.g., "2.5") + * @param {number} decimals - Token decimals + * @returns {bigint} - Amount in smallest units + * @private + */ + _parseAmountToUnits(amountStr, decimals) { + const normalized = amountStr.trim(); + + if (!normalized) { + throw new Error('inputAmount must be a valid positive number string'); + } + + const parts = normalized.split('.'); + if (parts.length > 2) { + throw new Error('inputAmount must be a valid positive number string'); + } + + const [wholeRaw, fractionRaw = ''] = parts; + + if (!this._isDigitsOnly(wholeRaw)) { + throw new Error('inputAmount must contain only numeric characters'); + } + + if (fractionRaw && !this._isDigitsOnly(fractionRaw)) { + throw new Error('inputAmount must contain only numeric characters'); + } + + const wholePart = wholeRaw.replace(/^0+(?=\d)/, '') || '0'; + const fractionPart = fractionRaw.replace(/0+$/, ''); + + if (fractionPart.length > decimals) { + throw new Error( + `inputAmount has more than ${decimals} decimal places for the selected token` + ); + } + + const paddedFraction = + fractionPart + '0'.repeat(Math.max(decimals - fractionPart.length, 0)); + + const unitsString = `${wholePart}${paddedFraction.slice(0, decimals)}`; + const sanitized = unitsString.replace(/^0+(?=\d)/, '') || '0'; + + return BigInt(sanitized); + } + + /** + * Determine if address represents native token sentinel + * @param {string} address - Token address or sentinel + * @returns {boolean} - True if native token sentinel + * @private + */ + _isNativeTokenAddress(address) { + if (!address) { + return true; + } + + const normalized = address.toLowerCase(); + + return ( + normalized === 'native' || + normalized === '0x0000000000000000000000000000000000000000' || + normalized === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ); + } + + /** + * Check if string contains only digit characters + * @param {string} value - String to validate + * @returns {boolean} - True if all characters are digits + * @private + */ + _isDigitsOnly(value) { + if (value.length === 0) { + return false; + } + + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + + return true; + } + /** * Estimate processing duration based on protocol count and complexity * @param {number} protocolCount - Number of protocols to process @@ -426,6 +569,10 @@ class UnifiedZapExecutor { const { instance, amount, tokenRequirements } = protocolAllocation; const transactions = []; + if (amount === 0n) { + return transactions; + } + try { // For single token protocols if (tokenRequirements.mode === 'single') { @@ -433,13 +580,23 @@ class UnifiedZapExecutor { tokenRequirements.protocolSpecific?.underlyingToken || tokenRequirements.outputToken; - // Generate approval transaction - if (protocolToken && tokenRequirements.requiresSwap) { + const approvalSpender = + tokenRequirements.protocolSpecific?.protocolAddress || + tokenRequirements.protocolSpecific?.poolAddress || + tokenRequirements.protocolSpecific?.routerAddress || + tokenRequirements.protocolSpecific?.spenderAddress; + + const approvalToken = protocolToken || inputToken; + + if ( + approvalSpender && + approvalToken && + !this._isNativeTokenAddress(approvalToken) + ) { const approvalTx = await instance.getApprovalTransaction( userAddress, - protocolToken, - tokenRequirements.protocolSpecific?.protocolAddress || - tokenRequirements.protocolSpecific?.poolAddress, + approvalToken, + approvalSpender, amount ); transactions.push(approvalTx); diff --git a/src/validators/UnifiedZapValidator.js b/src/validators/UnifiedZapValidator.js index 13568b8..881bf8e 100644 --- a/src/validators/UnifiedZapValidator.js +++ b/src/validators/UnifiedZapValidator.js @@ -73,7 +73,7 @@ class UnifiedZapValidator { this.validateInputToken(inputToken); // Validate input amount - this.validateInputAmount(inputAmount, config); + this.validateInputAmount(inputAmount); // Validate slippage (optional) if (slippage !== undefined) { @@ -207,7 +207,7 @@ class UnifiedZapValidator { * @param {string} inputAmount - Input amount as string * @param {Object} config - UnifiedZap configuration */ - static validateInputAmount(inputAmount, config) { + static validateInputAmount(inputAmount) { if (!inputAmount) { throw new Error('inputAmount is required'); } @@ -216,28 +216,77 @@ class UnifiedZapValidator { throw new Error('inputAmount must be a string'); } - // Check if it's a valid number string - if (!/^\d+$/.test(inputAmount)) { - throw new Error( - 'inputAmount must be a valid positive integer string (no decimals)' - ); + const normalizedAmount = inputAmount.trim(); + if (!this._isValidPositiveNumberString(normalizedAmount)) { + throw new Error('inputAmount must be a valid positive number string'); } - const amount = parseFloat(inputAmount); + const amount = parseFloat(normalizedAmount); if (amount <= 0) { throw new Error('inputAmount must be greater than 0'); } // Optional: Check minimum amount (if configured) - if ( - config.VALIDATION.minInputAmount && - amount < config.VALIDATION.minInputAmount - ) { - throw new Error( - `inputAmount must be at least ${config.VALIDATION.minInputAmount}, got ${amount}` - ); + // we should use USD value as min threshold, since we can zapIn with eth/btc as well so use amount as threshold is not correct + // if ( + // config.VALIDATION.minInputAmount && + // amount < config.VALIDATION.minInputAmount + // ) { + // throw new Error( + // `inputAmount must be at least ${config.VALIDATION.minInputAmount}, got ${amount}` + // ); + // } + } + + /** + * Determine if value is a positive number string (digits with optional decimal part) + * @param {string} value - Value to validate + * @returns {boolean} - True if value is a valid number string + * @private + */ + static _isValidPositiveNumberString(value) { + if (!value) { + return false; + } + + const parts = value.split('.'); + if (parts.length > 2) { + return false; + } + + const [whole, fraction = ''] = parts; + + if (!this._isDigitsOnly(whole)) { + return false; + } + + if (fraction && !this._isDigitsOnly(fraction)) { + return false; + } + + return true; + } + + /** + * Check if string contains only digit characters + * @param {string} value - String to test + * @returns {boolean} - True if string is non-empty and numeric + * @private + */ + static _isDigitsOnly(value) { + if (!value) { + return false; } + + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + + return true; } /** From e72229e2f0b9b29202c541d0679aa7714e9d8bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 7 Oct 2025 21:59:37 +0900 Subject: [PATCH 06/29] wip: split intent into 2 phase, init and continue to solve swap dust issues --- src/app.js | 4 +- src/controllers/PhasedZapController.js | 279 +++++ src/executors/PhasedExecutionStore.js | 211 ++++ src/executors/UnifiedZapExecutor.js | 997 +++++++++++++++++- src/routes/phasedZapRoutes.js | 506 +++++++++ test/PhasedExecutionStore.test.js | 306 ++++++ .../integration/phasedZap.integration.test.js | 396 +++++++ 7 files changed, 2650 insertions(+), 49 deletions(-) create mode 100644 src/controllers/PhasedZapController.js create mode 100644 src/executors/PhasedExecutionStore.js create mode 100644 src/routes/phasedZapRoutes.js create mode 100644 test/PhasedExecutionStore.test.js create mode 100644 test/integration/phasedZap.integration.test.js diff --git a/src/app.js b/src/app.js index fbe8fc6..096170f 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const swapRoutes = require('./routes/swap'); const intentRoutes = require('./routes/intents'); const tokenRoutes = require('./routes/tokens'); const balanceRoutes = require('./routes/balanceRoutes'); +const phasedZapRoutes = require('./routes/phasedZapRoutes'); const app = express(); const PORT = process.env.PORT || 3002; @@ -45,6 +46,7 @@ app.use('/', swapRoutes); app.use('/', intentRoutes); app.use('/tokens', tokenRoutes); app.use('/', balanceRoutes); +app.use('/', phasedZapRoutes); // Error handling middleware (must be last) app.use(errorHandler); @@ -58,7 +60,7 @@ if (require.main === module) { console.log(`❤️ Health Check: http://localhost:${PORT}/health`); console.log(`Supported DEX providers: 1inch, paraswap, 0x`); console.log( - `Supported intents: dustZap, unifiedZap (zapIn, zapOut, rebalance coming soon)` + `Supported intents: dustZap, unifiedZap (atomic & phased), zapIn, zapOut, rebalance coming soon` ); console.log(`🪙 Token endpoints: /tokens/zap/{chainId}, /tokens/chains`); console.log(`💰 Balance endpoint: /api/v1/balances/{chainId}/{address}`); diff --git a/src/controllers/PhasedZapController.js b/src/controllers/PhasedZapController.js new file mode 100644 index 0000000..cec3f22 --- /dev/null +++ b/src/controllers/PhasedZapController.js @@ -0,0 +1,279 @@ +/** + * PhasedZapController - HTTP controller for phased UnifiedZap execution + * + * Handles two-phase execution flow: + * Phase 1: Generate swap transactions → store execution context + * Phase 2: Query actual balances → generate deposit transactions + */ + +const UnifiedZapExecutor = require('../executors/UnifiedZapExecutor'); +const BalanceService = require('../services/balanceService'); +const SwapService = require('../services/swapService'); +const PriceService = require('../services/priceService'); +const RebalanceBackendClient = require('../services/RebalanceBackendClient'); +const { mapUnifiedZapError } = require('../utils/errorHandlerUtils'); + +// Initialize services +const swapService = new SwapService(); +const priceService = new PriceService(); +const rebalanceClient = new RebalanceBackendClient(); +const balanceService = new BalanceService(); + +// Initialize executor with balanceService for phased execution +const executor = new UnifiedZapExecutor( + swapService, + priceService, + rebalanceClient, + balanceService +); + +class PhasedZapController { + /** + * Initialize Phase 1 - Generate swap-only transactions + * POST /api/v1/intents/unified-zap/phased/init + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static async initializePhase1(req, res) { + try { + const request = req.body; + + // Validate request has required fields + if (!request.userAddress || !request.chainId || !request.params) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: + 'Missing required fields: userAddress, chainId, and params are required', + }, + }); + } + + // Call executor to initialize Phase 1 + const result = await executor.initializePhasedExecution(request); + + // Return Phase 1 response + res.json({ + success: true, + executionId: result.executionId, + phase: result.phase, + transactions: result.transactions, + metadata: { + totalStrategies: result.metadata.totalStrategies, + totalProtocols: result.metadata.totalProtocols, + swapCount: result.transactions.length, + userAddress: result.metadata.userAddress, + chainId: result.metadata.chainId, + expiresAt: new Date(result.metadata.expiresAt).toISOString(), + nextStep: + 'Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId', + }, + }); + } catch (error) { + console.error('Phase 1 initialization error:', error); + + // Map error to appropriate HTTP response + const { statusCode, errorCode, message, details } = + mapUnifiedZapError(error); + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message, + details, + }, + }); + } + } + + /** + * Continue to Phase 2 - Query balances and generate deposit transactions + * POST /api/v1/intents/unified-zap/phased/continue/:executionId + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static async continueToPhase2(req, res) { + try { + const { executionId } = req.params; + + // Validate executionId + if (!executionId) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'executionId is required', + }, + }); + } + + // Call executor to continue to Phase 2 + const result = await executor.continueToNextPhase(executionId); + + // Return Phase 2 response + res.json({ + success: true, + executionId: result.executionId, + phase: result.phase, + transactions: result.transactions, + actualBalances: result.actualBalances, + metadata: { + totalProtocols: result.metadata.totalProtocols, + depositCount: result.transactions.length, + balanceQueryTime: new Date( + result.metadata.balanceQueryTime + ).toISOString(), + }, + }); + } catch (error) { + console.error('Phase 2 continuation error:', error); + + // Handle specific error cases + if (error.message.includes('not found or expired')) { + return res.status(404).json({ + success: false, + error: { + code: 'EXECUTION_NOT_FOUND', + message: error.message, + }, + }); + } + + if (error.message.includes('Invalid phase')) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PHASE', + message: error.message, + }, + }); + } + + if ( + error.message.includes('No non-zero balances') || + error.message.includes('Zero balances detected') || + error.message.includes('Missing balances for tokens') + ) { + return res.status(400).json({ + success: false, + error: { + code: 'INSUFFICIENT_BALANCES', + message: error.message, + details: { + suggestion: + 'Ensure Phase 1 swap transactions completed successfully before calling Phase 2', + }, + }, + }); + } + + // Map other errors + const { statusCode, errorCode, message, details } = + mapUnifiedZapError(error); + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message, + details, + }, + }); + } + } + + /** + * Get execution status + * GET /api/v1/intents/unified-zap/phased/status/:executionId + * + * @param {Object} req - Express request + * @param {Object} res - Express response + */ + static async getStatus(req, res) { + try { + const { executionId } = req.params; + + // Validate executionId + if (!executionId) { + return res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'executionId is required', + }, + }); + } + + // Retrieve execution state from store + const state = executor.phasedStore.get(executionId); + + if (!state) { + return res.status(404).json({ + success: false, + error: { + code: 'EXECUTION_NOT_FOUND', + message: `Execution ${executionId} not found or expired`, + }, + }); + } + + // Build metadata response + const metadata = { + userAddress: state.userAddress, + chainId: state.chainId, + swapTokenAddresses: state.swapTokenAddresses, + createdAt: new Date(state.createdAt).toISOString(), + expiresAt: new Date(state.expiresAt).toISOString(), + }; + + // Add actual balances if Phase 2 was started + if (state.actualBalances) { + metadata.actualBalances = state.actualBalances; + } + + // Return status response + res.json({ + success: true, + executionId: state.executionId, + phase: state.phase, + status: state.status, + metadata, + }); + } catch (error) { + console.error('Get status error:', error); + + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve execution status', + details: { + error: error.message, + }, + }, + }); + } + } + + /** + * Get executor instance (for testing) + * @returns {UnifiedZapExecutor} + */ + static getExecutor() { + return executor; + } + + /** + * Get phased store statistics (for debugging/monitoring) + * @returns {Object} - Store statistics + */ + static getStoreStats() { + return executor.phasedStore.getStats(); + } +} + +module.exports = PhasedZapController; diff --git a/src/executors/PhasedExecutionStore.js b/src/executors/PhasedExecutionStore.js new file mode 100644 index 0000000..732a1fb --- /dev/null +++ b/src/executors/PhasedExecutionStore.js @@ -0,0 +1,211 @@ +/** + * PhasedExecutionStore - In-memory state management for phased zap execution + * + * Stores execution state between Phase 1 (swaps) and Phase 2 (deposits) + * with automatic TTL-based cleanup to prevent memory leaks. + */ + +class PhasedExecutionStore { + constructor(ttlMinutes = 30) { + /** + * In-memory store: executionId -> PhasedExecutionState + * @type {Map} + */ + this.store = new Map(); + + /** + * Time-to-live in milliseconds + * @type {number} + */ + this.TTL_MS = ttlMinutes * 60 * 1000; + + /** + * Cleanup interval timer + * @type {NodeJS.Timeout} + */ + this.cleanupInterval = this._startCleanupInterval(); + } + + /** + * Store new phased execution state + * @param {string} executionId - Unique execution identifier + * @param {Object} state - Execution state object + * @param {number} state.phase - Current phase (1 or 2) + * @param {string} state.status - Status: 'pending' | 'completed' | 'failed' + * @param {string} state.executionContext - Serialized execution context (JSON string) + * @param {string[]} state.swapTokenAddresses - Token addresses to query after phase 1 + * @param {Object} [state.actualBalances] - Actual balances from Moralis (set in phase 2) + * @param {string} state.userAddress - User wallet address + * @param {string|number} state.chainId - Blockchain chain ID + */ + set(executionId, state) { + const now = Date.now(); + + this.store.set(executionId, { + ...state, + executionId, + createdAt: now, + expiresAt: now + this.TTL_MS, + }); + } + + /** + * Retrieve execution state + * @param {string} executionId - Execution identifier + * @returns {Object|null} - Execution state or null if not found/expired + */ + get(executionId) { + const state = this.store.get(executionId); + + if (!state) { + return null; + } + + // Check expiration + const now = Date.now(); + if (now > state.expiresAt) { + this.store.delete(executionId); + return null; + } + + return state; + } + + /** + * Update existing execution state + * @param {string} executionId - Execution identifier + * @param {Partial} updates - State updates to merge + * @throws {Error} - If execution not found or expired + */ + update(executionId, updates) { + const state = this.get(executionId); + + if (!state) { + throw new Error( + `Execution ${executionId} not found or expired (TTL: ${this.TTL_MS / 1000 / 60} minutes)` + ); + } + + this.store.set(executionId, { + ...state, + ...updates, + }); + } + + /** + * Delete execution state + * @param {string} executionId - Execution identifier + * @returns {boolean} - True if deleted, false if not found + */ + delete(executionId) { + return this.store.delete(executionId); + } + + /** + * Get current store size + * @returns {number} - Number of stored executions + */ + size() { + return this.store.size; + } + + /** + * Get all execution IDs (for debugging) + * @returns {string[]} - Array of execution IDs + */ + keys() { + return Array.from(this.store.keys()); + } + + /** + * Clear all stored executions + */ + clear() { + this.store.clear(); + } + + /** + * Remove expired entries + * @private + * @returns {number} - Number of entries removed + */ + _cleanup() { + const now = Date.now(); + let removed = 0; + + for (const [id, state] of this.store.entries()) { + if (now > state.expiresAt) { + this.store.delete(id); + removed++; + } + } + + if (removed > 0) { + console.log( + `[PhasedExecutionStore] Cleaned up ${removed} expired execution(s)` + ); + } + + return removed; + } + + /** + * Start automatic cleanup interval + * @private + * @returns {NodeJS.Timeout} - Interval timer + */ + _startCleanupInterval() { + // Run cleanup every 5 minutes + const interval = setInterval(() => { + this._cleanup(); + }, 5 * 60 * 1000); + + // Allow process to exit even if interval is running + if (interval.unref) { + interval.unref(); + } + + return interval; + } + + /** + * Gracefully shut down the store + * Clears interval and all stored data + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + this.store.clear(); + console.log('[PhasedExecutionStore] Destroyed and cleared'); + } + + /** + * Get statistics about store usage + * @returns {Object} - Store statistics + */ + getStats() { + const now = Date.now(); + let active = 0; + let expired = 0; + + for (const state of this.store.values()) { + if (now > state.expiresAt) { + expired++; + } else { + active++; + } + } + + return { + total: this.store.size, + active, + expired, + ttlMinutes: this.TTL_MS / 1000 / 60, + }; + } +} + +module.exports = PhasedExecutionStore; diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index df1763d..6927ad7 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -7,13 +7,19 @@ const { protocolFactory } = require('../protocols'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); const { TokenConfigService, CHAIN_METADATA } = require('../config/tokenConfig'); const SSEEventFactory = require('../services/SSEEventFactory'); +const TransactionBuilder = require('../transactions/TransactionBuilder'); +const PhasedExecutionStore = require('./PhasedExecutionStore'); class UnifiedZapExecutor { - constructor(swapService, priceService, rebalanceClient) { + constructor(swapService, priceService, rebalanceClient, balanceService = null) { this.swapService = swapService; this.priceService = priceService; this.rebalanceClient = rebalanceClient; this.protocolFactory = protocolFactory; + + // Phased execution support + this.balanceService = balanceService; + this.phasedStore = new PhasedExecutionStore(30); // 30-minute TTL } /** @@ -78,6 +84,7 @@ class UnifiedZapExecutor { const { userAddress, inputToken, + inputTokenDecimals, protocolAllocations, slippage, tokenPrices, @@ -122,6 +129,7 @@ class UnifiedZapExecutor { protocolAllocation, userAddress, inputToken, + inputTokenDecimals, slippage, tokenPrices ); @@ -563,8 +571,9 @@ class UnifiedZapExecutor { protocolAllocation, userAddress, inputToken, + inputTokenDecimals, slippage, - _tokenPrices + tokenPrices ) { const { instance, amount, tokenRequirements } = protocolAllocation; const transactions = []; @@ -574,11 +583,29 @@ class UnifiedZapExecutor { } try { - // For single token protocols if (tokenRequirements.mode === 'single') { - const protocolToken = + let swapOutcome = null; + + if (tokenRequirements.requiresSwap) { + swapOutcome = await this._prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if (swapOutcome.swapTransactions.length > 0) { + transactions.push(...swapOutcome.swapTransactions); + } + } + + const protocolTokenAddress = tokenRequirements.protocolSpecific?.underlyingToken || - tokenRequirements.outputToken; + tokenRequirements.outputToken || + inputToken; const approvalSpender = tokenRequirements.protocolSpecific?.protocolAddress || @@ -586,71 +613,108 @@ class UnifiedZapExecutor { tokenRequirements.protocolSpecific?.routerAddress || tokenRequirements.protocolSpecific?.spenderAddress; - const approvalToken = protocolToken || inputToken; + const approvalTokenAddress = + swapOutcome?.depositTokenAddress || protocolTokenAddress; + + const approvalAmount = + swapOutcome?.depositAmount !== undefined + ? swapOutcome.depositAmount + : amount; if ( approvalSpender && - approvalToken && - !this._isNativeTokenAddress(approvalToken) + approvalTokenAddress && + !this._isNativeTokenAddress(approvalTokenAddress) ) { const approvalTx = await instance.getApprovalTransaction( userAddress, - approvalToken, + approvalTokenAddress, approvalSpender, - amount + approvalAmount ); transactions.push(approvalTx); } - // Generate deposit transaction + const depositTokenAddress = + swapOutcome?.depositTokenAddress || protocolTokenAddress; + const depositAmount = + swapOutcome?.depositAmount !== undefined + ? swapOutcome.depositAmount + : amount; + const depositTx = await instance.getDepositTransaction( userAddress, - protocolToken || inputToken, - amount, + depositTokenAddress, + depositAmount, { slippage } ); transactions.push(depositTx); - } + } else if (tokenRequirements.mode === 'LP') { + const lpContext = tokenRequirements.protocolSpecific || {}; + const { token0, token1, routerAddress } = lpContext; + + let lpSwapOutcome = null; + if (tokenRequirements.requiresSwap) { + lpSwapOutcome = await this._prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); - // For LP protocols - else if (tokenRequirements.mode === 'LP') { - const lpTokens = - tokenRequirements.protocolSpecific?.token0 && - tokenRequirements.protocolSpecific?.token1 - ? [ - tokenRequirements.protocolSpecific.token0, - tokenRequirements.protocolSpecific.token1, - ] - : []; - - if (lpTokens.length === 2) { - // Calculate token amounts for LP (50/50 split for simplicity) - const halfAmount = amount / 2n; - - // Generate approvals for both tokens - for (const token of lpTokens) { - const approvalTx = await instance.getApprovalTransaction( - userAddress, - token.address, - tokenRequirements.protocolSpecific?.routerAddress, - halfAmount - ); - transactions.push(approvalTx); + if (lpSwapOutcome.swapTransactions.length > 0) { + transactions.push(...lpSwapOutcome.swapTransactions); } + } - // Generate LP provision transaction - const depositTx = await instance.getDepositTransaction( + const token0Amount = + lpSwapOutcome?.depositParams?.token0Amount ?? amount / 2n; + const token1Amount = + lpSwapOutcome?.depositParams?.token1Amount ?? amount / 2n; + + if ( + token0?.address && + !this._isNativeTokenAddress(token0.address) && + routerAddress + ) { + const approvalTx0 = await instance.getApprovalTransaction( userAddress, - inputToken, - amount, - { - token0Amount: halfAmount, - token1Amount: halfAmount, - slippage, - } + token0.address, + routerAddress, + token0Amount ); - transactions.push(depositTx); + transactions.push(approvalTx0); + } + + if ( + token1?.address && + !this._isNativeTokenAddress(token1.address) && + routerAddress + ) { + const approvalTx1 = await instance.getApprovalTransaction( + userAddress, + token1.address, + routerAddress, + token1Amount + ); + transactions.push(approvalTx1); } + + const depositTx = await instance.getDepositTransaction( + userAddress, + inputToken, + amount, + { + token0Amount, + token1Amount, + slippage, + } + ); + transactions.push(depositTx); } } catch (error) { console.error( @@ -665,6 +729,283 @@ class UnifiedZapExecutor { return transactions; } + async _prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }) { + const { tokenRequirements } = protocolAllocation; + + const targetTokenAddress = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (!targetTokenAddress) { + throw new Error( + `Protocol ${protocolAllocation.id} requires swap but no target token address was provided` + ); + } + + const targetDecimals = this._resolveTokenDecimals( + protocolAllocation.chainId, + targetTokenAddress + ); + + const symbolHint = this._resolveTokenSymbol( + protocolAllocation.chainId, + targetTokenAddress, + protocolAllocation.config?.config?.symbolOfBestTokenToZapInOut + ); + + const swapResult = await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: targetTokenAddress, + toTokenDecimals: targetDecimals, + amount, + slippage, + tokenPrices, + toTokenSymbol: symbolHint, + }); + + if (swapResult.depositAmount <= 0n) { + throw new Error( + `Swap produced zero output for protocol ${protocolAllocation.id}` + ); + } + + return { + swapTransactions: swapResult.transactions, + depositAmount: swapResult.depositAmount, + depositTokenAddress: targetTokenAddress, + swapQuote: swapResult.swapQuote, + }; + } + + async _prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }) { + const lpContext = tokenRequirements?.protocolSpecific || {}; + const token0 = lpContext.token0; + const token1 = lpContext.token1; + + if (!token0 || !token1) { + throw new Error( + `LP protocol ${protocolAllocation.id} is missing token metadata` + ); + } + + const swapAmount0 = amount / 2n; + const swapAmount1 = amount - swapAmount0; + + if (swapAmount0 <= 0n || swapAmount1 <= 0n) { + throw new Error( + `Input amount too small to split for LP provisioning in protocol ${protocolAllocation.id}` + ); + } + + const symbol0 = this._resolveTokenSymbol( + protocolAllocation.chainId, + token0.address, + token0.symbol + ); + const symbol1 = this._resolveTokenSymbol( + protocolAllocation.chainId, + token1.address, + token1.symbol + ); + + const swap0 = await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token0.address, + toTokenDecimals: token0.decimals, + amount: swapAmount0, + slippage, + tokenPrices, + toTokenSymbol: symbol0, + }); + + const swap1 = await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token1.address, + toTokenDecimals: token1.decimals, + amount: swapAmount1, + slippage, + tokenPrices, + toTokenSymbol: symbol1, + }); + + if (swap0.depositAmount <= 0n || swap1.depositAmount <= 0n) { + throw new Error( + `Swap outputs zero amount for LP provisioning in protocol ${protocolAllocation.id}` + ); + } + + return { + swapTransactions: [...swap0.transactions, ...swap1.transactions], + depositParams: { + token0Amount: swap0.depositAmount, + token1Amount: swap1.depositAmount, + }, + }; + } + + async _executeSwap({ + chainId, + userAddress, + fromTokenAddress, + fromTokenDecimals, + toTokenAddress, + toTokenDecimals, + amount, + slippage, + tokenPrices, + toTokenSymbol, + }) { + if (amount <= 0n) { + throw new Error('Swap amount must be greater than zero'); + } + + const normalizedFrom = this._normalizeSwapAddress(fromTokenAddress); + const normalizedTo = this._normalizeSwapAddress(toTokenAddress); + + const ethPrice = + this._resolveTokenPrice('eth', tokenPrices) ?? + this._resolveTokenPrice('weth', tokenPrices) ?? + 3000; + + const toTokenPrice = + this._resolveTokenPrice(toTokenSymbol, tokenPrices) ?? + this._resolveTokenPrice('usdc', tokenPrices) ?? + ethPrice; + + const swapQuote = await this.swapService.getSecondBestSwapQuote({ + chainId, + fromTokenAddress: normalizedFrom, + fromTokenDecimals, + toTokenAddress: normalizedTo, + toTokenDecimals, + amount: amount.toString(), + fromAddress: userAddress, + slippage, + eth_price: ethPrice, + toTokenPrice, + }); + + const txBuilder = new TransactionBuilder(); + + if (!this._isNativeTokenAddress(fromTokenAddress)) { + txBuilder.addApprove(fromTokenAddress, swapQuote.approve_to, amount); + } + + const swapDescription = `Swap ${amount.toString()} units from ${fromTokenAddress} to ${toTokenAddress}`; + txBuilder.addSwap(swapQuote, swapDescription); + + return { + transactions: txBuilder.getTransactions(), + swapQuote, + depositAmount: this._selectDepositAmount(swapQuote), + depositTokenAddress: toTokenAddress, + }; + } + + _normalizeSwapAddress(address) { + if (!address) { + return address; + } + + const normalized = address.toLowerCase(); + + if (this._isNativeTokenAddress(normalized)) { + return '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + } + + return address; + } + + _resolveTokenSymbol(chainId, tokenAddress, fallbackSymbol) { + if (!tokenAddress) { + return fallbackSymbol || null; + } + + const tokenMeta = TokenConfigService.getTokenByAddress(chainId, tokenAddress); + if (tokenMeta?.symbol) { + return tokenMeta.symbol; + } + + return fallbackSymbol || null; + } + + _resolveTokenPrice(symbol, tokenPrices) { + if (!symbol || !tokenPrices) { + return null; + } + + const key = symbol.toLowerCase(); + + if (Object.prototype.hasOwnProperty.call(tokenPrices, key)) { + return tokenPrices[key]; + } + + return null; + } + + _selectDepositAmount(swapQuote) { + if (!swapQuote) { + return 0n; + } + + const candidate = + swapQuote.minToAmount !== undefined && swapQuote.minToAmount !== null + ? swapQuote.minToAmount + : swapQuote.toAmount; + + if (candidate === undefined || candidate === null) { + return 0n; + } + + if (typeof candidate === 'bigint') { + return candidate; + } + + if (typeof candidate === 'number') { + return BigInt(Math.max(Math.floor(candidate), 0)); + } + + if (typeof candidate === 'string') { + const sanitized = candidate.split('.')[0]; + if (sanitized) { + return BigInt(sanitized); + } + } + + try { + return BigInt(candidate.toString()); + } catch (error) { + console.warn('Failed to parse swap output amount, defaulting to 0:', error); + return 0n; + } + } + /** * Group transactions by protocol ID * @param {Array} transactions - All transactions @@ -740,6 +1081,566 @@ class UnifiedZapExecutor { return chainMapping[chainId] || 'unknown'; } + + // ============================================================================ + // PHASED EXECUTION METHODS + // ============================================================================ + + /** + * PHASE 1: Initialize phased execution with swap transactions only + * @param {Object} request - Original zap request + * @returns {Promise} - { executionId, phase, transactions, metadata } + */ + async initializePhasedExecution(request) { + if (!this.balanceService) { + throw new Error( + 'BalanceService is required for phased execution. Please inject it in constructor.' + ); + } + + const executionId = this._generateExecutionId(); + + try { + // Reuse existing validation and context preparation + const executionContext = await this.prepareExecutionContext(request); + + // Generate ONLY swap transactions (Phase 1) + const swapTransactions = await this._generateSwapTransactionsOnly( + executionContext + ); + + // Determine which token addresses to query after swaps + const swapTokenAddresses = this._extractSwapOutputTokens( + executionContext + ); + + // Store state for Phase 2 + this.phasedStore.set(executionId, { + phase: 1, + status: 'pending', + executionContext: this._serializeContext(executionContext), + swapTokenAddresses, + actualBalances: null, + userAddress: request.userAddress, + chainId: request.chainId.toString(), + }); + + // Retrieve stored state to get createdAt/expiresAt timestamps + const storedState = this.phasedStore.get(executionId); + + return { + executionId, + phase: 1, + transactions: swapTransactions, + estimatedDuration: this.estimateProcessingDuration( + executionContext.protocolAllocations.length + ), + metadata: { + swapCount: swapTransactions.length, + tokensToQuery: swapTokenAddresses, + protocolCount: executionContext.protocolAllocations.length, + userAddress: request.userAddress, + chainId: request.chainId.toString(), + totalStrategies: request.params.strategyAllocations.length, + totalProtocols: executionContext.protocolAllocations.length, + expiresAt: storedState.expiresAt, + createdAt: storedState.createdAt, + }, + }; + } catch (error) { + // Clean up state on error + this.phasedStore.delete(executionId); + throw new Error(`Phase 1 initialization failed: ${error.message}`); + } + } + + /** + * PHASE 2: Generate deposit transactions with actual on-chain balances + * @param {string} executionId - Execution identifier from Phase 1 + * @returns {Promise} - { phase, transactions, actualBalances, metadata } + */ + async continueToNextPhase(executionId) { + // 1. Retrieve and validate state + const state = this.phasedStore.get(executionId); + + if (!state) { + throw new Error( + `Execution ${executionId} not found or expired (TTL: 30 minutes)` + ); + } + + if (state.phase !== 1) { + throw new Error( + `Invalid phase transition. Current phase: ${state.phase}, expected: 1` + ); + } + + if (state.status !== 'pending') { + throw new Error( + `Execution ${executionId} is not in pending status (current: ${state.status})` + ); + } + + try { + // 2. Query actual balances from blockchain via Moralis + const actualBalances = await this._queryActualBalances( + state.userAddress, + state.chainId, + state.swapTokenAddresses + ); + + // 3. Validate we have non-zero balances + this._validateActualBalances(actualBalances, state.swapTokenAddresses); + + // 4. Deserialize execution context + const executionContext = this._deserializeContext( + state.executionContext + ); + + // 5. Generate deposit transactions with ACTUAL balances + const depositTransactions = + await this._generateDepositTransactionsWithBalances( + executionContext, + actualBalances + ); + + // 6. Update state to completed + this.phasedStore.update(executionId, { + phase: 2, + status: 'completed', + actualBalances: this._serializeBalances(actualBalances), + }); + + // Get updated state for metadata + const updatedState = this.phasedStore.get(executionId); + + return { + executionId, + phase: 2, + transactions: depositTransactions, + actualBalances: this._formatBalancesForResponse(actualBalances), + metadata: { + depositCount: depositTransactions.length, + balancesUsed: Array.from(actualBalances.keys()), + totalProtocols: executionContext.protocolAllocations.length, + balanceQueryTime: Date.now(), + }, + }; + } catch (error) { + // Mark as failed but keep in store for debugging + this.phasedStore.update(executionId, { status: 'failed' }); + throw new Error(`Phase 2 continuation failed: ${error.message}`); + } + } + + /** + * Generate ONLY swap transactions (Phase 1) + * Extracted from _generateProtocolTransactions + * @private + */ + async _generateSwapTransactionsOnly(executionContext) { + const { protocolAllocations, userAddress, inputToken, inputTokenDecimals, slippage, tokenPrices } = executionContext; + const transactions = []; + + for (const protocolAllocation of protocolAllocations) { + const { tokenRequirements, amount } = protocolAllocation; + + // Skip if no swap needed + if (!tokenRequirements.requiresSwap) { + continue; + } + + try { + if (tokenRequirements.mode === 'single') { + // Single token swap + const swapResult = await this._prepareSingleTokenSwap({ + protocolAllocation, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if (swapResult.swapTransactions && swapResult.swapTransactions.length > 0) { + transactions.push(...swapResult.swapTransactions); + } + } else if (tokenRequirements.mode === 'LP') { + // LP token swaps (dual swaps) + const lpSwapResult = await this._prepareLPTokenSwaps({ + protocolAllocation, + tokenRequirements, + userAddress, + inputToken, + inputTokenDecimals, + amount, + slippage, + tokenPrices, + }); + + if (lpSwapResult.swapTransactions && lpSwapResult.swapTransactions.length > 0) { + transactions.push(...lpSwapResult.swapTransactions); + } + } + } catch (error) { + console.error( + `Error generating swap for protocol ${protocolAllocation.id}:`, + error + ); + throw error; + } + } + + return transactions; + } + + /** + * Generate deposit transactions using ACTUAL on-chain balances (Phase 2) + * @private + */ + async _generateDepositTransactionsWithBalances(executionContext, actualBalances) { + const { protocolAllocations, userAddress, slippage } = executionContext; + const transactions = []; + + for (const protocolAllocation of protocolAllocations) { + const { instance, tokenRequirements } = protocolAllocation; + + try { + if (tokenRequirements.mode === 'single') { + // Single token deposit + const targetToken = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (!targetToken) { + throw new Error( + `No target token found for protocol ${protocolAllocation.id}` + ); + } + + const actualAmount = actualBalances.get(targetToken.toLowerCase()); + + if (!actualAmount || actualAmount === 0n) { + console.warn( + `No balance for ${targetToken} after swap - skipping deposit for ${protocolAllocation.id}` + ); + continue; + } + + // Approval transaction (if not native token) + const spender = + tokenRequirements.protocolSpecific?.protocolAddress || + tokenRequirements.protocolSpecific?.poolAddress; + + if (spender && !this._isNativeTokenAddress(targetToken)) { + const approvalTx = await instance.getApprovalTransaction( + userAddress, + targetToken, + spender, + actualAmount + ); + transactions.push(approvalTx); + } + + // Deposit transaction + const depositTx = await instance.getDepositTransaction( + userAddress, + targetToken, + actualAmount, + { slippage } + ); + transactions.push(depositTx); + } else if (tokenRequirements.mode === 'LP') { + // LP deposit + const { token0, token1, routerAddress } = + tokenRequirements.protocolSpecific || {}; + + if (!token0 || !token1) { + throw new Error( + `Missing LP token metadata for protocol ${protocolAllocation.id}` + ); + } + + const actualToken0Amount = actualBalances.get( + token0.address.toLowerCase() + ); + const actualToken1Amount = actualBalances.get( + token1.address.toLowerCase() + ); + + if (!actualToken0Amount || !actualToken1Amount) { + console.warn( + `Missing LP token balances for ${protocolAllocation.id} - skipping` + ); + continue; + } + + // Approvals for both tokens + if (!this._isNativeTokenAddress(token0.address) && routerAddress) { + const approvalTx0 = await instance.getApprovalTransaction( + userAddress, + token0.address, + routerAddress, + actualToken0Amount + ); + transactions.push(approvalTx0); + } + + if (!this._isNativeTokenAddress(token1.address) && routerAddress) { + const approvalTx1 = await instance.getApprovalTransaction( + userAddress, + token1.address, + routerAddress, + actualToken1Amount + ); + transactions.push(approvalTx1); + } + + // LP deposit with actual amounts + const depositTx = await instance.getDepositTransaction( + userAddress, + null, + 0n, + { + token0Amount: actualToken0Amount, + token1Amount: actualToken1Amount, + slippage, + } + ); + transactions.push(depositTx); + } + } catch (error) { + console.error( + `Error generating deposit for protocol ${protocolAllocation.id}:`, + error + ); + throw error; + } + } + + return transactions; + } + + /** + * Query actual token balances from blockchain via Moralis + * @private + */ + async _queryActualBalances(userAddress, chainId, tokenAddresses) { + if (!this.balanceService) { + throw new Error('BalanceService is not configured'); + } + + try { + const result = await this.balanceService.getBalances(userAddress, { + chainId, + tokenAddresses, + skipCache: true, // Always get fresh balances for phase transitions + }); + + // Convert to Map + const balanceMap = new Map(); + + if (result.balances && Array.isArray(result.balances)) { + for (const balance of result.balances) { + balanceMap.set( + balance.tokenAddress.toLowerCase(), + BigInt(balance.balance) + ); + } + } + + return balanceMap; + } catch (error) { + throw new Error(`Moralis balance query failed: ${error.message}`); + } + } + + /** + * Extract token addresses that will have balances after swaps + * @private + */ + _extractSwapOutputTokens(executionContext) { + const { protocolAllocations } = executionContext; + const addresses = new Set(); + + for (const protocol of protocolAllocations) { + const { tokenRequirements } = protocol; + + if (tokenRequirements.mode === 'single') { + const targetToken = + tokenRequirements.protocolSpecific?.underlyingToken || + tokenRequirements.outputToken; + + if (targetToken) { + addresses.add(targetToken.toLowerCase()); + } + } else if (tokenRequirements.mode === 'LP') { + const { token0, token1 } = tokenRequirements.protocolSpecific || {}; + + if (token0?.address) { + addresses.add(token0.address.toLowerCase()); + } + if (token1?.address) { + addresses.add(token1.address.toLowerCase()); + } + } + } + + return Array.from(addresses); + } + + /** + * Validate actual balances are non-zero + * @private + */ + _validateActualBalances(actualBalances, expectedTokens) { + const missing = []; + const zero = []; + + for (const tokenAddr of expectedTokens) { + const balance = actualBalances.get(tokenAddr.toLowerCase()); + + if (!balance) { + missing.push(tokenAddr); + } else if (balance === 0n) { + zero.push(tokenAddr); + } + } + + if (missing.length > 0) { + throw new Error( + `Missing balances for tokens: ${missing.join(', ')}. Ensure swap transactions completed successfully.` + ); + } + + if (zero.length > 0) { + throw new Error( + `Zero balances detected for tokens: ${zero.join(', ')}. Swap transactions may have failed or not yet confirmed.` + ); + } + } + + /** + * Serialize execution context (BigInt-safe) + * @private + */ + _serializeContext(context) { + return JSON.stringify(context, (key, value) => + typeof value === 'bigint' ? value.toString() : value + ); + } + + /** + * Deserialize execution context (restore BigInt and protocol instances) + * @private + */ + _deserializeContext(contextString) { + const parsed = JSON.parse(contextString); + + // Restore BigInt fields + if (parsed.inputAmount && typeof parsed.inputAmount === 'string') { + parsed.inputAmount = BigInt(parsed.inputAmount); + } + + // Restore BigInt and protocol instances in protocol allocations + if (parsed.protocolAllocations) { + const chainName = this._getChainName(parsed.chainId); + + parsed.protocolAllocations = parsed.protocolAllocations.map(proto => { + // Restore amount BigInt + const restoredProto = { + ...proto, + amount: typeof proto.amount === 'string' ? BigInt(proto.amount) : proto.amount, + }; + + // Restore protocol instance from factory + if (proto.id && !proto.instance) { + try { + restoredProto.instance = this.protocolFactory.createProtocol( + proto.id, + chainName + ); + } catch (error) { + console.warn( + `Failed to restore protocol instance for ${proto.id}: ${error.message}` + ); + } + } + + return restoredProto; + }); + } + + return parsed; + } + + /** + * Serialize balances Map to object (BigInt-safe) + * @private + */ + _serializeBalances(balanceMap) { + const obj = {}; + for (const [addr, balance] of balanceMap.entries()) { + obj[addr] = balance.toString(); + } + return obj; + } + + /** + * Format balances for API response + * @private + */ + _formatBalancesForResponse(balanceMap) { + const formatted = {}; + for (const [addr, balance] of balanceMap.entries()) { + formatted[addr] = { + raw: balance.toString(), + formatted: this._formatTokenAmount(balance, 18), // Default 18 decimals + }; + } + return formatted; + } + + /** + * Format token amount for display + * @private + */ + _formatTokenAmount(amount, decimals) { + try { + const divisor = BigInt(10) ** BigInt(decimals); + const wholePart = amount / divisor; + const fractionalPart = amount % divisor; + + if (fractionalPart === 0n) { + return wholePart.toString(); + } + + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + const trimmed = fractionalStr.replace(/0+$/, ''); + + return `${wholePart}.${trimmed}`; + } catch { + return amount.toString(); + } + } + + /** + * Generate unique execution ID + * @private + */ + _generateExecutionId() { + return `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Cleanup phased store on shutdown + */ + destroy() { + if (this.phasedStore) { + this.phasedStore.destroy(); + } + } } module.exports = UnifiedZapExecutor; diff --git a/src/routes/phasedZapRoutes.js b/src/routes/phasedZapRoutes.js new file mode 100644 index 0000000..5063716 --- /dev/null +++ b/src/routes/phasedZapRoutes.js @@ -0,0 +1,506 @@ +const express = require('express'); +const { validateIntentRequest } = require('../middleware/requestValidator'); + +const router = express.Router(); + +/** + * Phased UnifiedZap Routes + * + * Two-phase execution flow for multi-strategy allocation: + * Phase 1: Execute all swap transactions, wait for on-chain confirmation + * Phase 2: Query actual balances via Moralis, execute deposits with actual amounts + * + * This approach eliminates "dust" tokens from swap estimation errors. + */ + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/init: + * post: + * tags: + * - Phased Execution + * summary: Initialize Phase 1 of phased UnifiedZap execution + * description: | + * Generates Phase 1 transactions (all swaps and approvals) for multi-strategy allocation. + * After executing these transactions on-chain, call the continue endpoint with the executionId + * to proceed to Phase 2 (deposits with actual on-chain balances). + * + * **Flow:** + * 1. Call this endpoint to get Phase 1 transactions + * 2. User signs and executes all swap transactions on-chain + * 3. Wait for transaction confirmations + * 4. Call `/phased/continue/:executionId` to get Phase 2 deposit transactions + * + * **Benefits:** + * - Eliminates dust tokens from swap estimation errors + * - Uses actual on-chain balances for deposits + * - Reduces transaction failures from insufficient balances + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/IntentRequest' + * - type: object + * properties: + * params: + * $ref: '#/components/schemas/UnifiedZapParams' + * examples: + * phasedInitRequest: + * summary: Initialize phased execution for stablecoin strategy + * value: + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: 8453 + * params: + * strategyAllocations: + * - strategyId: "stablecoin" + * percentage: 100 + * inputToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * inputAmount: "1000000000" + * slippage: 0.5 + * responses: + * 200: + * description: Phase 1 transactions generated successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, transactions, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Unique execution ID to use for Phase 2 continuation + * phase: + * type: integer + * example: 1 + * description: Current phase number + * transactions: + * type: array + * description: Phase 1 swap transactions (approvals and swaps) + * items: + * type: object + * properties: + * to: + * type: string + * example: "0x1111111254EEB25477B68fb85Ed929f73A960582" + * data: + * type: string + * example: "0x12aa3caf..." + * value: + * type: string + * example: "0" + * gasLimit: + * type: string + * example: "300000" + * description: + * type: string + * example: "Swap USDC to WETH via 1inch" + * metadata: + * type: object + * properties: + * totalStrategies: + * type: integer + * example: 1 + * totalProtocols: + * type: integer + * example: 3 + * swapCount: + * type: integer + * example: 2 + * userAddress: + * type: string + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: + * type: integer + * example: 8453 + * expiresAt: + * type: string + * format: date-time + * example: "2024-01-01T00:30:00.000Z" + * description: Execution state expires after 30 minutes + * nextStep: + * type: string + * example: "Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId" + * examples: + * phase1Response: + * summary: Successful Phase 1 initialization + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 1 + * transactions: + * - to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * data: "0x095ea7b3..." + * value: "0" + * gasLimit: "60000" + * description: "Approve USDC for 1inch Router" + * - to: "0x1111111254EEB25477B68fb85Ed929f73A960582" + * data: "0x12aa3caf..." + * value: "0" + * gasLimit: "250000" + * description: "Swap 700 USDC to USDC for Aave" + * metadata: + * totalStrategies: 1 + * totalProtocols: 3 + * swapCount: 2 + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: 8453 + * expiresAt: "2024-01-01T00:30:00.000Z" + * nextStep: "Execute Phase 1 transactions on-chain, then call /phased/continue/:executionId" + * 400: + * description: Invalid request parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post( + '/api/v1/intents/unified-zap/phased/init', + validateIntentRequest, + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.initializePhase1(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to initialize phased execution', + }, + }); + } + } +); + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/continue/{executionId}: + * post: + * tags: + * - Phased Execution + * summary: Continue to Phase 2 of phased UnifiedZap execution + * description: | + * Queries actual on-chain balances via Moralis and generates Phase 2 transactions + * (deposits and stakes) using the actual token amounts from completed swaps. + * + * **Prerequisites:** + * - All Phase 1 swap transactions must be confirmed on-chain + * - ExecutionId must be valid and not expired (30-minute TTL) + * + * **Process:** + * 1. Validates executionId and retrieves stored execution context + * 2. Queries actual token balances via Moralis API + * 3. Validates balances are non-zero + * 4. Generates deposit/approval transactions with actual amounts + * 5. Returns Phase 2 transactions ready for execution + * parameters: + * - in: path + * name: executionId + * required: true + * schema: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Execution ID from Phase 1 initialization + * responses: + * 200: + * description: Phase 2 transactions generated successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, transactions, actualBalances, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * phase: + * type: integer + * example: 2 + * transactions: + * type: array + * description: Phase 2 deposit/approval transactions + * items: + * type: object + * properties: + * to: + * type: string + * data: + * type: string + * value: + * type: string + * gasLimit: + * type: string + * description: + * type: string + * actualBalances: + * type: object + * description: Actual on-chain balances queried from Moralis + * additionalProperties: + * type: object + * properties: + * raw: + * type: string + * example: "699850000" + * description: Raw balance in smallest unit + * formatted: + * type: string + * example: "699.85" + * description: Human-readable balance + * decimals: + * type: integer + * example: 6 + * metadata: + * type: object + * properties: + * totalProtocols: + * type: integer + * depositCount: + * type: integer + * balanceQueryTime: + * type: string + * format: date-time + * examples: + * phase2Response: + * summary: Successful Phase 2 continuation + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 2 + * transactions: + * - to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + * data: "0x095ea7b3..." + * value: "0" + * gasLimit: "60000" + * description: "Approve 699.85 USDC for Aave Pool" + * - to: "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5" + * data: "0xe8eda9df..." + * value: "0" + * gasLimit: "300000" + * description: "Supply 699.85 USDC to Aave" + * actualBalances: + * "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": + * raw: "699850000" + * formatted: "699.85" + * decimals: 6 + * metadata: + * totalProtocols: 3 + * depositCount: 3 + * balanceQueryTime: "2024-01-01T00:05:30.000Z" + * 400: + * description: Invalid executionId or execution context + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INVALID_EXECUTION_ID" + * message: + * type: string + * example: "Execution not found or expired" + * 404: + * description: Execution not found or expired (30-minute TTL) + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "EXECUTION_NOT_FOUND" + * message: + * type: string + * example: "Execution 550e8400-e29b-41d4-a716-446655440000 not found or expired (TTL: 30 minutes)" + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post( + '/api/v1/intents/unified-zap/phased/continue/:executionId', + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.continueToPhase2(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to continue phased execution', + }, + }); + } + } +); + +/** + * @swagger + * /api/v1/intents/unified-zap/phased/status/{executionId}: + * get: + * tags: + * - Phased Execution + * summary: Get status of phased execution + * description: | + * Retrieves the current status and metadata of a phased execution. + * Useful for debugging and tracking execution state. + * + * **Returns:** + * - Current phase (1 or 2) + * - Execution status (pending, completed, failed) + * - Timestamps (created, expires) + * - User address and chain ID + * - Token addresses being tracked + * parameters: + * - in: path + * name: executionId + * required: true + * schema: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * description: Execution ID to query + * responses: + * 200: + * description: Execution status retrieved successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, executionId, phase, status, metadata] + * properties: + * success: + * type: boolean + * example: true + * executionId: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * phase: + * type: integer + * example: 1 + * description: Current phase (1 or 2) + * status: + * type: string + * enum: [pending, completed, failed] + * example: "pending" + * metadata: + * type: object + * properties: + * userAddress: + * type: string + * example: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: + * type: string + * example: "8453" + * swapTokenAddresses: + * type: array + * items: + * type: string + * example: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + * description: Token addresses to query after Phase 1 + * createdAt: + * type: string + * format: date-time + * example: "2024-01-01T00:00:00.000Z" + * expiresAt: + * type: string + * format: date-time + * example: "2024-01-01T00:30:00.000Z" + * actualBalances: + * type: object + * description: Only present if Phase 2 was started + * examples: + * statusResponse: + * summary: Phase 1 pending status + * value: + * success: true + * executionId: "550e8400-e29b-41d4-a716-446655440000" + * phase: 1 + * status: "pending" + * metadata: + * userAddress: "0x2eCBC6f229feD06044CDb0dD772437a30190CD50" + * chainId: "8453" + * swapTokenAddresses: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + * createdAt: "2024-01-01T00:00:00.000Z" + * expiresAt: "2024-01-01T00:30:00.000Z" + * 404: + * description: Execution not found or expired + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "EXECUTION_NOT_FOUND" + * message: + * type: string + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.get( + '/api/v1/intents/unified-zap/phased/status/:executionId', + async (req, res) => { + try { + const PhasedZapController = require('../controllers/PhasedZapController'); + await PhasedZapController.getStatus(req, res); + } catch (error) { + console.error('Error loading PhasedZapController:', error); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve execution status', + }, + }); + } + } +); + +module.exports = router; diff --git a/test/PhasedExecutionStore.test.js b/test/PhasedExecutionStore.test.js new file mode 100644 index 0000000..1432cd2 --- /dev/null +++ b/test/PhasedExecutionStore.test.js @@ -0,0 +1,306 @@ +const PhasedExecutionStore = require('../src/executors/PhasedExecutionStore'); + +describe('PhasedExecutionStore', () => { + let store; + const EXECUTION_ID = 'test-exec-123'; + const MOCK_STATE = { + phase: 1, + status: 'pending', + executionContext: '{}', + swapTokenAddresses: ['0xTOKEN1'], + userAddress: '0xUSER', + chainId: 1, + }; + + // Use fake timers to control TTL and cleanup intervals deterministically + beforeAll(() => { + jest.useFakeTimers(); + // Mock setInterval and clearInterval to test cleanup lifecycle + jest.spyOn(global, 'setInterval'); + jest.spyOn(global, 'clearInterval'); + }); + + // Each test gets a fresh store instance with a short TTL for convenience + beforeEach(() => { + // Clear mock calls before each test + jest.clearAllMocks(); + // Default 1 minute TTL for most tests + store = new PhasedExecutionStore(1); + }); + + // Ensure timers and store are cleared after each test to prevent leaks + afterEach(() => { + store.destroy(); + }); + + afterAll(() => { + jest.useRealTimers(); + // Restore mocks - wrapped in try-catch to avoid issues if already restored + try { + if (jest.isMockFunction(global.setInterval)) { + global.setInterval.mockRestore(); + } + if (jest.isMockFunction(global.clearInterval)) { + global.clearInterval.mockRestore(); + } + } catch (e) { + // Mocks already restored or not needed + } + }); + + describe('Constructor and Initialization', () => { + it('should initialize with a default TTL of 30 minutes', () => { + const defaultStore = new PhasedExecutionStore(); + expect(defaultStore.TTL_MS).toBe(30 * 60 * 1000); + defaultStore.destroy(); + }); + + it('should accept a custom TTL in minutes', () => { + const customStore = new PhasedExecutionStore(10); + expect(customStore.TTL_MS).toBe(10 * 60 * 1000); + customStore.destroy(); + }); + + it('should handle a TTL of 0', () => { + const zeroTtlStore = new PhasedExecutionStore(0); + expect(zeroTtlStore.TTL_MS).toBe(0); + zeroTtlStore.destroy(); + }); + + it('should start the cleanup interval timer', () => { + expect(store.cleanupInterval).not.toBeNull(); + // Check that setInterval has been called + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 5 * 60 * 1000); + }); + }); + + describe('Set and Get', () => { + it('should set and get an execution state', () => { + store.set(EXECUTION_ID, MOCK_STATE); + const retrievedState = store.get(EXECUTION_ID); + + expect(retrievedState).not.toBeNull(); + expect(retrievedState.phase).toBe(MOCK_STATE.phase); + expect(retrievedState.executionId).toBe(EXECUTION_ID); + expect(retrievedState.createdAt).toBeDefined(); + expect(retrievedState.expiresAt).toBe(retrievedState.createdAt + 60 * 1000); + }); + + it('should return null for a non-existent execution ID', () => { + expect(store.get('non-existent-id')).toBeNull(); + }); + + it('should overwrite an existing state when set is called again for the same ID', () => { + store.set(EXECUTION_ID, MOCK_STATE); + const firstTimestamp = store.get(EXECUTION_ID).createdAt; + + jest.advanceTimersByTime(1000); // Advance time by 1 second + + const updatedStatePayload = { ...MOCK_STATE, status: 'updated' }; + store.set(EXECUTION_ID, updatedStatePayload); + + const retrievedState = store.get(EXECUTION_ID); + expect(retrievedState.status).toBe('updated'); + expect(retrievedState.createdAt).toBeGreaterThan(firstTimestamp); + }); + }); + + describe('Update', () => { + beforeEach(() => { + store.set(EXECUTION_ID, MOCK_STATE); + }); + + it('should update an existing execution state', () => { + const updates = { status: 'completed', phase: 2 }; + store.update(EXECUTION_ID, updates); + + const retrievedState = store.get(EXECUTION_ID); + expect(retrievedState.status).toBe('completed'); + expect(retrievedState.phase).toBe(2); + // Ensure other fields are preserved + expect(retrievedState.userAddress).toBe(MOCK_STATE.userAddress); + }); + + it('should throw an error when updating a non-existent execution', () => { + const updates = { status: 'failed' }; + expect(() => store.update('non-existent-id', updates)).toThrow( + 'Execution non-existent-id not found or expired (TTL: 1 minutes)' + ); + }); + + it('should throw an error when updating an expired execution', () => { + // Advance time past the TTL + jest.advanceTimersByTime(60 * 1000 + 1); + + const updates = { status: 'failed' }; + expect(() => store.update(EXECUTION_ID, updates)).toThrow( + `Execution ${EXECUTION_ID} not found or expired (TTL: 1 minutes)` + ); + }); + }); + + describe('TTL and Expiration', () => { + it('should return null and delete the state when getting an expired execution', () => { + store.set(EXECUTION_ID, MOCK_STATE); + expect(store.size()).toBe(1); + + // Advance time just past the TTL + jest.advanceTimersByTime(60 * 1000 + 1); + + // Getting the expired item should return null + expect(store.get(EXECUTION_ID)).toBeNull(); + + // The item should have been lazily deleted + expect(store.size()).toBe(0); + }); + + it('should retrieve an item just before it expires', () => { + store.set(EXECUTION_ID, MOCK_STATE); + + // Advance time to just before expiration + jest.advanceTimersByTime(60 * 1000 - 1); + + const state = store.get(EXECUTION_ID); + expect(state).not.toBeNull(); + expect(state.executionId).toBe(EXECUTION_ID); + }); + + it('should immediately expire items if TTL is 0', () => { + const zeroTtlStore = new PhasedExecutionStore(0); + zeroTtlStore.set(EXECUTION_ID, MOCK_STATE); + + // Advance time by a minimal amount + jest.advanceTimersByTime(1); + + expect(zeroTtlStore.get(EXECUTION_ID)).toBeNull(); + zeroTtlStore.destroy(); + }); + }); + + describe('Cleanup', () => { + it('should remove only expired entries during cleanup', () => { + store.set('exec-1', { ...MOCK_STATE }); // Will expire + store.set('exec-2', { ...MOCK_STATE }); // Will expire + + jest.advanceTimersByTime(30 * 1000); // 30 seconds pass + + store.set('exec-3', { ...MOCK_STATE }); // Will not expire yet + + jest.advanceTimersByTime(30 * 1000 + 1); // Total time > 60s, exec-1 and exec-2 expire + + // Manually trigger cleanup to test its logic in isolation + const removedCount = store._cleanup(); + + expect(removedCount).toBe(2); + expect(store.size()).toBe(1); + expect(store.get('exec-3')).not.toBeNull(); + expect(store.get('exec-1')).toBeNull(); + }); + + it('should not remove any entries if none are expired', () => { + store.set('exec-1', { ...MOCK_STATE }); + store.set('exec-2', { ...MOCK_STATE }); + + const removedCount = store._cleanup(); + expect(removedCount).toBe(0); + expect(store.size()).toBe(2); + }); + + it('should run cleanup automatically via setInterval', () => { + // Use a longer TTL to ensure items don't expire before the interval + store.destroy(); + store = new PhasedExecutionStore(4); // 4 minute TTL + + store.set('expired-item', { ...MOCK_STATE }); + + // Advance time past the 5-minute cleanup interval + jest.advanceTimersByTime(5 * 60 * 1000); + + // The item should have been removed by the automatic cleanup + expect(store.size()).toBe(0); + }); + }); + + describe('Lifecycle and Utilities', () => { + beforeEach(() => { + store.set('exec-1', { ...MOCK_STATE }); + store.set('exec-2', { ...MOCK_STATE }); + }); + + it('should delete an entry and return true', () => { + expect(store.size()).toBe(2); + const result = store.delete('exec-1'); + expect(result).toBe(true); + expect(store.size()).toBe(1); + expect(store.get('exec-1')).toBeNull(); + }); + + it('should return false when deleting a non-existent entry', () => { + const result = store.delete('non-existent'); + expect(result).toBe(false); + expect(store.size()).toBe(2); + }); + + it('should return the correct size', () => { + expect(store.size()).toBe(2); + store.set('exec-3', { ...MOCK_STATE }); + expect(store.size()).toBe(3); + }); + + it('should return all keys', () => { + const keys = store.keys(); + expect(keys).toHaveLength(2); + expect(keys).toContain('exec-1'); + expect(keys).toContain('exec-2'); + }); + + it('should clear all entries', () => { + expect(store.size()).toBe(2); + store.clear(); + expect(store.size()).toBe(0); + expect(store.keys()).toEqual([]); + }); + + it('should stop the cleanup interval and clear the store on destroy', () => { + const intervalId = store.cleanupInterval; + store.destroy(); + expect(clearInterval).toHaveBeenCalledWith(intervalId); + expect(store.cleanupInterval).toBeNull(); + expect(store.size()).toBe(0); + }); + }); + + describe('Statistics', () => { + it('should return correct stats for an empty store', () => { + const stats = store.getStats(); + expect(stats).toEqual({ + total: 0, + active: 0, + expired: 0, + ttlMinutes: 1, + }); + }); + + it('should return correct stats for active and expired items', () => { + store.set('expired-1', { ...MOCK_STATE }); + + // Advance time so expired-1 will be expired + jest.advanceTimersByTime(30 * 1000); + + // Create active-1 after some time has passed + store.set('active-1', { ...MOCK_STATE }); + + // Advance time to expire the first item only (30s + 31s = 61s total for expired-1, but only 31s for active-1) + jest.advanceTimersByTime(31 * 1000); + + const stats = store.getStats(); + expect(stats).toEqual({ + total: 2, + active: 1, + expired: 1, + ttlMinutes: 1, + }); + }); + }); +}); diff --git a/test/integration/phasedZap.integration.test.js b/test/integration/phasedZap.integration.test.js new file mode 100644 index 0000000..3cc9de0 --- /dev/null +++ b/test/integration/phasedZap.integration.test.js @@ -0,0 +1,396 @@ +// Setup mock instances FIRST +const mockSwapService = { + getSecondBestSwapQuote: jest.fn(), +}; + +const mockPriceService = { + getPrice: jest.fn(), +}; + +const mockBalanceService = { + getBalances: jest.fn(), +}; + +const mockRebalanceClient = { + getStrategyBalances: jest.fn(), + getSwapRoute: jest.fn(), +}; + +// Mock the services BEFORE importing anything else +jest.mock('../../src/services/swapService', () => { + return jest.fn().mockImplementation(() => mockSwapService); +}); + +jest.mock('../../src/services/priceService', () => { + return jest.fn().mockImplementation(() => mockPriceService); +}); + +jest.mock('../../src/services/balanceService', () => { + return jest.fn().mockImplementation(() => mockBalanceService); +}); + +jest.mock('../../src/services/RebalanceBackendClient', () => { + return jest.fn().mockImplementation(() => mockRebalanceClient); +}); + +const request = require('supertest'); +const express = require('express'); +const phasedZapRoutes = require('../../src/routes/phasedZapRoutes'); +const PhasedZapController = require('../../src/controllers/PhasedZapController'); + +// Setup Express app +const app = express(); +app.use(express.json()); +app.use(phasedZapRoutes); + +// Test constants +const MOCK_USER_ADDRESS = '0x2eCBC6f229feD06044CDb0dD772437a30190CD50'; +const MOCK_CHAIN_ID = 8453; +const MOCK_INPUT_TOKEN = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base +// Expected output tokens for stablecoin strategy on Base: +const MOCK_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC (for Aave + Velodrome) +const MOCK_BOLD_ADDRESS = '0x03569CC076654F82679C4BA2124D64774781B01D'; // BOLD (for Velodrome LP) + +describe('Phased Zap Execution - Integration Tests', () => { + let executor; + + beforeAll(() => { + // Get a handle on the executor instance to inspect its store + executor = PhasedZapController.getExecutor(); + }); + + beforeEach(() => { + // Reset mocks and clear the store before each test + jest.clearAllMocks(); + executor.phasedStore.clear(); + + // Default successful mock implementations + // CRITICAL: TransactionBuilder.addSwap() expects to, data, value at TOP LEVEL + mockSwapService.getSecondBestSwapQuote.mockResolvedValue({ + approve_to: '0x1111111254EEB25477B68fb85Ed929f73A960582', + minToAmount: '995000000000000000', // ~0.995 WETH + toAmount: '1000000000000000000', // 1 WETH + // Transaction fields at TOP LEVEL (not nested under 'tx') + to: '0x1111111254EEB25477B68fb85Ed929f73A960582', + data: '0xswapdata', + value: '0', + gas: 250000, + }); + + mockPriceService.getPrice.mockResolvedValue({ price: 3000 }); + + // Mock balance service to return balances for USDC and BOLD after swaps + // These are the tokens that will be held after Phase 1 swap transactions + mockBalanceService.getBalances.mockResolvedValue({ + balances: [ + { + tokenAddress: MOCK_USDC_ADDRESS, + balance: '500000000', // 500 USDC (6 decimals) + decimals: 6, + }, + { + tokenAddress: MOCK_BOLD_ADDRESS, + balance: '250000000000000000000', // 250 BOLD (18 decimals) + decimals: 18, + }, + ], + }); + }); + + afterAll(() => { + // Clean up the executor and its store + executor.destroy(); + }); + + describe('E2E Happy Path', () => { + it('should successfully complete a 2-phase execution flow', async () => { + // --- Phase 1: Initialize --- + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', // 1000 USDC + slippage: 0.5, + }, + }); + + // If init fails, show the actual error + if (initResponse.status !== 200) { + throw new Error(`Init failed with status ${initResponse.status}: ${JSON.stringify(initResponse.body)}`); + } + + // Assert Phase 1 response + expect(initResponse.body.success).toBe(true); + expect(initResponse.body.phase).toBe(1); + expect(initResponse.body.executionId).toBeDefined(); + expect(initResponse.body.transactions.length).toBeGreaterThan(0); + expect(initResponse.body.metadata.nextStep).toBeDefined(); + + const { executionId } = initResponse.body; + + // Assert state store after Phase 1 + const phase1State = executor.phasedStore.get(executionId); + expect(phase1State).toBeDefined(); + expect(phase1State.phase).toBe(1); + expect(phase1State.status).toBe('pending'); + expect(phase1State.swapTokenAddresses.length).toBeGreaterThan(0); + + // --- Phase 2: Continue --- + const continueResponse = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`); + + // Check for failures and provide detailed error + if (continueResponse.status !== 200) { + throw new Error(`Phase 2 failed: ${JSON.stringify(continueResponse.body, null, 2)}`); + } + + // Assert Phase 2 response + expect(continueResponse.body.success).toBe(true); + expect(continueResponse.body.phase).toBe(2); + expect(continueResponse.body.executionId).toBe(executionId); + expect(continueResponse.body.transactions.length).toBeGreaterThan(0); // Approvals + Deposits + expect(continueResponse.body.actualBalances).toBeDefined(); + + // Verify balances were queried + expect(mockBalanceService.getBalances).toHaveBeenCalledWith( + MOCK_USER_ADDRESS, + expect.objectContaining({ + chainId: MOCK_CHAIN_ID, + skipCache: true, + }) + ); + }); + }); + + describe('State Management and Error Handling', () => { + let executionId; + + beforeEach(async () => { + // Create a pending execution for error tests + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + executionId = res.body.executionId; + }); + + it('should return 404 for a non-existent executionId', async () => { + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/continue/exec_invalid_id') + .expect(404); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + }); + + it('should return 400 if post-swap balances are zero', async () => { + // Mock balance service to return zero balance + mockBalanceService.getBalances.mockResolvedValue({ + balances: [ + { + tokenAddress: MOCK_USDC_ADDRESS, + balance: '0', + decimals: 6, + }, + { + tokenAddress: MOCK_BOLD_ADDRESS, + balance: '0', + decimals: 18, + }, + ], + }); + + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INSUFFICIENT_BALANCES'); + expect(res.body.error.message).toContain('balances'); + }); + + it('should handle errors from the balance service gracefully', async () => { + // Mock balance service to throw an error + const errorMessage = 'Moralis API is down'; + mockBalanceService.getBalances.mockRejectedValue( + new Error(errorMessage) + ); + + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(500); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBeDefined(); + }); + + it('should return 400 for missing required parameters in init', async () => { + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + // Missing userAddress + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000', + }, + }) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INVALID_INPUT'); + }); + + it('should return 400 for missing executionId in continue', async () => { + const res = await request(app) + .post('/api/v1/intents/unified-zap/phased/continue/') + .expect(404); // Express returns 404 for missing route param + + // This tests that the route is properly configured + }); + }); + + describe('TTL and Expiration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return 404 when trying to continue an expired execution', async () => { + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + + const { executionId } = initResponse.body; + + // Advance time past the 30-minute TTL + jest.advanceTimersByTime(31 * 60 * 1000); + + // Attempt to continue + const res = await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(404); + + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + expect(res.body.error.message).toContain('not found or expired'); + }); + }); + + describe('GET /api/v1/intents/unified-zap/phased/status/:executionId', () => { + it('should return the correct status after phase 1 initialization', async () => { + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + const { executionId } = initResponse.body; + + const statusResponse = await request(app) + .get(`/api/v1/intents/unified-zap/phased/status/${executionId}`) + .expect(200); + + expect(statusResponse.body.success).toBe(true); + expect(statusResponse.body.executionId).toBe(executionId); + expect(statusResponse.body.phase).toBe(1); + expect(statusResponse.body.status).toBe('pending'); + expect(statusResponse.body.metadata.userAddress).toBe(MOCK_USER_ADDRESS); + expect(statusResponse.body.metadata.chainId).toBe(MOCK_CHAIN_ID.toString()); + expect(statusResponse.body.metadata.createdAt).toBeDefined(); + expect(statusResponse.body.metadata.expiresAt).toBeDefined(); + }); + + it('should return 404 for a non-existent execution status', async () => { + const res = await request(app) + .get('/api/v1/intents/unified-zap/phased/status/exec_invalid_id') + .expect(404); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXECUTION_NOT_FOUND'); + }); + + it('should return updated status after phase 2', async () => { + // Initialize + const initResponse = await request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: '1000000000', + }, + }); + const { executionId } = initResponse.body; + + // Continue to phase 2 + await request(app) + .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) + .expect(200); + + // Check status + const statusResponse = await request(app) + .get(`/api/v1/intents/unified-zap/phased/status/${executionId}`) + .expect(200); + + expect(statusResponse.body.phase).toBe(2); + expect(statusResponse.body.metadata.actualBalances).toBeDefined(); + }); + }); + + describe('Store Statistics', () => { + it('should track multiple concurrent executions', async () => { + // Create 3 executions + const requests = Array.from({ length: 3 }, (_, i) => + request(app) + .post('/api/v1/intents/unified-zap/phased/init') + .send({ + userAddress: MOCK_USER_ADDRESS, + chainId: MOCK_CHAIN_ID, + params: { + strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + inputToken: MOCK_INPUT_TOKEN, + inputAmount: String(1000000000 * (i + 1)), + }, + }) + ); + + await Promise.all(requests); + + // Check store stats + const stats = PhasedZapController.getStoreStats(); + expect(stats.total).toBe(3); + expect(stats.active).toBe(3); + expect(stats.expired).toBe(0); + }); + }); +}); From a8e0acb33dd3f83a002fb28bec93a8f3530edd0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Thu, 9 Oct 2025 08:45:05 +0900 Subject: [PATCH 07/29] test: increase coverage --- src/controllers/PhasedZapController.js | 2 +- src/executors/PhasedExecutionStore.js | 13 +- src/executors/UnifiedZapExecutor.js | 73 +- src/services/balanceService.js | 2 +- .../priceService/PriceCache.enhanced.js | 22 +- src/utils/balanceCache.enhanced.js | 36 +- src/utils/balanceCache.js | 2 +- test/PhasedExecutionStore.test.js | 11 +- .../integration/phasedZap.integration.test.js | 139 +- test/intents.extra.test.js | 5 + test/intents/UnifiedZapIntentHandler.test.js | 273 ++++ test/middleware/balanceRateLimit.test.js | 758 +++++++++++ test/routes/balances.test.js | 96 ++ test/routes/tokens.test.js | 124 ++ .../priceService/PriceCache.enhanced.test.js | 149 +++ test/utils/balanceCache.enhanced.test.js | 1188 +++++++++++++++++ test/validators/balanceValidator.test.js | 82 ++ 17 files changed, 2897 insertions(+), 78 deletions(-) create mode 100644 test/intents/UnifiedZapIntentHandler.test.js create mode 100644 test/middleware/balanceRateLimit.test.js create mode 100644 test/routes/balances.test.js create mode 100644 test/routes/tokens.test.js create mode 100644 test/services/priceService/PriceCache.enhanced.test.js create mode 100644 test/utils/balanceCache.enhanced.test.js create mode 100644 test/validators/balanceValidator.test.js diff --git a/src/controllers/PhasedZapController.js b/src/controllers/PhasedZapController.js index cec3f22..9f080d5 100644 --- a/src/controllers/PhasedZapController.js +++ b/src/controllers/PhasedZapController.js @@ -193,7 +193,7 @@ class PhasedZapController { * @param {Object} req - Express request * @param {Object} res - Express response */ - static async getStatus(req, res) { + static getStatus(req, res) { try { const { executionId } = req.params; diff --git a/src/executors/PhasedExecutionStore.js b/src/executors/PhasedExecutionStore.js index 732a1fb..2032093 100644 --- a/src/executors/PhasedExecutionStore.js +++ b/src/executors/PhasedExecutionStore.js @@ -141,7 +141,7 @@ class PhasedExecutionStore { } if (removed > 0) { - console.log( + console.warn( `[PhasedExecutionStore] Cleaned up ${removed} expired execution(s)` ); } @@ -156,9 +156,12 @@ class PhasedExecutionStore { */ _startCleanupInterval() { // Run cleanup every 5 minutes - const interval = setInterval(() => { - this._cleanup(); - }, 5 * 60 * 1000); + const interval = setInterval( + () => { + this._cleanup(); + }, + 5 * 60 * 1000 + ); // Allow process to exit even if interval is running if (interval.unref) { @@ -179,7 +182,7 @@ class PhasedExecutionStore { } this.store.clear(); - console.log('[PhasedExecutionStore] Destroyed and cleared'); + console.warn('[PhasedExecutionStore] Destroyed and cleared'); } /** diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index 6927ad7..b86ada1 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -11,7 +11,12 @@ const TransactionBuilder = require('../transactions/TransactionBuilder'); const PhasedExecutionStore = require('./PhasedExecutionStore'); class UnifiedZapExecutor { - constructor(swapService, priceService, rebalanceClient, balanceService = null) { + constructor( + swapService, + priceService, + rebalanceClient, + balanceService = null + ) { this.swapService = swapService; this.priceService = priceService; this.rebalanceClient = rebalanceClient; @@ -947,7 +952,10 @@ class UnifiedZapExecutor { return fallbackSymbol || null; } - const tokenMeta = TokenConfigService.getTokenByAddress(chainId, tokenAddress); + const tokenMeta = TokenConfigService.getTokenByAddress( + chainId, + tokenAddress + ); if (tokenMeta?.symbol) { return tokenMeta.symbol; } @@ -1001,7 +1009,10 @@ class UnifiedZapExecutor { try { return BigInt(candidate.toString()); } catch (error) { - console.warn('Failed to parse swap output amount, defaulting to 0:', error); + console.warn( + 'Failed to parse swap output amount, defaulting to 0:', + error + ); return 0n; } } @@ -1105,14 +1116,12 @@ class UnifiedZapExecutor { const executionContext = await this.prepareExecutionContext(request); // Generate ONLY swap transactions (Phase 1) - const swapTransactions = await this._generateSwapTransactionsOnly( - executionContext - ); + const swapTransactions = + await this._generateSwapTransactionsOnly(executionContext); // Determine which token addresses to query after swaps - const swapTokenAddresses = this._extractSwapOutputTokens( - executionContext - ); + const swapTokenAddresses = + this._extractSwapOutputTokens(executionContext); // Store state for Phase 2 this.phasedStore.set(executionId, { @@ -1193,9 +1202,7 @@ class UnifiedZapExecutor { this._validateActualBalances(actualBalances, state.swapTokenAddresses); // 4. Deserialize execution context - const executionContext = this._deserializeContext( - state.executionContext - ); + const executionContext = this._deserializeContext(state.executionContext); // 5. Generate deposit transactions with ACTUAL balances const depositTransactions = @@ -1212,7 +1219,7 @@ class UnifiedZapExecutor { }); // Get updated state for metadata - const updatedState = this.phasedStore.get(executionId); + const _updatedState = this.phasedStore.get(executionId); return { executionId, @@ -1239,7 +1246,14 @@ class UnifiedZapExecutor { * @private */ async _generateSwapTransactionsOnly(executionContext) { - const { protocolAllocations, userAddress, inputToken, inputTokenDecimals, slippage, tokenPrices } = executionContext; + const { + protocolAllocations, + userAddress, + inputToken, + inputTokenDecimals, + slippage, + tokenPrices, + } = executionContext; const transactions = []; for (const protocolAllocation of protocolAllocations) { @@ -1263,7 +1277,10 @@ class UnifiedZapExecutor { tokenPrices, }); - if (swapResult.swapTransactions && swapResult.swapTransactions.length > 0) { + if ( + swapResult.swapTransactions && + swapResult.swapTransactions.length > 0 + ) { transactions.push(...swapResult.swapTransactions); } } else if (tokenRequirements.mode === 'LP') { @@ -1279,7 +1296,10 @@ class UnifiedZapExecutor { tokenPrices, }); - if (lpSwapResult.swapTransactions && lpSwapResult.swapTransactions.length > 0) { + if ( + lpSwapResult.swapTransactions && + lpSwapResult.swapTransactions.length > 0 + ) { transactions.push(...lpSwapResult.swapTransactions); } } @@ -1299,7 +1319,10 @@ class UnifiedZapExecutor { * Generate deposit transactions using ACTUAL on-chain balances (Phase 2) * @private */ - async _generateDepositTransactionsWithBalances(executionContext, actualBalances) { + async _generateDepositTransactionsWithBalances( + executionContext, + actualBalances + ) { const { protocolAllocations, userAddress, slippage } = executionContext; const transactions = []; @@ -1551,21 +1574,29 @@ class UnifiedZapExecutor { // Restore amount BigInt const restoredProto = { ...proto, - amount: typeof proto.amount === 'string' ? BigInt(proto.amount) : proto.amount, + amount: + typeof proto.amount === 'string' + ? BigInt(proto.amount) + : proto.amount, }; // Restore protocol instance from factory - if (proto.id && !proto.instance) { + // Always recreate the instance from factory, even if instance exists (it may be serialized empty object) + if (proto.config) { try { restoredProto.instance = this.protocolFactory.createProtocol( - proto.id, - chainName + proto.config, + chainName, + proto.chainId ); } catch (error) { console.warn( `Failed to restore protocol instance for ${proto.id}: ${error.message}` ); } + } else if (proto.instance) { + // Instance exists but no config - use it as-is (shouldn't happen) + restoredProto.instance = proto.instance; } return restoredProto; diff --git a/src/services/balanceService.js b/src/services/balanceService.js index 0f83814..4e4e150 100644 --- a/src/services/balanceService.js +++ b/src/services/balanceService.js @@ -269,7 +269,7 @@ class BalanceService { console.warn('[BalanceService] Rate limit hit, retrying...'); return true; } - console.log(`[BalanceService] Not retrying HTTP ${error.status}`); + console.warn(`[BalanceService] Not retrying HTTP ${error.status}`); return false; } diff --git a/src/services/priceService/PriceCache.enhanced.js b/src/services/priceService/PriceCache.enhanced.js index 24a9671..838217e 100644 --- a/src/services/priceService/PriceCache.enhanced.js +++ b/src/services/priceService/PriceCache.enhanced.js @@ -188,17 +188,17 @@ class PriceCache { logHealthReport() { const stats = this.getStats(); - console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(' Price Cache Health Report'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(` Cache Size: ${stats.size} tokens`); - console.log(` Hit Rate: ${stats.hitRate}`); - console.log(` Avg Cache Age: ${stats.avgCacheAge}`); - console.log(` Stale Hit Rate: ${stats.staleHitRate}`); - console.log(` Total Hits: ${stats.hits}`); - console.log(` Total Misses: ${stats.misses}`); - console.log(` Total Sets: ${stats.sets}`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + console.warn('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn(' Price Cache Health Report'); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn(` Cache Size: ${stats.size} tokens`); + console.warn(` Hit Rate: ${stats.hitRate}`); + console.warn(` Avg Cache Age: ${stats.avgCacheAge}`); + console.warn(` Stale Hit Rate: ${stats.staleHitRate}`); + console.warn(` Total Hits: ${stats.hits}`); + console.warn(` Total Misses: ${stats.misses}`); + console.warn(` Total Sets: ${stats.sets}`); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); } } diff --git a/src/utils/balanceCache.enhanced.js b/src/utils/balanceCache.enhanced.js index 197d99c..f1cd7c7 100644 --- a/src/utils/balanceCache.enhanced.js +++ b/src/utils/balanceCache.enhanced.js @@ -53,7 +53,7 @@ class BalanceCacheMonitor { recordInvalidation(reason) { this.metrics.invalidations++; - console.log(`[BalanceCache] Invalidated: ${reason}`); + console.warn(`[BalanceCache] Invalidated: ${reason}`); } recordPartialUpdate() { @@ -219,7 +219,7 @@ class BalanceCache { }); this.monitor.recordPartialUpdate(); - console.log( + console.warn( `[BalanceCache] Partial update: ${tokenAddress} on chain ${chainId}` ); } @@ -270,7 +270,7 @@ class BalanceCache { this.set(cacheKey, updated); this.monitor.recordPartialUpdate(); - console.log( + console.warn( `[BalanceCache] Refreshed ${tokenAddresses.length} tokens on chain ${chainId}` ); return updated; @@ -388,7 +388,7 @@ class BalanceCache { this.cleanupInterval = setInterval(() => { const cleaned = this.cleanup(); if (cleaned > 0) { - console.log(`[BalanceCache] Cleaned up ${cleaned} expired entries`); + console.warn(`[BalanceCache] Cleaned up ${cleaned} expired entries`); } }, interval); @@ -448,20 +448,20 @@ class BalanceCache { logHealthReport() { const stats = this.getStats(); - console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(' Balance Cache Health Report'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(` Cache Size: ${stats.size} entries`); - console.log(` Hit Rate: ${stats.hitRate}`); - console.log(` Avg Cache Age: ${stats.avgCacheAge}`); - console.log(` Stale Hit Rate: ${stats.staleHitRate}`); - console.log(` Total Hits: ${stats.hits}`); - console.log(` Total Misses: ${stats.misses}`); - console.log(` Partial Updates: ${stats.partialUpdates}`); - console.log(` Invalidations: ${stats.invalidations}`); - console.log(` Memory Usage: ${stats.memoryUsage}`); - console.log(` TTL: ${stats.defaultTTL / 1000}s`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + console.warn('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn(' Balance Cache Health Report'); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn(` Cache Size: ${stats.size} entries`); + console.warn(` Hit Rate: ${stats.hitRate}`); + console.warn(` Avg Cache Age: ${stats.avgCacheAge}`); + console.warn(` Stale Hit Rate: ${stats.staleHitRate}`); + console.warn(` Total Hits: ${stats.hits}`); + console.warn(` Total Misses: ${stats.misses}`); + console.warn(` Partial Updates: ${stats.partialUpdates}`); + console.warn(` Invalidations: ${stats.invalidations}`); + console.warn(` Memory Usage: ${stats.memoryUsage}`); + console.warn(` TTL: ${stats.defaultTTL / 1000}s`); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); } } diff --git a/src/utils/balanceCache.js b/src/utils/balanceCache.js index 0726846..c11ba5b 100644 --- a/src/utils/balanceCache.js +++ b/src/utils/balanceCache.js @@ -180,7 +180,7 @@ class BalanceCache { this.cleanupInterval = setInterval(() => { const cleaned = this.cleanup(); if (cleaned > 0) { - console.log(`[BalanceCache] Cleaned up ${cleaned} expired entries`); + console.warn(`[BalanceCache] Cleaned up ${cleaned} expired entries`); } }, interval); diff --git a/test/PhasedExecutionStore.test.js b/test/PhasedExecutionStore.test.js index 1432cd2..c240014 100644 --- a/test/PhasedExecutionStore.test.js +++ b/test/PhasedExecutionStore.test.js @@ -43,7 +43,7 @@ describe('PhasedExecutionStore', () => { if (jest.isMockFunction(global.clearInterval)) { global.clearInterval.mockRestore(); } - } catch (e) { + } catch (_e) { // Mocks already restored or not needed } }); @@ -71,7 +71,10 @@ describe('PhasedExecutionStore', () => { expect(store.cleanupInterval).not.toBeNull(); // Check that setInterval has been called expect(setInterval).toHaveBeenCalledTimes(1); - expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 5 * 60 * 1000); + expect(setInterval).toHaveBeenCalledWith( + expect.any(Function), + 5 * 60 * 1000 + ); }); }); @@ -84,7 +87,9 @@ describe('PhasedExecutionStore', () => { expect(retrievedState.phase).toBe(MOCK_STATE.phase); expect(retrievedState.executionId).toBe(EXECUTION_ID); expect(retrievedState.createdAt).toBeDefined(); - expect(retrievedState.expiresAt).toBe(retrievedState.createdAt + 60 * 1000); + expect(retrievedState.expiresAt).toBe( + retrievedState.createdAt + 60 * 1000 + ); }); it('should return null for a non-existent execution ID', () => { diff --git a/test/integration/phasedZap.integration.test.js b/test/integration/phasedZap.integration.test.js index 3cc9de0..d4a28a6 100644 --- a/test/integration/phasedZap.integration.test.js +++ b/test/integration/phasedZap.integration.test.js @@ -16,6 +16,23 @@ const mockRebalanceClient = { getSwapRoute: jest.fn(), }; +// Mock protocol instance with mocked transaction methods +const mockProtocolInstance = { + getApprovalTransaction: jest.fn(), + getDepositTransaction: jest.fn(), + getTokenRequirements: jest.fn(), + config: { + mode: 'single', + symbolOfBestTokenToZapInOut: 'USDC', + }, +}; + +// Mock ProtocolFactory +const mockProtocolFactory = { + createProtocol: jest.fn(), + createProtocolsForStrategy: jest.fn(), +}; + // Mock the services BEFORE importing anything else jest.mock('../../src/services/swapService', () => { return jest.fn().mockImplementation(() => mockSwapService); @@ -33,6 +50,12 @@ jest.mock('../../src/services/RebalanceBackendClient', () => { return jest.fn().mockImplementation(() => mockRebalanceClient); }); +jest.mock('../../src/protocols', () => { + return { + protocolFactory: mockProtocolFactory, + }; +}); + const request = require('supertest'); const express = require('express'); const phasedZapRoutes = require('../../src/routes/phasedZapRoutes'); @@ -64,6 +87,9 @@ describe('Phased Zap Execution - Integration Tests', () => { jest.clearAllMocks(); executor.phasedStore.clear(); + // Replace executor's protocolFactory with our mock + executor.protocolFactory = mockProtocolFactory; + // Default successful mock implementations // CRITICAL: TransactionBuilder.addSwap() expects to, data, value at TOP LEVEL mockSwapService.getSecondBestSwapQuote.mockResolvedValue({ @@ -95,6 +121,67 @@ describe('Phased Zap Execution - Integration Tests', () => { }, ], }); + + // Mock protocol instance methods + mockProtocolInstance.getApprovalTransaction.mockResolvedValue({ + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xapprovaldata', + value: '0', + gasLimit: null, + description: 'Approve USDC for protocol', + }); + + mockProtocolInstance.getDepositTransaction.mockResolvedValue({ + to: '0xProtocolAddress', + data: '0xdepositdata', + value: '0', + gasLimit: null, + description: 'Deposit to protocol', + }); + + mockProtocolInstance.getTokenRequirements.mockReturnValue({ + mode: 'single', + inputToken: MOCK_USDC_ADDRESS, + outputToken: MOCK_USDC_ADDRESS, + requiresSwap: true, + protocolSpecific: { + underlyingToken: MOCK_USDC_ADDRESS, + protocolAddress: '0xProtocolAddress', + poolAddress: '0xPoolAddress', + }, + }); + + // Mock protocol factory to return mock protocol instance + mockProtocolFactory.createProtocol.mockReturnValue(mockProtocolInstance); + + mockProtocolFactory.createProtocolsForStrategy.mockReturnValue([ + { + id: 'aave-usdc-base', + name: 'Aave USDC', + weight: 50, + chain: 'base', + chainId: MOCK_CHAIN_ID, + instance: mockProtocolInstance, + config: { + id: 'aave-usdc-base', + implementation: 'AaveProtocol', + config: mockProtocolInstance.config, + }, + }, + { + id: 'velodrome-usdc-bold-base', + name: 'Velodrome USDC/BOLD', + weight: 50, + chain: 'base', + chainId: MOCK_CHAIN_ID, + instance: mockProtocolInstance, + config: { + id: 'velodrome-usdc-bold-base', + implementation: 'VelodromeProtocol', + config: mockProtocolInstance.config, + }, + }, + ]); }); afterAll(() => { @@ -111,7 +198,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000000000', // 1000 USDC slippage: 0.5, @@ -120,7 +209,9 @@ describe('Phased Zap Execution - Integration Tests', () => { // If init fails, show the actual error if (initResponse.status !== 200) { - throw new Error(`Init failed with status ${initResponse.status}: ${JSON.stringify(initResponse.body)}`); + throw new Error( + `Init failed with status ${initResponse.status}: ${JSON.stringify(initResponse.body)}` + ); } // Assert Phase 1 response @@ -140,12 +231,15 @@ describe('Phased Zap Execution - Integration Tests', () => { expect(phase1State.swapTokenAddresses.length).toBeGreaterThan(0); // --- Phase 2: Continue --- - const continueResponse = await request(app) - .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`); + const continueResponse = await request(app).post( + `/api/v1/intents/unified-zap/phased/continue/${executionId}` + ); // Check for failures and provide detailed error if (continueResponse.status !== 200) { - throw new Error(`Phase 2 failed: ${JSON.stringify(continueResponse.body, null, 2)}`); + throw new Error( + `Phase 2 failed: ${JSON.stringify(continueResponse.body, null, 2)}` + ); } // Assert Phase 2 response @@ -159,7 +253,6 @@ describe('Phased Zap Execution - Integration Tests', () => { expect(mockBalanceService.getBalances).toHaveBeenCalledWith( MOCK_USER_ADDRESS, expect.objectContaining({ - chainId: MOCK_CHAIN_ID, skipCache: true, }) ); @@ -177,7 +270,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000000000', }, @@ -223,9 +318,7 @@ describe('Phased Zap Execution - Integration Tests', () => { it('should handle errors from the balance service gracefully', async () => { // Mock balance service to throw an error const errorMessage = 'Moralis API is down'; - mockBalanceService.getBalances.mockRejectedValue( - new Error(errorMessage) - ); + mockBalanceService.getBalances.mockRejectedValue(new Error(errorMessage)); const res = await request(app) .post(`/api/v1/intents/unified-zap/phased/continue/${executionId}`) @@ -242,7 +335,9 @@ describe('Phased Zap Execution - Integration Tests', () => { // Missing userAddress chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000', }, @@ -254,7 +349,7 @@ describe('Phased Zap Execution - Integration Tests', () => { }); it('should return 400 for missing executionId in continue', async () => { - const res = await request(app) + const _res = await request(app) .post('/api/v1/intents/unified-zap/phased/continue/') .expect(404); // Express returns 404 for missing route param @@ -278,7 +373,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000000000', }, @@ -307,7 +404,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000000000', }, @@ -323,7 +422,9 @@ describe('Phased Zap Execution - Integration Tests', () => { expect(statusResponse.body.phase).toBe(1); expect(statusResponse.body.status).toBe('pending'); expect(statusResponse.body.metadata.userAddress).toBe(MOCK_USER_ADDRESS); - expect(statusResponse.body.metadata.chainId).toBe(MOCK_CHAIN_ID.toString()); + expect(statusResponse.body.metadata.chainId).toBe( + MOCK_CHAIN_ID.toString() + ); expect(statusResponse.body.metadata.createdAt).toBeDefined(); expect(statusResponse.body.metadata.expiresAt).toBeDefined(); }); @@ -345,7 +446,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: '1000000000', }, @@ -377,7 +480,9 @@ describe('Phased Zap Execution - Integration Tests', () => { userAddress: MOCK_USER_ADDRESS, chainId: MOCK_CHAIN_ID, params: { - strategyAllocations: [{ strategyId: 'stablecoin', percentage: 100 }], + strategyAllocations: [ + { strategyId: 'stablecoin', percentage: 100 }, + ], inputToken: MOCK_INPUT_TOKEN, inputAmount: String(1000000000 * (i + 1)), }, diff --git a/test/intents.extra.test.js b/test/intents.extra.test.js index ffada16..add17e6 100644 --- a/test/intents.extra.test.js +++ b/test/intents.extra.test.js @@ -26,6 +26,11 @@ jest.mock('../src/controllers/VaultController', () => ({ })); describe('Intents Routes Extra Coverage', () => { + // Ensure module isolation to prevent test pollution from other test files + beforeAll(() => { + jest.resetModules(); + }); + beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); // Clears the module cache diff --git a/test/intents/UnifiedZapIntentHandler.test.js b/test/intents/UnifiedZapIntentHandler.test.js new file mode 100644 index 0000000..e441edb --- /dev/null +++ b/test/intents/UnifiedZapIntentHandler.test.js @@ -0,0 +1,273 @@ +/** + * UnifiedZapIntentHandler Unit Tests + */ + +jest.mock('../../src/validators/UnifiedZapValidator'); +jest.mock('../../src/utils/intentIdGenerator'); +jest.mock('../../src/managers/ExecutionContextManager'); + +const UnifiedZapIntentHandler = require('../../src/intents/UnifiedZapIntentHandler'); +const UnifiedZapValidator = require('../../src/validators/UnifiedZapValidator'); +const IntentIdGenerator = require('../../src/utils/intentIdGenerator'); + +describe('UnifiedZapIntentHandler', () => { + let handler; + let mockSwapService; + let mockPriceService; + let mockRebalanceClient; + let mockExecutor; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSwapService = {}; + mockPriceService = {}; + mockRebalanceClient = {}; + + handler = new UnifiedZapIntentHandler( + mockSwapService, + mockPriceService, + mockRebalanceClient + ); + + // Mock executor + mockExecutor = { + prepareExecutionContext: jest.fn(), + generateTransactions: jest.fn(), + estimateGas: jest.fn(), + assembleFinalTransactions: jest.fn(), + estimateProcessingDuration: jest.fn().mockReturnValue(30), + }; + handler.executor = mockExecutor; + }); + + describe('constructor', () => { + test('should initialize with context manager', () => { + expect(handler.contextManager).toBeDefined(); + }); + + test('should initialize executor', () => { + expect(handler.executor).toBeDefined(); + }); + }); + + describe('validate', () => { + test('should call UnifiedZapValidator.validate', () => { + const request = { type: 'unifiedZap' }; + handler.validate(request); + + expect(UnifiedZapValidator.validate).toHaveBeenCalledWith( + request, + expect.anything() + ); + }); + }); + + describe('execute', () => { + test('should prepare execution context and return SSE response', async () => { + const request = { + type: 'unifiedZap', + userAddress: '0xUser', + strategies: [], + }; + + const mockContext = { + strategyAllocations: [{ strategy: 'test' }], + protocolAllocations: [{ protocol: 'aave' }], + userAddress: '0xUser', + }; + + mockExecutor.prepareExecutionContext.mockResolvedValue(mockContext); + IntentIdGenerator.generate.mockReturnValue('intent_123'); + + const result = await handler.execute(request); + + expect(mockExecutor.prepareExecutionContext).toHaveBeenCalledWith(request); + expect(result).toMatchObject({ + success: true, + intentType: 'unifiedZap', + mode: 'streaming', + intentId: 'intent_123', + }); + }); + + test('should throw error if validation fails', async () => { + UnifiedZapValidator.validate.mockImplementation(() => { + throw new Error('Validation failed'); + }); + + await expect(handler.execute({})).rejects.toThrow('Validation failed'); + }); + + test('should handle executor errors', async () => { + UnifiedZapValidator.validate.mockImplementation(() => {}); // Don't throw + mockExecutor.prepareExecutionContext.mockRejectedValue( + new Error('Executor error') + ); + + await expect(handler.execute({ userAddress: '0x123' })).rejects.toThrow( + 'Executor error' + ); + }); + }); + + describe('buildSSEResponse', () => { + test('should build SSE response with metadata', () => { + const executionContext = { + strategyAllocations: [{ id: 1 }, { id: 2 }], + protocolAllocations: [{ chainId: 1 }, { chainId: 137 }], + userAddress: '0xUser', + }; + + IntentIdGenerator.generate.mockReturnValue('intent_456'); + + const result = handler.buildSSEResponse(executionContext); + + expect(result).toMatchObject({ + success: true, + intentType: 'unifiedZap', + mode: 'streaming', + intentId: 'intent_456', + streamUrl: '/api/unifiedzap/intent_456/stream', + }); + + expect(result.metadata).toMatchObject({ + totalStrategies: 2, + totalProtocols: 2, + streamingEnabled: true, + }); + }); + }); + + describe('processWithSSEStreaming', () => { + test('should process with progress events', async () => { + const executionContext = { + strategyAllocations: [], + protocolAllocations: [], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockResolvedValue([]); + mockExecutor.estimateGas.mockResolvedValue([]); + mockExecutor.assembleFinalTransactions.mockResolvedValue([]); + + await handler.processWithSSEStreaming(executionContext, streamWriter); + + expect(streamWriter).toHaveBeenCalled(); + expect(mockExecutor.generateTransactions).toHaveBeenCalled(); + }); + + test('should emit phase progress events', async () => { + const executionContext = { + strategyAllocations: [{ id: 1 }], + protocolAllocations: [{ id: 1 }], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockResolvedValue([{ tx: 1 }]); + mockExecutor.estimateGas.mockResolvedValue([{ gas: 1000 }]); + mockExecutor.assembleFinalTransactions.mockResolvedValue([{ tx: 1 }]); + + await handler.processWithSSEStreaming(executionContext, streamWriter); + + const calls = streamWriter.mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); + + test('should handle errors in streaming', async () => { + const executionContext = { + strategyAllocations: [], + protocolAllocations: [], + }; + + const streamWriter = jest.fn(); + mockExecutor.generateTransactions.mockRejectedValue(new Error('TX error')); + + await expect( + handler.processWithSSEStreaming(executionContext, streamWriter) + ).rejects.toThrow('TX error'); + }); + }); + + describe('_getUniqueChains', () => { + test('should extract unique chain names', () => { + const protocolAllocations = [ + { chain: 'ethereum' }, + { chain: 'polygon' }, + { chain: 'ethereum' }, + ]; + + const chains = handler._getUniqueChains(protocolAllocations); + expect(chains).toHaveLength(2); + expect(chains).toContain('ethereum'); + expect(chains).toContain('polygon'); + }); + }); + + describe('_countRequiredSwaps', () => { + test('should count protocols requiring swaps', () => { + const protocolAllocations = [ + { requiresSwap: true }, + { requiresSwap: false }, + { requiresSwap: true }, + ]; + + const count = handler._countRequiredSwaps(protocolAllocations); + expect(count).toBe(2); + }); + + test('should return 0 for no swaps', () => { + const count = handler._countRequiredSwaps([]); + expect(count).toBe(0); + }); + }); + + describe('context management', () => { + test('should store execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'storeExecutionContext'); + + const context = { + strategyAllocations: [], + protocolAllocations: [], + userAddress: '0xUser', + }; + + IntentIdGenerator.generate.mockReturnValue('intent_789'); + handler.buildSSEResponse(context); + + expect(spy).toHaveBeenCalledWith('intent_789', context); + }); + + test('should retrieve execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'getExecutionContext'); + handler.getExecutionContext('intent_id'); + + expect(spy).toHaveBeenCalledWith('intent_id'); + }); + + test('should remove execution context', () => { + const spy = jest.spyOn(handler.contextManager, 'removeExecutionContext'); + handler.removeExecutionContext('intent_id'); + + expect(spy).toHaveBeenCalledWith('intent_id'); + }); + }); + + describe('getStatus', () => { + test('should return handler status', () => { + jest.spyOn(handler.contextManager, 'getStatus').mockReturnValue({ active: 1 }); + handler.contextManager.executionContexts = new Map(); + + const status = handler.getStatus(); + expect(status).toBeDefined(); + expect(status).toHaveProperty('contextManager'); + expect(status).toHaveProperty('executor'); + }); + }); + + describe('cleanup', () => { + test('should cleanup resources', () => { + expect(() => handler.cleanup()).not.toThrow(); + }); + }); +}); diff --git a/test/middleware/balanceRateLimit.test.js b/test/middleware/balanceRateLimit.test.js new file mode 100644 index 0000000..754847c --- /dev/null +++ b/test/middleware/balanceRateLimit.test.js @@ -0,0 +1,758 @@ +const balanceRateLimit = require('../../src/middleware/balanceRateLimit'); +const { RateLimiter, rateLimiter: singletonRateLimiter } = balanceRateLimit; + +// Helper to create mock Express req/res/next objects +const makeMockReqRes = (walletSource = {}) => { + const req = { + query: walletSource.query || {}, + body: walletSource.body || {}, + }; + const res = { + headers: {}, + statusCode: 200, + jsonData: null, + set(header, value) { + if (typeof header === 'object') { + for (const key in header) { + this.headers[key.toLowerCase()] = header[key]; + } + } else { + this.headers[header.toLowerCase()] = value; + } + return this; + }, + status(code) { + this.statusCode = code; + return this; + }, + json(data) { + this.jsonData = data; + return this; + }, + }; + const next = jest.fn(); + return { req, res, next }; +}; + +describe('balanceRateLimit', () => { + let limiter; + const wallet1 = '0x1234567890123456789012345678901234567890'; + const wallet2 = '0xabcDEF0123456789012345678901234567890abc'; + + beforeEach(() => { + // Use fake timers to control time-based logic like window resets + jest.useFakeTimers(); + // Create a new RateLimiter instance for each test to ensure isolation + limiter = new RateLimiter(); + }); + + afterEach(() => { + // Clean up the limiter instance + limiter.destroy(); + // Clean up the singleton instance and restore real timers + singletonRateLimiter.destroy(); + jest.useRealTimers(); + }); + + describe('RateLimiter Class', () => { + describe('Constructor', () => { + it('should initialize with empty walletLimits Map', () => { + expect(limiter.walletLimits).toBeInstanceOf(Map); + expect(limiter.walletLimits.size).toBe(0); + }); + + it('should initialize globalLimit with count 0', () => { + expect(limiter.globalLimit.count).toBe(0); + expect(limiter.globalLimit.resetAt).toBeGreaterThan(Date.now()); + }); + + it('should set up cleanup interval', () => { + expect(limiter.cleanupInterval).toBeDefined(); + }); + }); + + describe('checkLimit() - Per-wallet limit', () => { + it('should allow the first request for a wallet', () => { + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(true); + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(1); + expect(status.globalCount).toBe(1); + }); + + it('should handle wallet addresses case-insensitively', () => { + const walletUpper = '0X1234567890123456789012345678901234567890'; + limiter.checkLimit(wallet1); + const result = limiter.checkLimit(walletUpper); + expect(result.allowed).toBe(true); + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(2); + }); + + it('should allow requests 1-10 for a single wallet', () => { + for (let i = 0; i < 10; i++) { + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(true); + } + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(10); + }); + + it('should block the 11th request for a single wallet', () => { + // Make 10 successful requests + for (let i = 0; i < 10; i++) { + expect(limiter.checkLimit(wallet1).allowed).toBe(true); + } + + // The 11th request should be denied + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('wallet'); + expect(result.retryAfter).toBe(60); + expect(result.message).toContain(`Rate limit exceeded for wallet ${wallet1}`); + expect(result.message).toContain('Maximum 10 requests per minute'); + + // Global count should be 10, not 11 + const status = limiter.getStatus(wallet1); + expect(status.globalCount).toBe(10); + }); + + it('should reset the wallet limit after the time window expires', () => { + // Exhaust the wallet limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + expect(limiter.checkLimit(wallet1).allowed).toBe(false); + + // Advance time by 60 seconds + jest.advanceTimersByTime(60 * 1000); + + // The next request should be allowed + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(true); + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(1); + }); + + it('should correctly calculate retryAfter partway through a window', () => { + // Exhaust the limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + // Advance time by 20 seconds + jest.advanceTimersByTime(20 * 1000); + + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + // Should be 40 seconds remaining (60 - 20) + expect(result.retryAfter).toBe(40); + }); + + it('should track different wallets separately', () => { + // Use up wallet1 limit + for (let i = 0; i < 10; i++) { + expect(limiter.checkLimit(wallet1).allowed).toBe(true); + } + expect(limiter.checkLimit(wallet1).allowed).toBe(false); + + // Wallet2 should still work + expect(limiter.checkLimit(wallet2).allowed).toBe(true); + }); + + it('should reset wallet window when expired before checking limit', () => { + // Make 5 requests + for (let i = 0; i < 5; i++) { + limiter.checkLimit(wallet1); + } + + // Advance time past expiration + jest.advanceTimersByTime(61 * 1000); + + // Next request should reset and be allowed + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(true); + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(1); + }); + }); + + describe('checkLimit() - Global limit', () => { + it('should increment global counter for each request', () => { + limiter.checkLimit(wallet1); + limiter.checkLimit(wallet2); + const status = limiter.getStatus(wallet1); + expect(status.globalCount).toBe(2); + }); + + it('should block the 101st global request', () => { + // Make 100 requests from 100 different wallets + for (let i = 0; i < 100; i++) { + expect(limiter.checkLimit(`0xwallet${i}`).allowed).toBe(true); + } + + // The 101st request from a new wallet should be denied + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('global'); + expect(result.retryAfter).toBe(60); + expect(result.message).toContain('Global rate limit exceeded'); + expect(result.message).toContain('Too many requests across all wallets'); + + // Wallet count for the new wallet should be 0 + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(0); + expect(status.globalCount).toBe(100); + }); + + it('should reset the global limit after the time window expires', () => { + // Exhaust the global limit + for (let i = 0; i < 100; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + expect(limiter.checkLimit(wallet1).allowed).toBe(false); + + // Advance time by 60 seconds + jest.advanceTimersByTime(60 * 1000); + + // The next request should be allowed + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(true); + const status = limiter.getStatus(wallet1); + expect(status.globalCount).toBe(1); + }); + + it('should check global limit before wallet limit', () => { + // Exhaust global limit + for (let i = 0; i < 100; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + + // Try with a wallet that has no requests yet + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('global'); + + // Wallet count should still be 0 + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(0); + }); + + it('should calculate retryAfter for global limit', () => { + // Exhaust global limit + for (let i = 0; i < 100; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + + // Advance 15 seconds + jest.advanceTimersByTime(15 * 1000); + + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBe(45); // 60 - 15 + }); + }); + + describe('checkLimit() - Combined scenarios', () => { + it('should allow wallet limit to be reached with global capacity available', () => { + // Make 10 requests (wallet limit) + for (let i = 0; i < 10; i++) { + expect(limiter.checkLimit(wallet1).allowed).toBe(true); + } + + // 11th should fail on wallet limit + const result = limiter.checkLimit(wallet1); + expect(result.allowed).toBe(false); + expect(result.limit).toBe('wallet'); + + // Global should still have capacity + const status = limiter.getStatus(wallet1); + expect(status.globalCount).toBe(10); + }); + + it('should handle multiple wallets exhausting their limits independently', () => { + // Exhaust wallet1 + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + // Exhaust wallet2 + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet2); + } + + // Both should be blocked at wallet level + expect(limiter.checkLimit(wallet1).limit).toBe('wallet'); + expect(limiter.checkLimit(wallet2).limit).toBe('wallet'); + + // Global should be at 20 + expect(limiter.getStatus(wallet1).globalCount).toBe(20); + }); + + it('should reset windows independently', () => { + limiter.checkLimit(wallet1); + + // Advance time by 30 seconds + jest.advanceTimersByTime(30 * 1000); + + limiter.checkLimit(wallet2); + + // Advance time by 31 seconds (61 total for wallet1, 31 for wallet2) + jest.advanceTimersByTime(31 * 1000); + + // Wallet1 should have reset + const result1 = limiter.checkLimit(wallet1); + expect(result1.allowed).toBe(true); + expect(limiter.getStatus(wallet1).walletCount).toBe(1); + + // Wallet2 should not have reset + expect(limiter.getStatus(wallet2).walletCount).toBe(2); + }); + }); + + describe('getStatus()', () => { + it('should return correct counts for tracked wallet', () => { + limiter.checkLimit(wallet1); + limiter.checkLimit(wallet1); + + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(2); + expect(status.globalCount).toBe(2); + expect(status.walletReset).toBeGreaterThan(Date.now()); + expect(status.globalReset).toBeGreaterThan(Date.now()); + }); + + it('should return zeros for untracked wallet', () => { + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(0); + expect(status.globalCount).toBe(0); + expect(status.walletReset).toBeGreaterThanOrEqual(Date.now()); + }); + + it('should handle case-insensitive wallet lookup', () => { + limiter.checkLimit(wallet1.toLowerCase()); + + const statusUpper = limiter.getStatus(wallet1.toUpperCase()); + expect(statusUpper.walletCount).toBe(1); + }); + + it('should return current global count for any wallet', () => { + limiter.checkLimit(wallet1); + limiter.checkLimit(wallet2); + + const status = limiter.getStatus('0xrandom'); + expect(status.globalCount).toBe(2); + }); + }); + + describe('cleanup()', () => { + it('should remove expired entries from walletLimits', () => { + // Make a request to add an entry + limiter.checkLimit(wallet1); + expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(true); + + // Advance time past the reset window + jest.advanceTimersByTime(61 * 1000); + + // Manually trigger cleanup + limiter.cleanup(); + + // The entry should be gone + expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(false); + }); + + it('should keep valid entries', () => { + limiter.checkLimit(wallet1); + limiter.checkLimit(wallet2); + + // Advance time but not past expiration + jest.advanceTimersByTime(30 * 1000); + + limiter.cleanup(); + + // Both entries should still be present + expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(true); + expect(limiter.walletLimits.has(wallet2.toLowerCase())).toBe(true); + }); + + it('should handle empty map', () => { + expect(() => limiter.cleanup()).not.toThrow(); + expect(limiter.walletLimits.size).toBe(0); + }); + + it('should run automatically every 2 minutes', () => { + limiter.checkLimit(wallet1); + + // Advance past entry expiration but less than cleanup interval + jest.advanceTimersByTime(61 * 1000); + expect(limiter.walletLimits.size).toBe(1); + + // Advance to cleanup interval (120 seconds total) + jest.advanceTimersByTime(59 * 1000); + + // Trigger any pending timers + jest.runOnlyPendingTimers(); + + expect(limiter.walletLimits.size).toBe(0); + }); + + it('should remove multiple expired entries', () => { + // Create entries for 5 wallets + for (let i = 0; i < 5; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + expect(limiter.walletLimits.size).toBe(5); + + // Advance time past expiration + jest.advanceTimersByTime(61 * 1000); + + limiter.cleanup(); + + expect(limiter.walletLimits.size).toBe(0); + }); + }); + + describe('destroy()', () => { + it('should clear the interval and the limits map', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + limiter.checkLimit(wallet1); + expect(limiter.walletLimits.size).toBe(1); + + limiter.destroy(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(limiter.cleanupInterval); + expect(limiter.walletLimits.size).toBe(0); + clearIntervalSpy.mockRestore(); + }); + + it('should handle being called multiple times', () => { + limiter.destroy(); + expect(() => limiter.destroy()).not.toThrow(); + }); + + it('should clear all wallet entries', () => { + for (let i = 0; i < 10; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + expect(limiter.walletLimits.size).toBe(10); + + limiter.destroy(); + + expect(limiter.walletLimits.size).toBe(0); + }); + }); + }); + + describe('balanceRateLimit Middleware', () => { + // The middleware uses a singleton, so we need to replace its implementation + // with our isolated instance for these tests. + let originalCheckLimit; + let originalGetStatus; + + beforeEach(() => { + originalCheckLimit = singletonRateLimiter.checkLimit; + originalGetStatus = singletonRateLimiter.getStatus; + singletonRateLimiter.checkLimit = (...args) => limiter.checkLimit(...args); + singletonRateLimiter.getStatus = (...args) => limiter.getStatus(...args); + }); + + afterEach(() => { + singletonRateLimiter.checkLimit = originalCheckLimit; + singletonRateLimiter.getStatus = originalGetStatus; + }); + + describe('Success path (within limits)', () => { + it('should call next() and set headers for an allowed request from query', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + + balanceRateLimit(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.jsonData).toBeNull(); + expect(res.headers['x-ratelimit-limit-wallet']).toBe('10'); + expect(res.headers['x-ratelimit-remaining-wallet']).toBe(9); + expect(res.headers['x-ratelimit-limit-global']).toBe('100'); + expect(res.headers['x-ratelimit-remaining-global']).toBe(99); + expect(res.headers['x-ratelimit-reset-wallet']).toBeGreaterThan(0); + }); + + it('should call next() and set headers for an allowed request from body', () => { + const { req, res, next } = makeMockReqRes({ body: { wallet: wallet1 } }); + + balanceRateLimit(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.headers['x-ratelimit-limit-wallet']).toBe('10'); + }); + + it('should prioritize query over body for wallet parameter', () => { + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + body: { wallet: wallet2 }, + }); + + balanceRateLimit(req, res, next); + + const status = limiter.getStatus(wallet1); + expect(status.walletCount).toBe(1); + expect(limiter.getStatus(wallet2).walletCount).toBe(0); + }); + + it('should set correct remaining count after multiple requests', () => { + // Make 5 requests + for (let i = 0; i < 5; i++) { + limiter.checkLimit(wallet1); + } + + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.headers['x-ratelimit-remaining-wallet']).toBe(4); // 10 - 6 + }); + + it('should never show negative remaining counts', () => { + // Exhaust limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); + expect(res.headers['x-ratelimit-remaining-global']).toBe(90); + }); + }); + + describe('429 responses', () => { + it('should return 429 when wallet limit is exceeded', () => { + // Exhaust the limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + const { req, res, next } = makeMockReqRes({ body: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(429); + expect(res.jsonData.error).toBe('Too Many Requests'); + expect(res.jsonData.limitType).toBe('wallet'); + expect(res.jsonData.retryAfter).toBe(60); + expect(res.jsonData.retryAfterMs).toBe(60000); + expect(res.jsonData.message).toContain('Rate limit exceeded for wallet'); + expect(res.headers['retry-after']).toBe(60); + // Remaining should be 0, not negative + expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); + }); + + it('should return 429 when global limit is exceeded', () => { + // Exhaust global limit + for (let i = 0; i < 100; i++) { + limiter.checkLimit(`0xwallet${i}`); + } + + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(429); + expect(res.jsonData.error).toBe('Too Many Requests'); + expect(res.jsonData.limitType).toBe('global'); + expect(res.jsonData.retryAfter).toBe(60); + expect(res.jsonData.message).toContain('Global rate limit exceeded'); + expect(res.headers['retry-after']).toBe(60); + expect(res.headers['x-ratelimit-remaining-global']).toBe(0); + }); + + it('should include correct Retry-After header value', () => { + // Exhaust limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + // Advance time by 25 seconds + jest.advanceTimersByTime(25 * 1000); + + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.headers['retry-after']).toBe(35); // 60 - 25 + expect(res.jsonData.retryAfter).toBe(35); + }); + + it('should return 429 with all required response fields', () => { + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.jsonData).toEqual({ + error: 'Too Many Requests', + message: expect.stringContaining('Rate limit exceeded'), + limitType: 'wallet', + retryAfter: 60, + retryAfterMs: 60000, + }); + }); + }); + + describe('Missing wallet parameter', () => { + it('should call next() immediately if no wallet is provided', () => { + const { req, res, next } = makeMockReqRes(); // No wallet in query or body + balanceRateLimit(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + // No rate limit headers should be set + expect(res.headers['x-ratelimit-limit-wallet']).toBeUndefined(); + expect(res.headers['x-ratelimit-limit-global']).toBeUndefined(); + }); + + it('should not increment any counters when wallet is missing', () => { + const initialStatus = limiter.getStatus('0xrandom'); + const initialGlobalCount = initialStatus.globalCount; + + const { req, res, next } = makeMockReqRes(); + balanceRateLimit(req, res, next); + + const finalStatus = limiter.getStatus('0xrandom'); + expect(finalStatus.globalCount).toBe(initialGlobalCount); + }); + + it('should handle empty string wallet', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: '' } }); + balanceRateLimit(req, res, next); + + // Should pass through since empty string is falsy + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should handle null wallet', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: null } }); + balanceRateLimit(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should handle undefined wallet in body', () => { + const { req, res, next } = makeMockReqRes({ body: { wallet: undefined } }); + balanceRateLimit(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + }); + + describe('Rate limit headers', () => { + it('should set all required rate limit headers', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.headers['x-ratelimit-limit-wallet']).toBeDefined(); + expect(res.headers['x-ratelimit-remaining-wallet']).toBeDefined(); + expect(res.headers['x-ratelimit-reset-wallet']).toBeDefined(); + expect(res.headers['x-ratelimit-limit-global']).toBeDefined(); + expect(res.headers['x-ratelimit-remaining-global']).toBeDefined(); + }); + + it('should set X-RateLimit-Reset-Wallet as Unix timestamp in seconds', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + const resetTimestamp = parseInt(res.headers['x-ratelimit-reset-wallet']); + const now = Math.floor(Date.now() / 1000); + + // Should be a valid Unix timestamp in the future + expect(resetTimestamp).toBeGreaterThan(now); + expect(resetTimestamp).toBeLessThan(now + 61); // Within 61 seconds + }); + + it('should update headers correctly across multiple requests', () => { + // First request + const mock1 = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(mock1.req, mock1.res, mock1.next); + expect(mock1.res.headers['x-ratelimit-remaining-wallet']).toBe(9); + + // Second request + const mock2 = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(mock2.req, mock2.res, mock2.next); + expect(mock2.res.headers['x-ratelimit-remaining-wallet']).toBe(8); + }); + + it('should not set Retry-After header for allowed requests', () => { + const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(req, res, next); + + expect(res.headers['retry-after']).toBeUndefined(); + }); + }); + + describe('Integration scenarios', () => { + it('should handle rapid sequential requests correctly', () => { + const requests = []; + + // Make 12 rapid requests + for (let i = 0; i < 12; i++) { + const mock = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(mock.req, mock.res, mock.next); + requests.push(mock); + } + + // First 10 should succeed + for (let i = 0; i < 10; i++) { + expect(requests[i].next).toHaveBeenCalled(); + expect(requests[i].res.statusCode).toBe(200); + } + + // Last 2 should fail + for (let i = 10; i < 12; i++) { + expect(requests[i].next).not.toHaveBeenCalled(); + expect(requests[i].res.statusCode).toBe(429); + } + }); + + it('should handle concurrent requests from different wallets', () => { + const wallets = []; + for (let i = 0; i < 15; i++) { + wallets.push(`0xwallet${i}`); + } + + // Each wallet makes 5 requests + wallets.forEach(wallet => { + for (let i = 0; i < 5; i++) { + const mock = makeMockReqRes({ query: { wallet } }); + balanceRateLimit(mock.req, mock.res, mock.next); + expect(mock.res.statusCode).toBe(200); + } + }); + + // Global count should be 75 (15 wallets * 5 requests) + const status = limiter.getStatus(wallets[0]); + expect(status.globalCount).toBe(75); + }); + + it('should recover after time window reset', () => { + // Exhaust limit + for (let i = 0; i < 10; i++) { + limiter.checkLimit(wallet1); + } + + // Verify blocked + const mock1 = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(mock1.req, mock1.res, mock1.next); + expect(mock1.res.statusCode).toBe(429); + + // Advance time + jest.advanceTimersByTime(60 * 1000); + + // Should work again + const mock2 = makeMockReqRes({ query: { wallet: wallet1 } }); + balanceRateLimit(mock2.req, mock2.res, mock2.next); + expect(mock2.res.statusCode).toBe(200); + expect(mock2.next).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/test/routes/balances.test.js b/test/routes/balances.test.js new file mode 100644 index 0000000..dbf0994 --- /dev/null +++ b/test/routes/balances.test.js @@ -0,0 +1,96 @@ +/** + * Balance Routes Unit Tests + */ + +jest.mock('../../src/services/balanceService'); + +const express = require('express'); +const request = require('supertest'); +const balancesRouter = require('../../src/routes/balances'); +const BalanceController = require('../../src/controllers/balanceController'); + +describe('Balance Routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/balances', balancesRouter); + }); + + describe('GET /balances/chains', () => { + test('should call BalanceController.getSupportedChains', async () => { + BalanceController.getSupportedChains.mockImplementation((req, res) => { + res.json({ success: true, data: { chains: ['1', '137'] } }); + }); + + const res = await request(app).get('/balances/chains'); + + expect(BalanceController.getSupportedChains).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('GET /balances/cache/stats', () => { + test('should call BalanceController.getCacheStats', async () => { + BalanceController.getCacheStats.mockImplementation((req, res) => { + res.json({ success: true, data: { hits: 10, misses: 5 } }); + }); + + const res = await request(app).get('/balances/cache/stats'); + + expect(BalanceController.getCacheStats).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('DELETE /balances/cache', () => { + test('should call BalanceController.clearCache', async () => { + BalanceController.clearCache.mockImplementation((req, res) => { + res.json({ success: true }); + }); + + const res = await request(app).delete('/balances/cache?address=0x123'); + + expect(BalanceController.clearCache).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); + + describe('GET /balances/:chainId/:address', () => { + test('should call BalanceController.getBalances with params', async () => { + BalanceController.getBalances.mockImplementation((req, res) => { + res.json({ success: true, data: { balances: [] } }); + }); + + const res = await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'); + + expect(BalanceController.getBalances).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + + test('should pass query parameters to controller', async () => { + BalanceController.getBalances.mockImplementation((req, res) => { + res.json({ success: true }); + }); + + await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb?tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + + expect(BalanceController.getBalances).toHaveBeenCalled(); + }); + }); + + describe('GET /balances/:chainId/:address/native', () => { + test('should call BalanceController.getNativeBalance', async () => { + BalanceController.getNativeBalance.mockImplementation((req, res) => { + res.json({ success: true, data: { balance: '1000000000000000000' } }); + }); + + const res = await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb/native'); + + expect(BalanceController.getNativeBalance).toHaveBeenCalled(); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/test/routes/tokens.test.js b/test/routes/tokens.test.js new file mode 100644 index 0000000..9028255 --- /dev/null +++ b/test/routes/tokens.test.js @@ -0,0 +1,124 @@ +/** + * Token Routes Unit Tests + */ + +jest.mock('../../src/config/tokenConfig'); + +const express = require('express'); +const request = require('supertest'); +const tokensRouter = require('../../src/routes/tokens'); +const { TokenConfigService } = require('../../src/config/tokenConfig'); + +describe('Token Routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/tokens', tokensRouter); + + // Mock TokenConfigService + TokenConfigService.getZapTokens = jest.fn(); + TokenConfigService.getSupportedChains = jest.fn().mockReturnValue([1, 137, 42161, 8453]); + }); + + describe('GET /tokens/zap/:chainId', () => { + test('should return zap tokens for valid chain', async () => { + const mockTokens = { + chainId: 1, + chainName: 'ethereum', + nativeToken: 'ETH', + tokens: [ + { + symbol: 'USDC', + name: 'USD Coin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + type: 'stablecoin', + }, + ], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject(mockTokens); + expect(TokenConfigService.getZapTokens).toHaveBeenCalledWith(1); + }); + + test('should handle invalid chainId', async () => { + TokenConfigService.getZapTokens.mockReturnValue(null); + + const res = await request(app).get('/tokens/zap/999'); + + expect(res.status).toBe(404); + }); + + test('should handle non-numeric chainId', async () => { + const res = await request(app).get('/tokens/zap/invalid'); + + expect(res.status).toBe(400); + }); + + test('should return tokens for Arbitrum', async () => { + const mockTokens = { + chainId: 42161, + chainName: 'arbitrum', + nativeToken: 'ETH', + tokens: [], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/42161'); + + expect(res.status).toBe(200); + expect(res.body.chainId).toBe(42161); + }); + + test('should return tokens for Base', async () => { + const mockTokens = { + chainId: 8453, + chainName: 'base', + nativeToken: 'ETH', + tokens: [], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/8453'); + + expect(res.status).toBe(200); + expect(res.body.chainId).toBe(8453); + }); + + test('should include token metadata', async () => { + const mockTokens = { + chainId: 1, + chainName: 'ethereum', + nativeToken: 'ETH', + tokens: [ + { + symbol: 'USDC', + name: 'USD Coin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + type: 'stablecoin', + coingeckoId: 'usd-coin', + }, + ], + }; + + TokenConfigService.getZapTokens.mockReturnValue(mockTokens); + + const res = await request(app).get('/tokens/zap/1'); + + expect(res.body.tokens[0]).toHaveProperty('symbol'); + expect(res.body.tokens[0]).toHaveProperty('address'); + expect(res.body.tokens[0]).toHaveProperty('decimals'); + }); + }); +}); diff --git a/test/services/priceService/PriceCache.enhanced.test.js b/test/services/priceService/PriceCache.enhanced.test.js new file mode 100644 index 0000000..3d41552 --- /dev/null +++ b/test/services/priceService/PriceCache.enhanced.test.js @@ -0,0 +1,149 @@ +/** + * Enhanced Price Cache Tests + */ + +const PriceCache = require('../../../src/services/priceService/PriceCache.enhanced'); + +describe('PriceCache', () => { + let cache; + + beforeEach(() => { + jest.useFakeTimers(); + cache = new PriceCache(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('get/set', () => { + test('should set and get price data', () => { + const priceData = { price: 1000, symbol: 'ETH' }; + cache.set('ETH', priceData); + + const result = cache.get('ETH'); + expect(result).toMatchObject(priceData); + expect(result.timestamp).toBeDefined(); + }); + + test('should be case insensitive', () => { + cache.set('ETH', { price: 1000 }); + + const result = cache.get('eth'); + expect(result).toBeTruthy(); + expect(result.price).toBe(1000); + }); + + test('should return null for expired data', () => { + cache.set('ETH', { price: 1000 }, 1); + jest.advanceTimersByTime(2000); + + const result = cache.get('ETH'); + expect(result).toBeNull(); + }); + + test('should return null for missing symbol', () => { + const result = cache.get('NONEXISTENT'); + expect(result).toBeNull(); + }); + }); + + describe('getBulk', () => { + test('should get multiple symbols at once', () => { + cache.set('ETH', { price: 1000 }); + cache.set('BTC', { price: 30000 }); + + const result = cache.getBulk(['ETH', 'BTC', 'USDC']); + + expect(result.results.eth).toBeDefined(); + expect(result.results.btc).toBeDefined(); + expect(result.remaining).toContain('usdc'); + }); + + test('should mark cached results', () => { + cache.set('ETH', { price: 1000 }); + + const result = cache.getBulk(['ETH']); + expect(result.results.eth.fromCache).toBe(true); + }); + }); + + describe('clear', () => { + test('should clear all cache', () => { + cache.set('ETH', { price: 1000 }); + cache.set('BTC', { price: 30000 }); + + cache.clear(); + + expect(cache.get('ETH')).toBeNull(); + expect(cache.get('BTC')).toBeNull(); + }); + }); + + describe('getStats', () => { + test('should return cache statistics', () => { + cache.set('ETH', { price: 1000 }); + cache.get('ETH'); + cache.get('NONEXISTENT'); + + const stats = cache.getStats(); + expect(stats).toHaveProperty('size'); + expect(stats).toHaveProperty('hits'); + expect(stats).toHaveProperty('misses'); + expect(stats).toHaveProperty('hitRate'); + expect(stats).toHaveProperty('avgCacheAge'); + }); + }); + + describe('monitoring', () => { + test('should track hits and misses', () => { + cache.set('ETH', { price: 1000 }); + cache.get('ETH'); // hit + cache.get('BTC'); // miss + + const stats = cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + }); + + test('should track stale hits', () => { + cache.set('ETH', { price: 1000, timestamp: Date.now() - 400000 }); // 6.6 min old + cache.get('ETH'); + + const stats = cache.getStats(); + expect(stats.staleHits).toBe(1); + }); + + test('should calculate hit rate', () => { + cache.set('ETH', { price: 1000 }); + cache.get('ETH'); // hit + cache.get('ETH'); // hit + cache.get('BTC'); // miss + + const stats = cache.getStats(); + expect(stats.hitRate).toBe('66.67%'); + }); + + test('should track average cache age', () => { + cache.set('ETH', { price: 1000 }); + jest.advanceTimersByTime(5000); + cache.get('ETH'); + + const stats = cache.getStats(); + expect(stats.avgCacheAge).toContain('s'); + }); + }); + + describe('resetStats', () => { + test('should reset monitoring metrics', () => { + cache.set('ETH', { price: 1000 }); + cache.get('ETH'); + + cache.resetStats(); + + const stats = cache.getStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + }); + }); +}); diff --git a/test/utils/balanceCache.enhanced.test.js b/test/utils/balanceCache.enhanced.test.js new file mode 100644 index 0000000..e08ffd0 --- /dev/null +++ b/test/utils/balanceCache.enhanced.test.js @@ -0,0 +1,1188 @@ +/** + * Unit Tests for Enhanced Balance Cache (balanceCache.enhanced.js) + * + * Tests cover: + * - BalanceCacheMonitor: metric tracking, stale detection, statistics + * - BalanceCache: CRUD operations, TTL expiration, partial updates, async refresh, + * invalidation methods, cleanup intervals, monitoring integration + * + * Uses Jest fake timers for TTL testing and achieves 90%+ code coverage + */ + +const BalanceCache = require('../../src/utils/balanceCache.enhanced'); + +describe('BalanceCacheMonitor', () => { + let cache; + let monitor; + + beforeEach(() => { + cache = new BalanceCache(); + monitor = cache.monitor; + }); + + afterEach(() => { + cache.stopCleanup(); + }); + + describe('Initial State', () => { + it('should initialize with zero metrics', () => { + expect(monitor.metrics.hits).toBe(0); + expect(monitor.metrics.misses).toBe(0); + expect(monitor.metrics.staleHits).toBe(0); + expect(monitor.metrics.sets).toBe(0); + expect(monitor.metrics.evictions).toBe(0); + expect(monitor.metrics.invalidations).toBe(0); + expect(monitor.metrics.partialUpdates).toBe(0); + expect(monitor.metrics.totalCacheAge).toBe(0); + }); + + it('should have staleThreshold of 300000ms (5 minutes)', () => { + expect(monitor.staleThreshold).toBe(300000); + }); + }); + + describe('recordHit', () => { + it('should increment hits counter', () => { + monitor.recordHit(1000); + expect(monitor.metrics.hits).toBe(1); + monitor.recordHit(2000); + expect(monitor.metrics.hits).toBe(2); + }); + + it('should track total cache age', () => { + monitor.recordHit(1000); + monitor.recordHit(2000); + expect(monitor.metrics.totalCacheAge).toBe(3000); + }); + + it('should not record stale hit for fresh cache', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordHit(100000); // 100 seconds - not stale + expect(monitor.metrics.staleHits).toBe(0); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should record stale hit when cache age exceeds threshold', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordHit(350000); // 350 seconds - stale + expect(monitor.metrics.staleHits).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[BalanceCache] Stale cache hit: 350.0s old') + ); + consoleSpy.mockRestore(); + }); + }); + + describe('recordMiss', () => { + it('should increment misses counter', () => { + monitor.recordMiss(); + expect(monitor.metrics.misses).toBe(1); + monitor.recordMiss(); + expect(monitor.metrics.misses).toBe(2); + }); + }); + + describe('recordSet', () => { + it('should increment sets counter', () => { + monitor.recordSet(); + expect(monitor.metrics.sets).toBe(1); + }); + }); + + describe('recordEviction', () => { + it('should increment evictions counter', () => { + monitor.recordEviction(); + expect(monitor.metrics.evictions).toBe(1); + }); + }); + + describe('recordInvalidation', () => { + it('should increment invalidations counter', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordInvalidation('test reason'); + expect(monitor.metrics.invalidations).toBe(1); + consoleSpy.mockRestore(); + }); + + it('should log invalidation reason', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordInvalidation('manual clear'); + expect(consoleSpy).toHaveBeenCalledWith( + '[BalanceCache] Invalidated: manual clear' + ); + consoleSpy.mockRestore(); + }); + }); + + describe('recordPartialUpdate', () => { + it('should increment partialUpdates counter', () => { + monitor.recordPartialUpdate(); + expect(monitor.metrics.partialUpdates).toBe(1); + }); + }); + + describe('getHitRate', () => { + it('should return 0.00% when no requests', () => { + expect(monitor.getHitRate()).toBe('0.00'); + }); + + it('should calculate 100% hit rate', () => { + monitor.recordHit(1000); + monitor.recordHit(2000); + expect(monitor.getHitRate()).toBe('100.00'); + }); + + it('should calculate 50% hit rate', () => { + monitor.recordHit(1000); + monitor.recordMiss(); + expect(monitor.getHitRate()).toBe('50.00'); + }); + + it('should calculate 33.33% hit rate', () => { + monitor.recordHit(1000); + monitor.recordMiss(); + monitor.recordMiss(); + expect(monitor.getHitRate()).toBe('33.33'); + }); + }); + + describe('getAvgCacheAge', () => { + it('should return 0 when no hits', () => { + expect(monitor.getAvgCacheAge()).toBe(0); + }); + + it('should calculate average cache age', () => { + monitor.recordHit(1000); + monitor.recordHit(3000); + expect(monitor.getAvgCacheAge()).toBe(2000); + }); + }); + + describe('getStats', () => { + it('should return comprehensive stats object', () => { + monitor.recordHit(1000); + monitor.recordMiss(); + monitor.recordSet(); + + const stats = monitor.getStats(); + + expect(stats).toMatchObject({ + hits: 1, + misses: 1, + staleHits: 0, + sets: 1, + evictions: 0, + invalidations: 0, + partialUpdates: 0, + totalCacheAge: 1000, + }); + expect(stats.hitRate).toBe('50.00%'); + expect(stats.avgCacheAge).toBe('1.0s'); + expect(stats.staleHitRate).toBe('0.00%'); + }); + + it('should calculate stale hit rate correctly', () => { + monitor.recordHit(100000); + jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordHit(350000); // stale + console.warn.mockRestore(); + + const stats = monitor.getStats(); + expect(stats.staleHitRate).toBe('50.00%'); + }); + }); + + describe('reset', () => { + it('should reset all metrics to zero', () => { + monitor.recordHit(1000); + monitor.recordMiss(); + monitor.recordSet(); + monitor.recordEviction(); + jest.spyOn(console, 'warn').mockImplementation(); + monitor.recordInvalidation('test'); + console.warn.mockRestore(); + monitor.recordPartialUpdate(); + + monitor.reset(); + + expect(monitor.metrics).toEqual({ + hits: 0, + misses: 0, + staleHits: 0, + sets: 0, + evictions: 0, + invalidations: 0, + partialUpdates: 0, + totalCacheAge: 0, + }); + }); + }); +}); + +describe('BalanceCache', () => { + let cache; + + beforeEach(() => { + cache = new BalanceCache(); + }); + + afterEach(() => { + cache.stopCleanup(); + }); + + describe('Constructor', () => { + it('should initialize with default TTL of 180000ms (3 minutes)', () => { + expect(cache.defaultTTL).toBe(180000); + }); + + it('should accept custom TTL', () => { + const customCache = new BalanceCache(60000); + expect(customCache.defaultTTL).toBe(60000); + customCache.stopCleanup(); + }); + + it('should create empty cache and expirations maps', () => { + expect(cache.cache.size).toBe(0); + expect(cache.expirations.size).toBe(0); + }); + + it('should create monitor instance', () => { + expect(cache.monitor).toBeDefined(); + expect(cache.monitor.metrics).toBeDefined(); + }); + + it('should start cleanup interval automatically', () => { + expect(cache.cleanupInterval).not.toBeNull(); + }); + }); + + describe('generateKey (static)', () => { + it('should generate key with chainId and address only', () => { + const key = BalanceCache.generateKey(1, '0xABCD'); + expect(key).toBe('balance:1:0xabcd:all'); + }); + + it('should normalize chainId to lowercase string', () => { + const key1 = BalanceCache.generateKey(1, '0xAddress'); + const key2 = BalanceCache.generateKey('1', '0xAddress'); + expect(key1).toBe(key2); + }); + + it('should normalize address to lowercase', () => { + const key = BalanceCache.generateKey(1, '0xABCDEF'); + expect(key).toBe('balance:1:0xabcdef:all'); + }); + + it('should include sorted token addresses', () => { + const tokens = ['0xToken2', '0xToken1', '0xToken3']; + const key = BalanceCache.generateKey(1, '0xAddr', tokens); + expect(key).toBe('balance:1:0xaddr:0xtoken1,0xtoken2,0xtoken3'); + }); + + it('should normalize and sort token addresses', () => { + const tokens = ['0xBBB', '0xAAA', '0xCCC']; + const key = BalanceCache.generateKey(1, '0xAddr', tokens); + expect(key).toContain('0xaaa,0xbbb,0xccc'); + }); + + it('should handle empty token array as "all"', () => { + const key = BalanceCache.generateKey(1, '0xAddr', []); + expect(key).toBe('balance:1:0xaddr:all'); + }); + + it('should handle null token array as "all"', () => { + const key = BalanceCache.generateKey(1, '0xAddr', null); + expect(key).toBe('balance:1:0xaddr:all'); + }); + }); + + describe('set and get', () => { + it('should store and retrieve data', () => { + const key = 'test:key'; + const data = { balances: [], value: 100 }; + + cache.set(key, data); + const retrieved = cache.get(key); + + expect(retrieved).toMatchObject(data); + expect(retrieved.timestamp).toBeDefined(); + }); + + it('should add timestamp to data if not present', () => { + const key = 'test:key'; + const data = { value: 100 }; + + cache.set(key, data); + const retrieved = cache.get(key); + + expect(retrieved.timestamp).toBeDefined(); + expect(typeof retrieved.timestamp).toBe('number'); + }); + + it('should preserve existing timestamp', () => { + const key = 'test:key'; + const timestamp = 1234567890; + const data = { value: 100, timestamp }; + + cache.set(key, data); + const retrieved = cache.get(key); + + expect(retrieved.timestamp).toBe(timestamp); + }); + + it('should use custom TTL when provided', () => { + const key = 'test:key'; + const data = { value: 100 }; + const customTTL = 60000; + + cache.set(key, data, customTTL); + const expiresAt = cache.expirations.get(key); + + expect(expiresAt - Date.now()).toBeGreaterThan(55000); + expect(expiresAt - Date.now()).toBeLessThanOrEqual(customTTL); + }); + + it('should return null for non-existent key', () => { + const retrieved = cache.get('non-existent'); + expect(retrieved).toBeNull(); + }); + + it('should record set metric', () => { + cache.set('test:key', { value: 100 }); + expect(cache.monitor.metrics.sets).toBe(1); + }); + + it('should record hit metric on successful get', () => { + cache.set('test:key', { value: 100 }); + cache.get('test:key'); + expect(cache.monitor.metrics.hits).toBe(1); + }); + + it('should record miss metric on failed get', () => { + cache.get('non-existent'); + expect(cache.monitor.metrics.misses).toBe(1); + }); + + it('should handle edge case where expiration exists but cache data is missing', () => { + const key = 'test:key'; + + // Manually create expiration without cache data (edge case) + cache.expirations.set(key, Date.now() + 60000); + + const retrieved = cache.get(key); + expect(retrieved).toBeNull(); + expect(cache.monitor.metrics.misses).toBe(1); + }); + }); + + describe('TTL expiration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire data after default TTL', () => { + const key = 'test:key'; + const data = { value: 100 }; + + cache.set(key, data); + expect(cache.get(key)).not.toBeNull(); + + // Advance time past TTL + jest.advanceTimersByTime(180001); + + const retrieved = cache.get(key); + expect(retrieved).toBeNull(); + }); + + it('should expire data after custom TTL', () => { + const key = 'test:key'; + const data = { value: 100 }; + const customTTL = 60000; + + cache.set(key, data, customTTL); + expect(cache.get(key)).not.toBeNull(); + + // Advance time past custom TTL + jest.advanceTimersByTime(60001); + + expect(cache.get(key)).toBeNull(); + }); + + it('should not expire data before TTL', () => { + const key = 'test:key'; + const data = { value: 100 }; + + cache.set(key, data); + + // Advance time but stay within TTL + jest.advanceTimersByTime(179000); + + const retrieved = cache.get(key); + expect(retrieved).not.toBeNull(); + }); + + it('should delete expired entry when accessed', () => { + const key = 'test:key'; + cache.set(key, { value: 100 }); + + jest.advanceTimersByTime(180001); + cache.get(key); + + expect(cache.cache.has(key)).toBe(false); + expect(cache.expirations.has(key)).toBe(false); + }); + + it('should record eviction when expired entry is accessed', () => { + const key = 'test:key'; + cache.set(key, { value: 100 }); + + jest.advanceTimersByTime(180001); + cache.get(key); + + expect(cache.monitor.metrics.evictions).toBe(1); + }); + }); + + describe('delete', () => { + it('should remove cache entry', () => { + const key = 'test:key'; + cache.set(key, { value: 100 }); + + const deleted = cache.delete(key); + + expect(deleted).toBe(true); + expect(cache.cache.has(key)).toBe(false); + expect(cache.expirations.has(key)).toBe(false); + }); + + it('should return false when deleting non-existent key', () => { + const deleted = cache.delete('non-existent'); + expect(deleted).toBe(false); + }); + }); + + describe('clear', () => { + it('should remove all cache entries', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('key1', { value: 1 }); + cache.set('key2', { value: 2 }); + cache.set('key3', { value: 3 }); + + cache.clear(); + + expect(cache.cache.size).toBe(0); + expect(cache.expirations.size).toBe(0); + + consoleSpy.mockRestore(); + }); + + it('should record invalidation with entry count', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('key1', { value: 1 }); + cache.set('key2', { value: 2 }); + + cache.clear(); + + expect(cache.monitor.metrics.invalidations).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('full clear (2 entries)') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('updateTokenBalance', () => { + it('should update specific token balance', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const data = { + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + ], + }; + + cache.set(key, data); + const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); + + expect(updated).toBe(true); + + const cached = cache.get(key); + expect(cached.balances[0].balance).toBe('150'); + expect(cached.balances[1].balance).toBe('200'); + expect(cached.partialUpdate).toBe(true); + expect(cached.lastPartialUpdate).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it('should add lastUpdated timestamp to updated token', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const data = { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }; + + cache.set(key, data); + cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); + + const cached = cache.get(key); + expect(cached.balances[0].lastUpdated).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it('should be case-insensitive for token address', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const data = { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }; + + cache.set(key, data); + const updated = cache.updateTokenBalance(1, '0xAddress', '0xTOKEN1', '150'); + + expect(updated).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should return false when cache miss', () => { + const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); + expect(updated).toBe(false); + }); + + it('should return false when token not found in balances', () => { + const key = BalanceCache.generateKey(1, '0xAddress', null); + const data = { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }; + + cache.set(key, data); + const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken2', '150'); + + expect(updated).toBe(false); + }); + + it('should return false when cached data has no balances', () => { + const key = BalanceCache.generateKey(1, '0xAddress', null); + cache.set(key, { value: 100 }); + + const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); + expect(updated).toBe(false); + }); + + it('should record partial update metric', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const data = { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }; + + cache.set(key, data); + cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); + + expect(cache.monitor.metrics.partialUpdates).toBe(1); + + consoleSpy.mockRestore(); + }); + }); + + describe('refreshTokens', () => { + it('should fetch and set fresh data when no existing cache', async () => { + const fetchFn = jest.fn().mockResolvedValue({ + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + ], + }); + + const result = await cache.refreshTokens( + 1, + '0xAddress', + ['0xToken1', '0xToken2'], + fetchFn + ); + + expect(fetchFn).toHaveBeenCalledWith(1, '0xAddress', [ + '0xToken1', + '0xToken2', + ]); + expect(result.balances).toHaveLength(2); + + const key = BalanceCache.generateKey(1, '0xAddress', null); + const cached = cache.get(key); + expect(cached.balances).toHaveLength(2); + }); + + it('should merge fresh data with existing cache', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const existingData = { + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + { tokenAddress: '0xToken3', balance: '300' }, + ], + }; + + cache.set(key, existingData); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '150' }], + }); + + const result = await cache.refreshTokens( + 1, + '0xAddress', + ['0xToken1'], + fetchFn + ); + + // Should have all 3 tokens, with Token1 updated + expect(result.balances).toHaveLength(3); + + const updatedToken = result.balances.find( + b => b.tokenAddress === '0xToken1' + ); + expect(updatedToken.balance).toBe('150'); + + const preservedToken2 = result.balances.find( + b => b.tokenAddress === '0xToken2' + ); + expect(preservedToken2.balance).toBe('200'); + + consoleSpy.mockRestore(); + }); + + it('should preserve non-refreshed tokens', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const existingData = { + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + ], + }; + + cache.set(key, existingData); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '150' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); + + const cached = cache.get(key); + const token2 = cached.balances.find(b => b.tokenAddress === '0xToken2'); + expect(token2.balance).toBe('200'); + + consoleSpy.mockRestore(); + }); + + it('should mark as partial update', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + cache.set(key, { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '150' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); + + const cached = cache.get(key); + expect(cached.partialUpdate).toBe(true); + expect(cached.lastPartialUpdate).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it('should update timestamp', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + const oldTimestamp = Date.now() - 10000; + cache.set(key, { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + timestamp: oldTimestamp, + }); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '150' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); + + const cached = cache.get(key); + expect(cached.timestamp).toBeGreaterThan(oldTimestamp); + + consoleSpy.mockRestore(); + }); + + it('should record partial update metric when merging with existing cache', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + cache.set(key, { + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '150' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); + + expect(cache.monitor.metrics.partialUpdates).toBe(1); + + consoleSpy.mockRestore(); + }); + + it('should not record partial update metric when no existing cache', async () => { + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xToken1', balance: '100' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); + + expect(cache.monitor.metrics.partialUpdates).toBe(0); + }); + + it('should handle case-insensitive token matching', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const key = BalanceCache.generateKey(1, '0xAddress', null); + cache.set(key, { + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + ], + }); + + const fetchFn = jest.fn().mockResolvedValue({ + balances: [{ tokenAddress: '0xTOKEN1', balance: '150' }], + }); + + await cache.refreshTokens(1, '0xAddress', ['0xTOKEN1'], fetchFn); + + const cached = cache.get(key); + // Should only have Token2 from old cache + Token1 from fresh data + expect(cached.balances).toHaveLength(2); + + consoleSpy.mockRestore(); + }); + }); + + describe('clearAddress', () => { + it('should clear all entries for an address', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + cache.set('balance:1:0xaddr2:all', { value: 2 }); + cache.set('balance:2:0xaddr1:all', { value: 3 }); + + const cleared = cache.clearAddress('0xAddr1'); + + expect(cleared).toBe(2); + expect(cache.get('balance:1:0xaddr1:all')).toBeNull(); + expect(cache.get('balance:2:0xaddr1:all')).toBeNull(); + expect(cache.get('balance:1:0xaddr2:all')).not.toBeNull(); + + consoleSpy.mockRestore(); + }); + + it('should be case-insensitive', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + + const cleared = cache.clearAddress('0xADDR1'); + expect(cleared).toBe(1); + + consoleSpy.mockRestore(); + }); + + it('should record invalidation with reason', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + cache.clearAddress('0xAddr1', 'transaction'); + + expect(cache.monitor.metrics.invalidations).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('address 0xAddr1 (transaction)') + ); + + consoleSpy.mockRestore(); + }); + + it('should return 0 when no entries cleared', () => { + const cleared = cache.clearAddress('0xNonExistent'); + expect(cleared).toBe(0); + }); + + it('should not record invalidation when no entries cleared', () => { + cache.clearAddress('0xNonExistent'); + expect(cache.monitor.metrics.invalidations).toBe(0); + }); + }); + + describe('clearChain', () => { + it('should clear all entries for a chain', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + cache.set('balance:1:0xaddr2:all', { value: 2 }); + cache.set('balance:2:0xaddr1:all', { value: 3 }); + + const cleared = cache.clearChain(1); + + expect(cleared).toBe(2); + expect(cache.get('balance:1:0xaddr1:all')).toBeNull(); + expect(cache.get('balance:1:0xaddr2:all')).toBeNull(); + expect(cache.get('balance:2:0xaddr1:all')).not.toBeNull(); + + consoleSpy.mockRestore(); + }); + + it('should normalize chainId to string', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + + const cleared = cache.clearChain('1'); + expect(cleared).toBe(1); + + consoleSpy.mockRestore(); + }); + + it('should record invalidation with reason', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + cache.clearChain(1, 'reorg'); + + expect(cache.monitor.metrics.invalidations).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('chain 1 (reorg)') + ); + + consoleSpy.mockRestore(); + }); + + it('should return 0 when no entries cleared', () => { + const cleared = cache.clearChain(999); + expect(cleared).toBe(0); + }); + }); + + describe('invalidateAfterTransaction', () => { + it('should call clearAddress with transaction reason', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('balance:1:0xaddr1:all', { value: 1 }); + const cleared = cache.invalidateAfterTransaction(1, '0xAddr1', 'zapIn'); + + expect(cleared).toBe(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('transaction:zapIn') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('cleanup', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should remove expired entries', () => { + cache.set('key1', { value: 1 }); + cache.set('key2', { value: 2 }); + + jest.advanceTimersByTime(180001); + + const cleaned = cache.cleanup(); + + expect(cleaned).toBe(2); + expect(cache.cache.size).toBe(0); + }); + + it('should not remove non-expired entries', () => { + cache.set('key1', { value: 1 }); + cache.set('key2', { value: 2 }); + + jest.advanceTimersByTime(90000); + + const cleaned = cache.cleanup(); + + expect(cleaned).toBe(0); + expect(cache.cache.size).toBe(2); + }); + + it('should record eviction metrics', () => { + cache.set('key1', { value: 1 }); + cache.set('key2', { value: 2 }); + + jest.advanceTimersByTime(180001); + cache.cleanup(); + + expect(cache.monitor.metrics.evictions).toBe(2); + }); + + it('should return 0 when no expired entries', () => { + cache.set('key1', { value: 1 }); + + const cleaned = cache.cleanup(); + expect(cleaned).toBe(0); + }); + }); + + describe('startCleanup and stopCleanup', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should run cleanup at specified interval', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Create a new cache with fake timers active to ensure interval is mocked + const testCache = new BalanceCache(); + + testCache.set('key1', { value: 1 }); + + // Advance past TTL + jest.advanceTimersByTime(180001); + + // Advance to trigger cleanup interval (default 60000ms) + jest.advanceTimersByTime(60000); + + expect(testCache.cache.size).toBe(0); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Cleaned up 1 expired entries') + ); + + testCache.stopCleanup(); + consoleSpy.mockRestore(); + }); + + it('should accept custom cleanup interval', () => { + cache.stopCleanup(); + cache.startCleanup(30000); + + cache.set('key1', { value: 1 }); + jest.advanceTimersByTime(180001); + + jest.advanceTimersByTime(30000); + + expect(cache.cache.size).toBe(0); + }); + + it('should replace existing cleanup interval', () => { + cache.startCleanup(10000); + expect(cache.cleanupInterval).not.toBeNull(); + + const oldInterval = cache.cleanupInterval; + cache.startCleanup(20000); + + expect(cache.cleanupInterval).not.toBe(oldInterval); + }); + + it('should stop cleanup interval', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.stopCleanup(); + expect(cache.cleanupInterval).toBeNull(); + + cache.set('key1', { value: 1 }); + jest.advanceTimersByTime(240001); // Past TTL and cleanup interval + + // Cleanup should not run automatically + expect(cache.cache.has('key1')).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should not log when no entries cleaned', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('key1', { value: 1 }); + + // Advance cleanup interval but not past TTL + jest.advanceTimersByTime(60000); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Cleaned up') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('getStats', () => { + it('should return comprehensive statistics', () => { + cache.set('key1', { value: 1 }); + cache.get('key1'); + + const stats = cache.getStats(); + + expect(stats).toMatchObject({ + size: 1, + defaultTTL: 180000, + hits: 1, + misses: 0, + sets: 1, + }); + expect(stats.hitRate).toBeDefined(); + expect(stats.avgCacheAge).toBeDefined(); + expect(stats.memoryUsage).toBeDefined(); + }); + + it('should include monitor stats', () => { + const stats = cache.getStats(); + + expect(stats.hitRate).toBeDefined(); + expect(stats.staleHitRate).toBeDefined(); + expect(stats.avgCacheAge).toBeDefined(); + }); + }); + + describe('estimateMemoryUsage', () => { + it('should return KB for small cache', () => { + cache.set('key1', { value: 1 }); + + const usage = cache.estimateMemoryUsage(); + expect(usage).toContain('KB'); + }); + + it('should return MB for large cache', () => { + // Add many entries to exceed 1024 KB threshold + for (let i = 0; i < 3000; i++) { + cache.set(`key${i}`, { value: i }); + } + + const usage = cache.estimateMemoryUsage(); + expect(usage).toContain('MB'); + }); + }); + + describe('resetStats', () => { + it('should reset monitor statistics', () => { + cache.set('key1', { value: 1 }); + cache.get('key1'); + + cache.resetStats(); + + expect(cache.monitor.metrics.hits).toBe(0); + expect(cache.monitor.metrics.sets).toBe(0); + }); + }); + + describe('logHealthReport', () => { + it('should log formatted health report', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + cache.set('key1', { value: 1 }); + cache.get('key1'); + + cache.logHealthReport(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Balance Cache Health Report') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Cache Size:') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Hit Rate:') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('Edge Cases and Integration', () => { + it('should handle multiple simultaneous operations', () => { + const key1 = BalanceCache.generateKey(1, '0xAddr1'); + const key2 = BalanceCache.generateKey(2, '0xAddr2'); + + cache.set(key1, { value: 1 }); + cache.set(key2, { value: 2 }); + + expect(cache.get(key1)).not.toBeNull(); + expect(cache.get(key2)).not.toBeNull(); + + cache.delete(key1); + + expect(cache.get(key1)).toBeNull(); + expect(cache.get(key2)).not.toBeNull(); + }); + + it('should maintain cache integrity across operations', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const key = BalanceCache.generateKey(1, '0xAddr', null); + cache.set(key, { + balances: [ + { tokenAddress: '0xToken1', balance: '100' }, + { tokenAddress: '0xToken2', balance: '200' }, + ], + }); + + cache.updateTokenBalance(1, '0xAddr', '0xToken1', '150'); + const retrieved = cache.get(key); + + expect(retrieved.balances[0].balance).toBe('150'); + expect(retrieved.balances[1].balance).toBe('200'); + expect(cache.monitor.metrics.hits).toBe(2); // Once in update, once in get + expect(cache.monitor.metrics.partialUpdates).toBe(1); + + consoleSpy.mockRestore(); + }); + + it('should handle empty cache gracefully', () => { + expect(cache.cache.size).toBe(0); + expect(cache.get('any-key')).toBeNull(); + expect(cache.cleanup()).toBe(0); + + const stats = cache.getStats(); + expect(stats.size).toBe(0); + expect(stats.hitRate).toBe('0.00%'); + }); + + it('should handle clearing empty address', () => { + const cleared = cache.clearAddress('0xEmpty'); + expect(cleared).toBe(0); + expect(cache.monitor.metrics.invalidations).toBe(0); + }); + + it('should handle special characters in addresses', () => { + const key = BalanceCache.generateKey( + 1, + '0xABCDEF1234567890', + ['0xToken-1', '0xToken_2'] + ); + + expect(key).toContain('0xabcdef1234567890'); + expect(key).toContain('0xtoken-1'); + expect(key).toContain('0xtoken_2'); + }); + }); +}); diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js new file mode 100644 index 0000000..69e9bb8 --- /dev/null +++ b/test/validators/balanceValidator.test.js @@ -0,0 +1,82 @@ +/** + * Balance Validator Unit Tests + */ + +const { + balanceValidationRules, + handleValidationErrors, + SUPPORTED_CHAINS, + SUPPORTED_CHAIN_IDS, + isValidAddress, + areValidAddresses, +} = require('../../src/validators/balanceValidator'); + +describe('Balance Validator', () => { + describe('isValidAddress', () => { + test('should return true for valid address', () => { + expect(isValidAddress('0x742d35cc6634c0532925a3b844bc9e7595f0beb')).toBe(true); + }); + + test('should return false for invalid address', () => { + expect(isValidAddress('0xinvalid')).toBe(false); + expect(isValidAddress('not-an-address')).toBe(false); + expect(isValidAddress('')).toBe(false); + expect(isValidAddress(null)).toBe(false); + }); + }); + + describe('areValidAddresses', () => { + test('should return true for valid comma-separated addresses', () => { + expect(areValidAddresses('0x742d35cc6634c0532925a3b844bc9e7595f0beb,0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')).toBe(true); + }); + + test('should return true for empty string (optional)', () => { + expect(areValidAddresses('')).toBe(true); + expect(areValidAddresses(null)).toBe(true); + }); + + test('should return false for invalid address in list', () => { + expect(areValidAddresses('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xinvalid')).toBe(false); + }); + + test('should return false for more than 50 addresses', () => { + const addresses = Array(51).fill('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb').join(','); + expect(areValidAddresses(addresses)).toBe(false); + }); + }); + + describe('SUPPORTED_CHAINS', () => { + test('should contain expected chains', () => { + expect(SUPPORTED_CHAINS).toHaveProperty('1'); + expect(SUPPORTED_CHAINS).toHaveProperty('137'); + expect(SUPPORTED_CHAINS).toHaveProperty('42161'); + expect(SUPPORTED_CHAINS).toHaveProperty('8453'); + expect(SUPPORTED_CHAINS).toHaveProperty('10'); + }); + }); + + describe('SUPPORTED_CHAIN_IDS', () => { + test('should be array of numbers', () => { + expect(Array.isArray(SUPPORTED_CHAIN_IDS)).toBe(true); + expect(SUPPORTED_CHAIN_IDS.every(id => typeof id === 'number')).toBe(true); + expect(SUPPORTED_CHAIN_IDS).toContain(1); + expect(SUPPORTED_CHAIN_IDS).toContain(137); + }); + }); + + describe('handleValidationErrors', () => { + test('should call next() when no errors', () => { + const req = {}; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + const next = jest.fn(); + + // Mock validationResult to return no errors + jest.mock('express-validator', () => ({ + validationResult: jest.fn(() => ({ isEmpty: () => true })), + })); + + handleValidationErrors(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); +}); From e683e9d39c10c2fbaa849f5a4cb4f8234adc304e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Thu, 9 Oct 2025 16:39:50 +0900 Subject: [PATCH 08/29] test: increase test coverage --- test/middleware/balanceRateLimit.test.js | 99 ++++++++++++++----- test/routes/balances.test.js | 13 ++- .../priceService/PriceCache.enhanced.test.js | 7 +- test/validators/balanceValidator.test.js | 25 +++-- 4 files changed, 109 insertions(+), 35 deletions(-) diff --git a/test/middleware/balanceRateLimit.test.js b/test/middleware/balanceRateLimit.test.js index 754847c..939b154 100644 --- a/test/middleware/balanceRateLimit.test.js +++ b/test/middleware/balanceRateLimit.test.js @@ -42,6 +42,8 @@ describe('balanceRateLimit', () => { beforeEach(() => { // Use fake timers to control time-based logic like window resets jest.useFakeTimers(); + // Set a consistent starting time for Date.now() + jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); // Create a new RateLimiter instance for each test to ensure isolation limiter = new RateLimiter(); }); @@ -109,7 +111,9 @@ describe('balanceRateLimit', () => { expect(result.allowed).toBe(false); expect(result.limit).toBe('wallet'); expect(result.retryAfter).toBe(60); - expect(result.message).toContain(`Rate limit exceeded for wallet ${wallet1}`); + expect(result.message).toContain( + `Rate limit exceeded for wallet ${wallet1}` + ); expect(result.message).toContain('Maximum 10 requests per minute'); // Global count should be 10, not 11 @@ -118,14 +122,17 @@ describe('balanceRateLimit', () => { }); it('should reset the wallet limit after the time window expires', () => { + const startTime = new Date('2024-01-01T00:00:00Z').getTime(); + // Exhaust the wallet limit for (let i = 0; i < 10; i++) { limiter.checkLimit(wallet1); } expect(limiter.checkLimit(wallet1).allowed).toBe(false); - // Advance time by 60 seconds - jest.advanceTimersByTime(60 * 1000); + // Advance time by just over 60 seconds (both timers and system time) + jest.advanceTimersByTime(60001); + jest.setSystemTime(startTime + 60001); // The next request should be allowed const result = limiter.checkLimit(wallet1); @@ -197,7 +204,9 @@ describe('balanceRateLimit', () => { expect(result.limit).toBe('global'); expect(result.retryAfter).toBe(60); expect(result.message).toContain('Global rate limit exceeded'); - expect(result.message).toContain('Too many requests across all wallets'); + expect(result.message).toContain( + 'Too many requests across all wallets' + ); // Wallet count for the new wallet should be 0 const status = limiter.getStatus(wallet1); @@ -206,14 +215,17 @@ describe('balanceRateLimit', () => { }); it('should reset the global limit after the time window expires', () => { + const startTime = new Date('2024-01-01T00:00:00Z').getTime(); + // Exhaust the global limit for (let i = 0; i < 100; i++) { limiter.checkLimit(`0xwallet${i}`); } expect(limiter.checkLimit(wallet1).allowed).toBe(false); - // Advance time by 60 seconds - jest.advanceTimersByTime(60 * 1000); + // Advance time by just over 60 seconds (both timers and system time) + jest.advanceTimersByTime(60001); + jest.setSystemTime(startTime + 60001); // The next request should be allowed const result = limiter.checkLimit(wallet1); @@ -290,22 +302,27 @@ describe('balanceRateLimit', () => { }); it('should reset windows independently', () => { + const startTime = new Date('2024-01-01T00:00:00Z').getTime(); + limiter.checkLimit(wallet1); // Advance time by 30 seconds jest.advanceTimersByTime(30 * 1000); + jest.setSystemTime(startTime + 30 * 1000); limiter.checkLimit(wallet2); + limiter.checkLimit(wallet2); // Make a second request for wallet2 // Advance time by 31 seconds (61 total for wallet1, 31 for wallet2) jest.advanceTimersByTime(31 * 1000); + jest.setSystemTime(startTime + 61 * 1000); - // Wallet1 should have reset + // Wallet1 should have reset (> 60 seconds since first check) const result1 = limiter.checkLimit(wallet1); expect(result1.allowed).toBe(true); expect(limiter.getStatus(wallet1).walletCount).toBe(1); - // Wallet2 should not have reset + // Wallet2 should not have reset (only 31 seconds since first check) expect(limiter.getStatus(wallet2).walletCount).toBe(2); }); }); @@ -452,7 +469,8 @@ describe('balanceRateLimit', () => { beforeEach(() => { originalCheckLimit = singletonRateLimiter.checkLimit; originalGetStatus = singletonRateLimiter.getStatus; - singletonRateLimiter.checkLimit = (...args) => limiter.checkLimit(...args); + singletonRateLimiter.checkLimit = (...args) => + limiter.checkLimit(...args); singletonRateLimiter.getStatus = (...args) => limiter.getStatus(...args); }); @@ -463,7 +481,9 @@ describe('balanceRateLimit', () => { describe('Success path (within limits)', () => { it('should call next() and set headers for an allowed request from query', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); @@ -478,7 +498,9 @@ describe('balanceRateLimit', () => { }); it('should call next() and set headers for an allowed request from body', () => { - const { req, res, next } = makeMockReqRes({ body: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + body: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); @@ -505,7 +527,9 @@ describe('balanceRateLimit', () => { limiter.checkLimit(wallet1); } - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.headers['x-ratelimit-remaining-wallet']).toBe(4); // 10 - 6 @@ -517,7 +541,9 @@ describe('balanceRateLimit', () => { limiter.checkLimit(wallet1); } - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); @@ -532,7 +558,9 @@ describe('balanceRateLimit', () => { limiter.checkLimit(wallet1); } - const { req, res, next } = makeMockReqRes({ body: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + body: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -541,7 +569,9 @@ describe('balanceRateLimit', () => { expect(res.jsonData.limitType).toBe('wallet'); expect(res.jsonData.retryAfter).toBe(60); expect(res.jsonData.retryAfterMs).toBe(60000); - expect(res.jsonData.message).toContain('Rate limit exceeded for wallet'); + expect(res.jsonData.message).toContain( + 'Rate limit exceeded for wallet' + ); expect(res.headers['retry-after']).toBe(60); // Remaining should be 0, not negative expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); @@ -553,7 +583,9 @@ describe('balanceRateLimit', () => { limiter.checkLimit(`0xwallet${i}`); } - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -575,7 +607,9 @@ describe('balanceRateLimit', () => { // Advance time by 25 seconds jest.advanceTimersByTime(25 * 1000); - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.headers['retry-after']).toBe(35); // 60 - 25 @@ -587,7 +621,9 @@ describe('balanceRateLimit', () => { limiter.checkLimit(wallet1); } - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.jsonData).toEqual({ @@ -638,7 +674,9 @@ describe('balanceRateLimit', () => { }); it('should handle undefined wallet in body', () => { - const { req, res, next } = makeMockReqRes({ body: { wallet: undefined } }); + const { req, res, next } = makeMockReqRes({ + body: { wallet: undefined }, + }); balanceRateLimit(req, res, next); expect(next).toHaveBeenCalledTimes(1); @@ -647,7 +685,9 @@ describe('balanceRateLimit', () => { describe('Rate limit headers', () => { it('should set all required rate limit headers', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.headers['x-ratelimit-limit-wallet']).toBeDefined(); @@ -658,10 +698,14 @@ describe('balanceRateLimit', () => { }); it('should set X-RateLimit-Reset-Wallet as Unix timestamp in seconds', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); - const resetTimestamp = parseInt(res.headers['x-ratelimit-reset-wallet']); + const resetTimestamp = parseInt( + res.headers['x-ratelimit-reset-wallet'] + ); const now = Math.floor(Date.now() / 1000); // Should be a valid Unix timestamp in the future @@ -682,7 +726,9 @@ describe('balanceRateLimit', () => { }); it('should not set Retry-After header for allowed requests', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: wallet1 } }); + const { req, res, next } = makeMockReqRes({ + query: { wallet: wallet1 }, + }); balanceRateLimit(req, res, next); expect(res.headers['retry-after']).toBeUndefined(); @@ -734,6 +780,8 @@ describe('balanceRateLimit', () => { }); it('should recover after time window reset', () => { + const startTime = new Date('2024-01-01T00:00:00Z').getTime(); + // Exhaust limit for (let i = 0; i < 10; i++) { limiter.checkLimit(wallet1); @@ -744,8 +792,9 @@ describe('balanceRateLimit', () => { balanceRateLimit(mock1.req, mock1.res, mock1.next); expect(mock1.res.statusCode).toBe(429); - // Advance time - jest.advanceTimersByTime(60 * 1000); + // Advance time by just over 60 seconds (both timers and system time) + jest.advanceTimersByTime(60001); + jest.setSystemTime(startTime + 60001); // Should work again const mock2 = makeMockReqRes({ query: { wallet: wallet1 } }); diff --git a/test/routes/balances.test.js b/test/routes/balances.test.js index dbf0994..56c9a6f 100644 --- a/test/routes/balances.test.js +++ b/test/routes/balances.test.js @@ -3,6 +3,7 @@ */ jest.mock('../../src/services/balanceService'); +jest.mock('../../src/controllers/balanceController'); const express = require('express'); const request = require('supertest'); @@ -64,7 +65,9 @@ describe('Balance Routes', () => { res.json({ success: true, data: { balances: [] } }); }); - const res = await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'); + const res = await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb' + ); expect(BalanceController.getBalances).toHaveBeenCalled(); expect(res.status).toBe(200); @@ -75,7 +78,9 @@ describe('Balance Routes', () => { res.json({ success: true }); }); - await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb?tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb?tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); expect(BalanceController.getBalances).toHaveBeenCalled(); }); @@ -87,7 +92,9 @@ describe('Balance Routes', () => { res.json({ success: true, data: { balance: '1000000000000000000' } }); }); - const res = await request(app).get('/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb/native'); + const res = await request(app).get( + '/balances/1/0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb/native' + ); expect(BalanceController.getNativeBalance).toHaveBeenCalled(); expect(res.status).toBe(200); diff --git a/test/services/priceService/PriceCache.enhanced.test.js b/test/services/priceService/PriceCache.enhanced.test.js index 3d41552..0c42e54 100644 --- a/test/services/priceService/PriceCache.enhanced.test.js +++ b/test/services/priceService/PriceCache.enhanced.test.js @@ -9,6 +9,7 @@ describe('PriceCache', () => { beforeEach(() => { jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); cache = new PriceCache(); }); @@ -107,7 +108,11 @@ describe('PriceCache', () => { }); test('should track stale hits', () => { - cache.set('ETH', { price: 1000, timestamp: Date.now() - 400000 }); // 6.6 min old + // Manually set an old entry (set() would override the timestamp) + const oldTimestamp = Date.now() - 400000; // 6.6 min old + cache.cache.set('eth', { price: 1000, timestamp: oldTimestamp }); + cache.cacheTimeouts.set('eth', Date.now() + 180000); // Still valid TTL + cache.get('ETH'); const stats = cache.getStats(); diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js index 69e9bb8..5f7843e 100644 --- a/test/validators/balanceValidator.test.js +++ b/test/validators/balanceValidator.test.js @@ -3,7 +3,6 @@ */ const { - balanceValidationRules, handleValidationErrors, SUPPORTED_CHAINS, SUPPORTED_CHAIN_IDS, @@ -14,7 +13,9 @@ const { describe('Balance Validator', () => { describe('isValidAddress', () => { test('should return true for valid address', () => { - expect(isValidAddress('0x742d35cc6634c0532925a3b844bc9e7595f0beb')).toBe(true); + expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( + true + ); }); test('should return false for invalid address', () => { @@ -27,7 +28,11 @@ describe('Balance Validator', () => { describe('areValidAddresses', () => { test('should return true for valid comma-separated addresses', () => { - expect(areValidAddresses('0x742d35cc6634c0532925a3b844bc9e7595f0beb,0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')).toBe(true); + expect( + areValidAddresses( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7' + ) + ).toBe(true); }); test('should return true for empty string (optional)', () => { @@ -36,11 +41,17 @@ describe('Balance Validator', () => { }); test('should return false for invalid address in list', () => { - expect(areValidAddresses('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xinvalid')).toBe(false); + expect( + areValidAddresses( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xinvalid' + ) + ).toBe(false); }); test('should return false for more than 50 addresses', () => { - const addresses = Array(51).fill('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb').join(','); + const addresses = Array(51) + .fill('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48') + .join(','); expect(areValidAddresses(addresses)).toBe(false); }); }); @@ -58,7 +69,9 @@ describe('Balance Validator', () => { describe('SUPPORTED_CHAIN_IDS', () => { test('should be array of numbers', () => { expect(Array.isArray(SUPPORTED_CHAIN_IDS)).toBe(true); - expect(SUPPORTED_CHAIN_IDS.every(id => typeof id === 'number')).toBe(true); + expect(SUPPORTED_CHAIN_IDS.every(id => typeof id === 'number')).toBe( + true + ); expect(SUPPORTED_CHAIN_IDS).toContain(1); expect(SUPPORTED_CHAIN_IDS).toContain(137); }); From 07e5e85bd6d1ad0b8f3b8c3a61040e109903a89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Thu, 9 Oct 2025 20:35:13 +0900 Subject: [PATCH 09/29] fix: accept native token to zapIn --- src/validators/UnifiedZapValidator.js | 18 +- test/validators/UnifiedZapValidator.test.js | 338 ++++++++++++++++++++ 2 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 test/validators/UnifiedZapValidator.test.js diff --git a/src/validators/UnifiedZapValidator.js b/src/validators/UnifiedZapValidator.js index 881bf8e..592f089 100644 --- a/src/validators/UnifiedZapValidator.js +++ b/src/validators/UnifiedZapValidator.js @@ -190,15 +190,29 @@ class UnifiedZapValidator { /** * Validate input token address - * @param {string} inputToken - Input token address + * @param {string} inputToken - Input token address or 'native' keyword */ static validateInputToken(inputToken) { if (!inputToken || typeof inputToken !== 'string') { throw new Error('inputToken is required and must be a string'); } + const normalized = inputToken.toLowerCase(); + + // Allow native token identifiers + if ( + normalized === 'native' || + normalized === '0x0000000000000000000000000000000000000000' || + normalized === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ) { + return; // Valid native token identifier + } + + // Validate as Ethereum address if (!/^0x[a-fA-F0-9]{40}$/.test(inputToken)) { - throw new Error('Invalid inputToken: must be a valid Ethereum address'); + throw new Error( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); } } diff --git a/test/validators/UnifiedZapValidator.test.js b/test/validators/UnifiedZapValidator.test.js new file mode 100644 index 0000000..bf1ff12 --- /dev/null +++ b/test/validators/UnifiedZapValidator.test.js @@ -0,0 +1,338 @@ +/** + * UnifiedZapValidator - Comprehensive validation tests + */ + +const UnifiedZapValidator = require('../../src/validators/UnifiedZapValidator'); +const UNIFIED_ZAP_CONFIG = require('../../src/config/unifiedZapConfig'); + +describe('UnifiedZapValidator', () => { + describe('validateInputToken', () => { + describe('Native token identifiers', () => { + it('should accept "native" keyword', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('native'); + }).not.toThrow(); + }); + + it('should accept "native" keyword (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('NATIVE'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken('Native'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken('NaTiVe'); + }).not.toThrow(); + }); + + it('should accept zero address (0x0000...)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0x0000000000000000000000000000000000000000' + ); + }).not.toThrow(); + }); + + it('should accept zero address (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0X0000000000000000000000000000000000000000' + ); + }).not.toThrow(); + }); + + it('should accept sentinel address (0xeeee...)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + ); + }).not.toThrow(); + }); + + it('should accept sentinel address (case insensitive)', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE' + ); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEe' + ); + }).not.toThrow(); + }); + }); + + describe('Valid ERC20 addresses', () => { + it('should accept valid USDC address', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).not.toThrow(); + }); + + it('should accept valid WETH address', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + ); + }).not.toThrow(); + }); + + it('should accept address with lowercase hex', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + ); + }).not.toThrow(); + }); + + it('should accept address with uppercase hex', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48' + ); + }).not.toThrow(); + }); + }); + + describe('Invalid inputs', () => { + it('should throw error when inputToken is missing', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is null', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(null); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is empty string', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(''); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error when inputToken is not a string', () => { + expect(() => { + UnifiedZapValidator.validateInputToken(12345); + }).toThrow('inputToken is required and must be a string'); + + expect(() => { + UnifiedZapValidator.validateInputToken({}); + }).toThrow('inputToken is required and must be a string'); + + expect(() => { + UnifiedZapValidator.validateInputToken([]); + }).toThrow('inputToken is required and must be a string'); + }); + + it('should throw error for invalid address format', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('invalid-address'); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address without 0x prefix', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + 'A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address with wrong length', () => { + expect(() => { + UnifiedZapValidator.validateInputToken('0x123'); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB4812345' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + + it('should throw error for address with invalid characters', () => { + expect(() => { + UnifiedZapValidator.validateInputToken( + '0xG0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + }); + }); + + describe('validate - Full request validation', () => { + const validRequest = { + userAddress: '0x806686442aF382B627818D08dA93c96C2Fb0a981', + chainId: 8453, + params: { + strategyAllocations: [ + { + strategyId: 'stablecoin', + percentage: 100, + }, + ], + inputToken: 'native', + inputAmount: '0.002524126015179282', + slippage: 0.5, + }, + }; + + it('should validate complete request with native token', () => { + expect(() => { + UnifiedZapValidator.validate(validRequest, UNIFIED_ZAP_CONFIG); + }).not.toThrow(); + }); + + it('should validate complete request with ERC20 token', () => { + const requestWithERC20 = { + ...validRequest, + params: { + ...validRequest.params, + inputToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base + }, + }; + + expect(() => { + UnifiedZapValidator.validate(requestWithERC20, UNIFIED_ZAP_CONFIG); + }).not.toThrow(); + }); + + it('should throw error for invalid inputToken in complete request', () => { + const invalidRequest = { + ...validRequest, + params: { + ...validRequest.params, + inputToken: 'invalid-token', + }, + }; + + expect(() => { + UnifiedZapValidator.validate(invalidRequest, UNIFIED_ZAP_CONFIG); + }).toThrow( + 'Invalid inputToken: must be a valid Ethereum address or "native"' + ); + }); + }); + + describe('validateInputAmount', () => { + it('should accept valid decimal amount strings', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('0.002524126015179282'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputAmount('1.5'); + }).not.toThrow(); + + expect(() => { + UnifiedZapValidator.validateInputAmount('100'); + }).not.toThrow(); + }); + + it('should throw error for invalid amount format', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('abc'); + }).toThrow('inputAmount must be a valid positive number string'); + + expect(() => { + UnifiedZapValidator.validateInputAmount(''); + }).toThrow('inputAmount is required'); + + expect(() => { + UnifiedZapValidator.validateInputAmount(null); + }).toThrow('inputAmount is required'); + }); + + it('should throw error for zero or negative amounts', () => { + expect(() => { + UnifiedZapValidator.validateInputAmount('0'); + }).toThrow('inputAmount must be greater than 0'); + + expect(() => { + UnifiedZapValidator.validateInputAmount('-1.5'); + }).toThrow('inputAmount must be a valid positive number string'); + }); + }); + + describe('validateStrategyAllocations', () => { + it('should accept valid single strategy', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [{ strategyId: 'stablecoin', percentage: 100 }], + UNIFIED_ZAP_CONFIG + ); + }).not.toThrow(); + }); + + it('should accept valid multiple strategies', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'eth', percentage: 50 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).not.toThrow(); + }); + + it('should throw error for empty allocations', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations([], UNIFIED_ZAP_CONFIG); + }).toThrow('strategyAllocations cannot be empty'); + }); + + it('should throw error when percentages do not sum to 100', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'eth', percentage: 40 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Strategy percentages must sum to 100%'); + }); + + it('should throw error for unknown strategy', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [{ strategyId: 'unknown-strategy', percentage: 100 }], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Unknown strategy: unknown-strategy'); + }); + + it('should throw error for duplicate strategies', () => { + expect(() => { + UnifiedZapValidator.validateStrategyAllocations( + [ + { strategyId: 'stablecoin', percentage: 50 }, + { strategyId: 'stablecoin', percentage: 50 }, + ], + UNIFIED_ZAP_CONFIG + ); + }).toThrow('Duplicate strategy ID: stablecoin'); + }); + }); +}); From 260d96040a0c671296ccb2029ed7f3125832a89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Fri, 10 Oct 2025 15:44:57 +0900 Subject: [PATCH 10/29] refactor: remove dead code --- .../__tests__/balanceCache.enhanced.test.js | 310 ------------------ src/validators/UnifiedZapValidator.js | 102 ------ 2 files changed, 412 deletions(-) delete mode 100644 src/utils/__tests__/balanceCache.enhanced.test.js diff --git a/src/utils/__tests__/balanceCache.enhanced.test.js b/src/utils/__tests__/balanceCache.enhanced.test.js deleted file mode 100644 index 33e3d42..0000000 --- a/src/utils/__tests__/balanceCache.enhanced.test.js +++ /dev/null @@ -1,310 +0,0 @@ -/** - * Enhanced Balance Cache Tests - * Testing TTL alignment, monitoring, partial updates, and cache invalidation - */ - -const BalanceCache = require('../balanceCache.enhanced'); - -describe('Enhanced BalanceCache', () => { - let cache; - - beforeEach(() => { - jest.clearAllMocks(); - cache = new BalanceCache(180000); // 3 minutes TTL - }); - - afterEach(() => { - cache.stopCleanup(); - }); - - describe('Cache TTL Alignment', () => { - it('should expire cache after 3 minutes (aligned with price cache)', done => { - const key = BalanceCache.generateKey('1', '0x123', null); - const data = { - balances: [{ tokenAddress: '0xAAA', balance: '1000' }], - timestamp: Date.now(), - }; - - cache.set(key, data); - - // Should be cached immediately - expect(cache.get(key)).toBeTruthy(); - - // Should still be cached after 2 minutes - setTimeout(() => { - expect(cache.get(key)).toBeTruthy(); - }, 120000); - - // Should be expired after 3 minutes - setTimeout(() => { - expect(cache.get(key)).toBeNull(); - done(); - }, 180000); - }, 200000); - - it('should align with backend TTL (180000ms = 180 seconds)', () => { - expect(cache.defaultTTL).toBe(180000); - }); - }); - - describe('Monitoring and Metrics', () => { - it('should track cache hits and misses', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - const data = { balances: [], timestamp: Date.now() }; - - // Miss - no data - expect(cache.get(key)).toBeNull(); - - // Set data - cache.set(key, data); - - // Hit - data exists - expect(cache.get(key)).toBeTruthy(); - expect(cache.get(key)).toBeTruthy(); - - const stats = cache.getStats(); - expect(stats.hits).toBe(2); - expect(stats.misses).toBe(1); - expect(stats.sets).toBe(1); - }); - - it('should detect stale cache hits', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - - const key = BalanceCache.generateKey('1', '0x123', null); - const staleData = { - balances: [], - timestamp: Date.now() - 600000, // 10 minutes old - }; - - cache.set(key, staleData); - cache.get(key); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Stale cache hit') - ); - - consoleWarnSpy.mockRestore(); - }); - - it('should calculate hit rate correctly', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - cache.set(key, { balances: [], timestamp: Date.now() }); - - cache.get(key); // hit - cache.get(key); // hit - cache.get('nonexistent'); // miss - - const stats = cache.getStats(); - expect(stats.hitRate).toBe('66.67%'); // 2 hits / 3 total - }); - - it('should track average cache age', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - const timestamp = Date.now() - 30000; // 30 seconds old - - cache.set(key, { balances: [], timestamp }); - cache.get(key); - - const stats = cache.getStats(); - expect(stats.avgCacheAge).toMatch(/30\./); // ~30 seconds - }); - }); - - describe('Partial Cache Updates', () => { - it('should update specific token balance without full invalidation', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - const originalData = { - balances: [ - { tokenAddress: '0xAAA', balance: '1000' }, - { tokenAddress: '0xBBB', balance: '2000' }, - ], - timestamp: Date.now(), - }; - - cache.set(key, originalData); - - // Update single token - const updated = cache.updateTokenBalance('1', '0x123', '0xAAA', '1500'); - expect(updated).toBe(true); - - const cachedData = cache.get(key); - expect(cachedData.balances[0].balance).toBe('1500'); - expect(cachedData.balances[1].balance).toBe('2000'); // Unchanged - expect(cachedData.partialUpdate).toBe(true); - - const stats = cache.getStats(); - expect(stats.partialUpdates).toBe(1); - }); - - it('should handle partial update for non-existent token', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - const data = { - balances: [{ tokenAddress: '0xAAA', balance: '1000' }], - timestamp: Date.now(), - }; - - cache.set(key, data); - - const updated = cache.updateTokenBalance('1', '0x123', '0xCCC', '3000'); - expect(updated).toBe(false); // Token not found, no update - }); - - it('should refresh specific tokens without full cache invalidation', async () => { - const key = BalanceCache.generateKey('1', '0x123', null); - const originalData = { - balances: [ - { tokenAddress: '0xAAA', balance: '1000' }, - { tokenAddress: '0xBBB', balance: '2000' }, - ], - timestamp: Date.now() - 60000, // 1 minute old - }; - - cache.set(key, originalData); - - // Mock fetch function - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xAAA', balance: '1500' }], - timestamp: Date.now(), - }); - - const refreshed = await cache.refreshTokens( - '1', - '0x123', - ['0xAAA'], - fetchFn - ); - - expect(fetchFn).toHaveBeenCalledWith('1', '0x123', ['0xAAA']); - expect(refreshed.balances).toHaveLength(2); // Merged result - expect(refreshed.partialUpdate).toBe(true); - expect( - refreshed.balances.find(b => b.tokenAddress === '0xAAA').balance - ).toBe('1500'); - expect( - refreshed.balances.find(b => b.tokenAddress === '0xBBB').balance - ).toBe('2000'); - }); - }); - - describe('Cache Invalidation', () => { - it('should invalidate cache after transaction', () => { - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - - const key = BalanceCache.generateKey('1', '0x123', null); - cache.set(key, { balances: [], timestamp: Date.now() }); - - const cleared = cache.invalidateAfterTransaction('1', '0x123', 'zapIn'); - - expect(cleared).toBe(1); - expect(cache.get(key)).toBeNull(); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalidated: address 0x123 (transaction:zapIn)' - ) - ); - - consoleLogSpy.mockRestore(); - }); - - it('should clear all caches for an address with reason', () => { - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - - cache.set(BalanceCache.generateKey('1', '0x123', ['0xAAA']), { - balances: [], - }); - cache.set(BalanceCache.generateKey('1', '0x123', ['0xBBB']), { - balances: [], - }); - cache.set(BalanceCache.generateKey('42161', '0x123', null), { - balances: [], - }); - - const cleared = cache.clearAddress('0x123', 'balance_changed'); - - expect(cleared).toBe(3); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalidated: address 0x123 (balance_changed)') - ); - - consoleLogSpy.mockRestore(); - }); - - it('should clear all caches for a chain with reason', () => { - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - - cache.set(BalanceCache.generateKey('1', '0x123', null), { balances: [] }); - cache.set(BalanceCache.generateKey('1', '0x456', null), { balances: [] }); - cache.set(BalanceCache.generateKey('42161', '0x123', null), { - balances: [], - }); - - const cleared = cache.clearChain('1', 'chain_reorg'); - - expect(cleared).toBe(2); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalidated: chain 1 (chain_reorg)') - ); - - consoleLogSpy.mockRestore(); - }); - }); - - describe('Cache Health Reporting', () => { - it('should log comprehensive health report', () => { - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - - const key = BalanceCache.generateKey('1', '0x123', null); - cache.set(key, { balances: [], timestamp: Date.now() }); - cache.get(key); - - cache.logHealthReport(); - - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Balance Cache Health Report') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Cache Size:') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Hit Rate:') - ); - - consoleLogSpy.mockRestore(); - }); - - it('should provide detailed stats', () => { - const key = BalanceCache.generateKey('1', '0x123', null); - cache.set(key, { balances: [], timestamp: Date.now() }); - cache.get(key); - cache.get('nonexistent'); - - const stats = cache.getStats(); - - expect(stats).toHaveProperty('size', 1); - expect(stats).toHaveProperty('hits', 1); - expect(stats).toHaveProperty('misses', 1); - expect(stats).toHaveProperty('sets', 1); - expect(stats).toHaveProperty('hitRate', '50.00%'); - expect(stats).toHaveProperty('avgCacheAge'); - expect(stats).toHaveProperty('memoryUsage'); - expect(stats).toHaveProperty('partialUpdates', 0); - expect(stats).toHaveProperty('invalidations', 0); - }); - }); - - describe('Backward Compatibility', () => { - it('should maintain same key generation logic', () => { - const key1 = BalanceCache.generateKey('1', '0xAbC', null); - const key2 = BalanceCache.generateKey('1', '0xabc', null); - - expect(key1).toBe(key2); // Case insensitive - expect(key1).toBe('balance:1:0xabc:all'); - }); - - it('should maintain same key format for token arrays', () => { - const key = BalanceCache.generateKey('1', '0x123', ['0xBBB', '0xAAA']); - expect(key).toBe('balance:1:0x123:0xaaa,0xbbb'); // Sorted - }); - }); -}); diff --git a/src/validators/UnifiedZapValidator.js b/src/validators/UnifiedZapValidator.js index 592f089..c2f0eee 100644 --- a/src/validators/UnifiedZapValidator.js +++ b/src/validators/UnifiedZapValidator.js @@ -329,36 +329,6 @@ class UnifiedZapValidator { } } - /** - * Validate strategy is available on the requested chain - * @param {string} strategyId - Strategy ID - * @param {number} chainId - Chain ID - * @param {Object} config - UnifiedZap configuration - * @returns {boolean} - Whether strategy is available on chain - */ - static isStrategyAvailableOnChain( - strategyId, - chainId, - config = UNIFIED_ZAP_CONFIG - ) { - const strategyConfig = config.STRATEGY_CATEGORIES[strategyId]; - if (!strategyConfig) { - return false; - } - - const chainName = this.getChainNameById(chainId, config); - if (!chainName) { - return false; - } - - // Check if strategy has protocols on this chain - const chainProtocols = strategyConfig.protocols.filter( - p => p.chain === chainName && p.enabled !== false - ); - - return chainProtocols.length > 0; - } - /** * Get chain name by chain ID * @param {number} chainId - Chain ID @@ -375,78 +345,6 @@ class UnifiedZapValidator { } return null; } - - /** - * Get validation summary for debugging - * @param {Object} request - Intent request - * @param {Object} config - UnifiedZap configuration - * @returns {Object} - Validation summary - */ - static getValidationSummary(request, config = UNIFIED_ZAP_CONFIG) { - try { - this.validate(request, config); - - const { params } = request; - const chainName = this.getChainNameById(request.chainId, config); - - return { - valid: true, - chainName, - totalStrategies: params.strategyAllocations.length, - totalPercentage: params.strategyAllocations.reduce( - (sum, s) => sum + s.percentage, - 0 - ), - strategiesOnChain: params.strategyAllocations.filter(s => - this.isStrategyAvailableOnChain(s.strategyId, request.chainId, config) - ).length, - estimatedComplexity: this.calculateComplexity( - params.strategyAllocations, - config - ), - }; - } catch (error) { - return { - valid: false, - error: error.message, - errorType: error.constructor.name, - }; - } - } - - /** - * Calculate request complexity score - * @param {Array} strategyAllocations - Strategy allocations - * @param {Object} config - UnifiedZap configuration - * @returns {number} - Complexity score (1-10) - */ - static calculateComplexity(strategyAllocations, config) { - let complexity = 1; - - // Base complexity from strategy count - complexity += strategyAllocations.length * 0.5; - - // Protocol count complexity - const totalProtocols = strategyAllocations.reduce((total, allocation) => { - const strategyConfig = config.STRATEGY_CATEGORIES[allocation.strategyId]; - return total + strategyConfig.protocols.length; - }, 0); - - complexity += Math.min(totalProtocols * 0.3, 3); - - // Multi-chain complexity (if strategies span multiple chains) - const chains = new Set(); - strategyAllocations.forEach(allocation => { - const strategyConfig = config.STRATEGY_CATEGORIES[allocation.strategyId]; - strategyConfig.protocols.forEach(protocol => chains.add(protocol.chain)); - }); - - if (chains.size > 1) { - complexity += 2; - } - - return Math.min(Math.ceil(complexity), 10); - } } module.exports = UnifiedZapValidator; From a0b9c8b9c4dfbc0819fff5fa5bc40060842fc2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Fri, 10 Oct 2025 16:27:47 +0900 Subject: [PATCH 11/29] chore: remove abandoned enhanced cache files - Remove experimental balanceCache.enhanced.js and PriceCache.enhanced.js - Remove associated test files (109 tests removed) - Files were only used for test coverage experiments - Production uses standard versions without .enhanced suffix - No production code affected, all remaining tests pass --- .../priceService/PriceCache.enhanced.js | 205 --- src/utils/balanceCache.enhanced.js | 468 ------- .../priceService/PriceCache.enhanced.test.js | 154 --- test/utils/balanceCache.enhanced.test.js | 1188 ----------------- 4 files changed, 2015 deletions(-) delete mode 100644 src/services/priceService/PriceCache.enhanced.js delete mode 100644 src/utils/balanceCache.enhanced.js delete mode 100644 test/services/priceService/PriceCache.enhanced.test.js delete mode 100644 test/utils/balanceCache.enhanced.test.js diff --git a/src/services/priceService/PriceCache.enhanced.js b/src/services/priceService/PriceCache.enhanced.js deleted file mode 100644 index 838217e..0000000 --- a/src/services/priceService/PriceCache.enhanced.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Enhanced Price Cache - Handles caching concerns for price data with monitoring - * - * IMPROVEMENTS: - * - Aligned TTL with balance cache (3 minutes) - * - Added cache monitoring and metrics - * - Stale data detection - * - Improved logging - */ - -class PriceCacheMonitor { - constructor() { - this.metrics = { - hits: 0, - misses: 0, - staleHits: 0, - sets: 0, - totalCacheAge: 0, - }; - this.staleThreshold = 300000; // 5 minutes - } - - recordHit(cacheAge) { - this.metrics.hits++; - this.metrics.totalCacheAge += cacheAge; - - if (cacheAge > this.staleThreshold) { - this.metrics.staleHits++; - console.warn( - `[PriceCache] Stale price hit: ${(cacheAge / 1000).toFixed(1)}s old` - ); - } - } - - recordMiss() { - this.metrics.misses++; - } - - recordSet() { - this.metrics.sets++; - } - - getHitRate() { - const total = this.metrics.hits + this.metrics.misses; - return total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) : '0.00'; - } - - getAvgCacheAge() { - return this.metrics.hits > 0 - ? Math.round(this.metrics.totalCacheAge / this.metrics.hits) - : 0; - } - - getStats() { - return { - ...this.metrics, - hitRate: `${this.getHitRate()}%`, - avgCacheAge: `${(this.getAvgCacheAge() / 1000).toFixed(1)}s`, - staleHitRate: - this.metrics.hits > 0 - ? `${((this.metrics.staleHits / this.metrics.hits) * 100).toFixed(2)}%` - : '0.00%', - }; - } - - reset() { - this.metrics = { - hits: 0, - misses: 0, - staleHits: 0, - sets: 0, - totalCacheAge: 0, - }; - } -} - -class PriceCache { - constructor() { - this.cache = new Map(); - this.cacheTimeouts = new Map(); - this.monitor = new PriceCacheMonitor(); - } - - /** - * Get cached price if available and not expired (with monitoring) - * @param {string} symbol - Token symbol - * @returns {Object|null} - Cached price data or null - */ - get(symbol) { - const cacheKey = symbol.toLowerCase(); - const cached = this.cache.get(cacheKey); - const timeout = this.cacheTimeouts.get(cacheKey); - - if (cached && timeout && Date.now() < timeout) { - const cacheAge = Date.now() - (cached.timestamp || 0); - this.monitor.recordHit(cacheAge); - return cached; - } - - // Clean up expired cache entries - if (cached) { - this.cache.delete(cacheKey); - this.cacheTimeouts.delete(cacheKey); - } - - this.monitor.recordMiss(); - return null; - } - - /** - * Set price in cache with TTL (aligned with balance cache: 3 minutes) - * @param {string} symbol - Token symbol - * @param {Object} priceData - Price data to cache - * @param {number} ttl - Time to live in seconds (default: 180s = 3 minutes) - */ - set(symbol, priceData, ttl = 180) { - const cacheKey = symbol.toLowerCase(); - - // Add timestamp to price data - const dataWithTimestamp = { - ...priceData, - timestamp: Date.now(), - }; - - this.cache.set(cacheKey, dataWithTimestamp); - this.cacheTimeouts.set(cacheKey, Date.now() + ttl * 1000); - this.monitor.recordSet(); - } - - /** - * Get cache statistics with monitoring data - * @returns {Object} - Enhanced cache info - */ - getStats() { - const monitorStats = this.monitor.getStats(); - - return { - size: this.cache.size, - entries: Array.from(this.cache.keys()), - ...monitorStats, - }; - } - - /** - * Clear cache - */ - clear() { - this.cache.clear(); - this.cacheTimeouts.clear(); - } - - /** - * Get cached prices for multiple symbols - * @param {Array} symbols - Array of token symbols - * @returns {Object} - Object with results and remaining symbols - */ - getBulk(symbols) { - const results = {}; - const remaining = new Set(symbols.map(s => s.toLowerCase())); - - for (const symbol of symbols) { - const cached = this.get(symbol); - if (cached) { - results[symbol.toLowerCase()] = { - ...cached, - fromCache: true, - }; - remaining.delete(symbol.toLowerCase()); - } - } - - return { - results, - remaining: Array.from(remaining), - }; - } - - /** - * Reset monitoring statistics - */ - resetStats() { - this.monitor.reset(); - } - - /** - * Log detailed cache health report - */ - logHealthReport() { - const stats = this.getStats(); - - console.warn('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.warn(' Price Cache Health Report'); - console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.warn(` Cache Size: ${stats.size} tokens`); - console.warn(` Hit Rate: ${stats.hitRate}`); - console.warn(` Avg Cache Age: ${stats.avgCacheAge}`); - console.warn(` Stale Hit Rate: ${stats.staleHitRate}`); - console.warn(` Total Hits: ${stats.hits}`); - console.warn(` Total Misses: ${stats.misses}`); - console.warn(` Total Sets: ${stats.sets}`); - console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - } -} - -module.exports = PriceCache; diff --git a/src/utils/balanceCache.enhanced.js b/src/utils/balanceCache.enhanced.js deleted file mode 100644 index f1cd7c7..0000000 --- a/src/utils/balanceCache.enhanced.js +++ /dev/null @@ -1,468 +0,0 @@ -/** - * Enhanced In-Memory Cache Manager for Token Balances - * - * NEW FEATURES: - * - Partial cache updates (update single tokens without full invalidation) - * - Cache monitoring and metrics - * - Stale data detection and warnings - * - Improved logging and observability - * - Cache age tracking - * - * Cache Key Format: balance:{chainId}:{address}:{tokenAddresses} - * Default TTL: 3 minutes (aligned with price cache) - */ - -class BalanceCacheMonitor { - constructor() { - this.metrics = { - hits: 0, - misses: 0, - staleHits: 0, - sets: 0, - evictions: 0, - invalidations: 0, - partialUpdates: 0, - totalCacheAge: 0, - }; - this.staleThreshold = 300000; // 5 minutes - warn if cache older than this - } - - recordHit(cacheAge) { - this.metrics.hits++; - this.metrics.totalCacheAge += cacheAge; - - if (cacheAge > this.staleThreshold) { - this.metrics.staleHits++; - console.warn( - `[BalanceCache] Stale cache hit: ${(cacheAge / 1000).toFixed(1)}s old` - ); - } - } - - recordMiss() { - this.metrics.misses++; - } - - recordSet() { - this.metrics.sets++; - } - - recordEviction() { - this.metrics.evictions++; - } - - recordInvalidation(reason) { - this.metrics.invalidations++; - console.warn(`[BalanceCache] Invalidated: ${reason}`); - } - - recordPartialUpdate() { - this.metrics.partialUpdates++; - } - - getHitRate() { - const total = this.metrics.hits + this.metrics.misses; - return total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) : '0.00'; - } - - getAvgCacheAge() { - return this.metrics.hits > 0 - ? Math.round(this.metrics.totalCacheAge / this.metrics.hits) - : 0; - } - - getStats() { - return { - ...this.metrics, - hitRate: `${this.getHitRate()}%`, - avgCacheAge: `${(this.getAvgCacheAge() / 1000).toFixed(1)}s`, - staleHitRate: - this.metrics.hits > 0 - ? `${((this.metrics.staleHits / this.metrics.hits) * 100).toFixed(2)}%` - : '0.00%', - }; - } - - reset() { - this.metrics = { - hits: 0, - misses: 0, - staleHits: 0, - sets: 0, - evictions: 0, - invalidations: 0, - partialUpdates: 0, - totalCacheAge: 0, - }; - } -} - -class BalanceCache { - constructor(defaultTTL = 180000) { - // 3 minutes default (aligned with price cache) - this.cache = new Map(); - this.expirations = new Map(); - this.defaultTTL = defaultTTL; - this.cleanupInterval = null; - this.monitor = new BalanceCacheMonitor(); - - // Start cleanup interval (runs every minute) - this.startCleanup(); - } - - /** - * Generate cache key from balance request parameters - * @param {string|number} chainId - Chain ID - * @param {string} address - Wallet address - * @param {string[]} [tokenAddresses] - Optional array of token addresses - * @returns {string} - Cache key - */ - static generateKey(chainId, address, tokenAddresses = null) { - const normalizedChain = String(chainId).toLowerCase(); - const normalizedAddress = address.toLowerCase(); - - if (tokenAddresses && tokenAddresses.length > 0) { - // Sort token addresses for consistent keys - const sortedTokens = [...tokenAddresses] - .map(t => t.toLowerCase()) - .sort() - .join(','); - return `balance:${normalizedChain}:${normalizedAddress}:${sortedTokens}`; - } - - return `balance:${normalizedChain}:${normalizedAddress}:all`; - } - - /** - * Get cached balance data with monitoring - * @param {string} key - Cache key - * @returns {Object|null} - Cached balance data or null if expired/missing - */ - get(key) { - const expiresAt = this.expirations.get(key); - - // Check if expired - if (!expiresAt || Date.now() >= expiresAt) { - if (this.cache.has(key)) { - this.delete(key); - this.monitor.recordEviction(); - } - this.monitor.recordMiss(); - return null; - } - - const data = this.cache.get(key); - if (data) { - const cacheAge = Date.now() - data.timestamp; - this.monitor.recordHit(cacheAge); - return data; - } - - this.monitor.recordMiss(); - return null; - } - - /** - * Set balance data in cache with TTL - * @param {string} key - Cache key - * @param {Object} data - Balance data to cache - * @param {number} [ttl] - Time to live in milliseconds (optional) - */ - set(key, data, ttl = null) { - const expiresAt = Date.now() + (ttl || this.defaultTTL); - - // Add timestamp to data if not present - const dataWithTimestamp = { - ...data, - timestamp: data.timestamp || Date.now(), - }; - - this.cache.set(key, dataWithTimestamp); - this.expirations.set(key, expiresAt); - this.monitor.recordSet(); - } - - /** - * Update specific token balance without clearing entire cache - * @param {string|number} chainId - Chain ID - * @param {string} address - Wallet address - * @param {string} tokenAddress - Token address to update - * @param {string} newBalance - New balance value - * @returns {boolean} - True if update successful, false if cache miss - */ - updateTokenBalance(chainId, address, tokenAddress, newBalance) { - const cacheKey = BalanceCache.generateKey(chainId, address, null); - const cached = this.get(cacheKey); - - if (!cached || !cached.balances) { - return false; - } - - // Update specific token in cached balances - const updatedBalances = cached.balances.map(bal => - bal.tokenAddress.toLowerCase() === tokenAddress.toLowerCase() - ? { ...bal, balance: newBalance, lastUpdated: Date.now() } - : bal - ); - - // Check if token was found and updated - const wasUpdated = updatedBalances.some( - bal => bal.lastUpdated && bal.lastUpdated === Date.now() - ); - - if (wasUpdated) { - this.set(cacheKey, { - ...cached, - balances: updatedBalances, - partialUpdate: true, - lastPartialUpdate: Date.now(), - }); - - this.monitor.recordPartialUpdate(); - console.warn( - `[BalanceCache] Partial update: ${tokenAddress} on chain ${chainId}` - ); - } - - return wasUpdated; - } - - /** - * Refresh specific tokens without full cache invalidation - * @param {string|number} chainId - Chain ID - * @param {string} address - Wallet address - * @param {string[]} tokenAddresses - Array of token addresses to refresh - * @param {Function} fetchFn - Async function to fetch fresh token balances - * @returns {Promise} - Updated cache data - */ - async refreshTokens(chainId, address, tokenAddresses, fetchFn) { - const cacheKey = BalanceCache.generateKey(chainId, address, null); - const cached = this.get(cacheKey); - - // Fetch fresh data for specified tokens - const freshData = await fetchFn(chainId, address, tokenAddresses); - - if (!cached) { - // No existing cache, set fresh data - this.set(cacheKey, freshData); - return freshData; - } - - // Merge fresh data with cached data - const tokenAddressSet = new Set(tokenAddresses.map(t => t.toLowerCase())); - const mergedBalances = [ - // Keep cached balances for tokens not being refreshed - ...cached.balances.filter( - bal => !tokenAddressSet.has(bal.tokenAddress.toLowerCase()) - ), - // Add fresh balances for refreshed tokens - ...freshData.balances, - ]; - - const updated = { - ...cached, - balances: mergedBalances, - partialUpdate: true, - lastPartialUpdate: Date.now(), - timestamp: Date.now(), - }; - - this.set(cacheKey, updated); - this.monitor.recordPartialUpdate(); - - console.warn( - `[BalanceCache] Refreshed ${tokenAddresses.length} tokens on chain ${chainId}` - ); - return updated; - } - - /** - * Delete specific cache entry - * @param {string} key - Cache key - * @returns {boolean} - True if deleted, false if not found - */ - delete(key) { - const deleted = this.cache.delete(key); - this.expirations.delete(key); - return deleted; - } - - /** - * Clear all cache entries for a specific address (with reason logging) - * @param {string} address - Wallet address - * @param {string} reason - Reason for invalidation - * @returns {number} - Number of entries cleared - */ - clearAddress(address, reason = 'manual') { - const normalizedAddress = address.toLowerCase(); - let cleared = 0; - - for (const key of this.cache.keys()) { - if (key.includes(`:${normalizedAddress}:`)) { - this.delete(key); - cleared++; - } - } - - if (cleared > 0) { - this.monitor.recordInvalidation(`address ${address} (${reason})`); - } - - return cleared; - } - - /** - * Clear all cache entries for a specific chain - * @param {string|number} chainId - Chain ID - * @param {string} reason - Reason for invalidation - * @returns {number} - Number of entries cleared - */ - clearChain(chainId, reason = 'manual') { - const normalizedChain = String(chainId).toLowerCase(); - let cleared = 0; - - for (const key of this.cache.keys()) { - if (key.startsWith(`balance:${normalizedChain}:`)) { - this.delete(key); - cleared++; - } - } - - if (cleared > 0) { - this.monitor.recordInvalidation(`chain ${chainId} (${reason})`); - } - - return cleared; - } - - /** - * Invalidate cache after transaction completion - * @param {string|number} chainId - Chain ID where transaction occurred - * @param {string} address - Wallet address - * @param {string} txType - Transaction type (zapIn, zapOut, rebalance, etc.) - * @returns {number} - Number of entries cleared - */ - invalidateAfterTransaction(chainId, address, txType) { - const reason = `transaction:${txType}`; - return this.clearAddress(address, reason); - } - - /** - * Clear all cache entries - */ - clear() { - const size = this.cache.size; - this.cache.clear(); - this.expirations.clear(); - this.monitor.recordInvalidation(`full clear (${size} entries)`); - } - - /** - * Clean up expired entries - * @returns {number} - Number of entries cleaned - */ - cleanup() { - const now = Date.now(); - let cleaned = 0; - - for (const [key, expiresAt] of this.expirations.entries()) { - if (now >= expiresAt) { - this.delete(key); - cleaned++; - this.monitor.recordEviction(); - } - } - - return cleaned; - } - - /** - * Start automatic cleanup interval - * @param {number} [interval] - Cleanup interval in ms (default: 60000) - */ - startCleanup(interval = 60000) { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - } - - this.cleanupInterval = setInterval(() => { - const cleaned = this.cleanup(); - if (cleaned > 0) { - console.warn(`[BalanceCache] Cleaned up ${cleaned} expired entries`); - } - }, interval); - - // Prevent cleanup interval from keeping process alive - if (this.cleanupInterval.unref) { - this.cleanupInterval.unref(); - } - } - - /** - * Stop automatic cleanup interval - */ - stopCleanup() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - } - - /** - * Get cache statistics with monitoring data - * @returns {Object} - Enhanced cache stats - */ - getStats() { - const monitorStats = this.monitor.getStats(); - - return { - size: this.cache.size, - defaultTTL: this.defaultTTL, - ...monitorStats, - memoryUsage: this.estimateMemoryUsage(), - }; - } - - /** - * Estimate memory usage (rough approximation) - * @returns {string} - Memory usage estimate - */ - estimateMemoryUsage() { - const sizeKB = Math.ceil(this.cache.size * 0.5); // Rough estimate: ~0.5KB per entry - if (sizeKB < 1024) { - return `${sizeKB} KB`; - } - return `${(sizeKB / 1024).toFixed(2)} MB`; - } - - /** - * Reset cache statistics - */ - resetStats() { - this.monitor.reset(); - } - - /** - * Log detailed cache health report - */ - logHealthReport() { - const stats = this.getStats(); - - console.warn('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.warn(' Balance Cache Health Report'); - console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.warn(` Cache Size: ${stats.size} entries`); - console.warn(` Hit Rate: ${stats.hitRate}`); - console.warn(` Avg Cache Age: ${stats.avgCacheAge}`); - console.warn(` Stale Hit Rate: ${stats.staleHitRate}`); - console.warn(` Total Hits: ${stats.hits}`); - console.warn(` Total Misses: ${stats.misses}`); - console.warn(` Partial Updates: ${stats.partialUpdates}`); - console.warn(` Invalidations: ${stats.invalidations}`); - console.warn(` Memory Usage: ${stats.memoryUsage}`); - console.warn(` TTL: ${stats.defaultTTL / 1000}s`); - console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - } -} - -module.exports = BalanceCache; diff --git a/test/services/priceService/PriceCache.enhanced.test.js b/test/services/priceService/PriceCache.enhanced.test.js deleted file mode 100644 index 0c42e54..0000000 --- a/test/services/priceService/PriceCache.enhanced.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Enhanced Price Cache Tests - */ - -const PriceCache = require('../../../src/services/priceService/PriceCache.enhanced'); - -describe('PriceCache', () => { - let cache; - - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - cache = new PriceCache(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('get/set', () => { - test('should set and get price data', () => { - const priceData = { price: 1000, symbol: 'ETH' }; - cache.set('ETH', priceData); - - const result = cache.get('ETH'); - expect(result).toMatchObject(priceData); - expect(result.timestamp).toBeDefined(); - }); - - test('should be case insensitive', () => { - cache.set('ETH', { price: 1000 }); - - const result = cache.get('eth'); - expect(result).toBeTruthy(); - expect(result.price).toBe(1000); - }); - - test('should return null for expired data', () => { - cache.set('ETH', { price: 1000 }, 1); - jest.advanceTimersByTime(2000); - - const result = cache.get('ETH'); - expect(result).toBeNull(); - }); - - test('should return null for missing symbol', () => { - const result = cache.get('NONEXISTENT'); - expect(result).toBeNull(); - }); - }); - - describe('getBulk', () => { - test('should get multiple symbols at once', () => { - cache.set('ETH', { price: 1000 }); - cache.set('BTC', { price: 30000 }); - - const result = cache.getBulk(['ETH', 'BTC', 'USDC']); - - expect(result.results.eth).toBeDefined(); - expect(result.results.btc).toBeDefined(); - expect(result.remaining).toContain('usdc'); - }); - - test('should mark cached results', () => { - cache.set('ETH', { price: 1000 }); - - const result = cache.getBulk(['ETH']); - expect(result.results.eth.fromCache).toBe(true); - }); - }); - - describe('clear', () => { - test('should clear all cache', () => { - cache.set('ETH', { price: 1000 }); - cache.set('BTC', { price: 30000 }); - - cache.clear(); - - expect(cache.get('ETH')).toBeNull(); - expect(cache.get('BTC')).toBeNull(); - }); - }); - - describe('getStats', () => { - test('should return cache statistics', () => { - cache.set('ETH', { price: 1000 }); - cache.get('ETH'); - cache.get('NONEXISTENT'); - - const stats = cache.getStats(); - expect(stats).toHaveProperty('size'); - expect(stats).toHaveProperty('hits'); - expect(stats).toHaveProperty('misses'); - expect(stats).toHaveProperty('hitRate'); - expect(stats).toHaveProperty('avgCacheAge'); - }); - }); - - describe('monitoring', () => { - test('should track hits and misses', () => { - cache.set('ETH', { price: 1000 }); - cache.get('ETH'); // hit - cache.get('BTC'); // miss - - const stats = cache.getStats(); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(1); - }); - - test('should track stale hits', () => { - // Manually set an old entry (set() would override the timestamp) - const oldTimestamp = Date.now() - 400000; // 6.6 min old - cache.cache.set('eth', { price: 1000, timestamp: oldTimestamp }); - cache.cacheTimeouts.set('eth', Date.now() + 180000); // Still valid TTL - - cache.get('ETH'); - - const stats = cache.getStats(); - expect(stats.staleHits).toBe(1); - }); - - test('should calculate hit rate', () => { - cache.set('ETH', { price: 1000 }); - cache.get('ETH'); // hit - cache.get('ETH'); // hit - cache.get('BTC'); // miss - - const stats = cache.getStats(); - expect(stats.hitRate).toBe('66.67%'); - }); - - test('should track average cache age', () => { - cache.set('ETH', { price: 1000 }); - jest.advanceTimersByTime(5000); - cache.get('ETH'); - - const stats = cache.getStats(); - expect(stats.avgCacheAge).toContain('s'); - }); - }); - - describe('resetStats', () => { - test('should reset monitoring metrics', () => { - cache.set('ETH', { price: 1000 }); - cache.get('ETH'); - - cache.resetStats(); - - const stats = cache.getStats(); - expect(stats.hits).toBe(0); - expect(stats.misses).toBe(0); - }); - }); -}); diff --git a/test/utils/balanceCache.enhanced.test.js b/test/utils/balanceCache.enhanced.test.js deleted file mode 100644 index e08ffd0..0000000 --- a/test/utils/balanceCache.enhanced.test.js +++ /dev/null @@ -1,1188 +0,0 @@ -/** - * Unit Tests for Enhanced Balance Cache (balanceCache.enhanced.js) - * - * Tests cover: - * - BalanceCacheMonitor: metric tracking, stale detection, statistics - * - BalanceCache: CRUD operations, TTL expiration, partial updates, async refresh, - * invalidation methods, cleanup intervals, monitoring integration - * - * Uses Jest fake timers for TTL testing and achieves 90%+ code coverage - */ - -const BalanceCache = require('../../src/utils/balanceCache.enhanced'); - -describe('BalanceCacheMonitor', () => { - let cache; - let monitor; - - beforeEach(() => { - cache = new BalanceCache(); - monitor = cache.monitor; - }); - - afterEach(() => { - cache.stopCleanup(); - }); - - describe('Initial State', () => { - it('should initialize with zero metrics', () => { - expect(monitor.metrics.hits).toBe(0); - expect(monitor.metrics.misses).toBe(0); - expect(monitor.metrics.staleHits).toBe(0); - expect(monitor.metrics.sets).toBe(0); - expect(monitor.metrics.evictions).toBe(0); - expect(monitor.metrics.invalidations).toBe(0); - expect(monitor.metrics.partialUpdates).toBe(0); - expect(monitor.metrics.totalCacheAge).toBe(0); - }); - - it('should have staleThreshold of 300000ms (5 minutes)', () => { - expect(monitor.staleThreshold).toBe(300000); - }); - }); - - describe('recordHit', () => { - it('should increment hits counter', () => { - monitor.recordHit(1000); - expect(monitor.metrics.hits).toBe(1); - monitor.recordHit(2000); - expect(monitor.metrics.hits).toBe(2); - }); - - it('should track total cache age', () => { - monitor.recordHit(1000); - monitor.recordHit(2000); - expect(monitor.metrics.totalCacheAge).toBe(3000); - }); - - it('should not record stale hit for fresh cache', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordHit(100000); // 100 seconds - not stale - expect(monitor.metrics.staleHits).toBe(0); - expect(consoleSpy).not.toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - - it('should record stale hit when cache age exceeds threshold', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordHit(350000); // 350 seconds - stale - expect(monitor.metrics.staleHits).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('[BalanceCache] Stale cache hit: 350.0s old') - ); - consoleSpy.mockRestore(); - }); - }); - - describe('recordMiss', () => { - it('should increment misses counter', () => { - monitor.recordMiss(); - expect(monitor.metrics.misses).toBe(1); - monitor.recordMiss(); - expect(monitor.metrics.misses).toBe(2); - }); - }); - - describe('recordSet', () => { - it('should increment sets counter', () => { - monitor.recordSet(); - expect(monitor.metrics.sets).toBe(1); - }); - }); - - describe('recordEviction', () => { - it('should increment evictions counter', () => { - monitor.recordEviction(); - expect(monitor.metrics.evictions).toBe(1); - }); - }); - - describe('recordInvalidation', () => { - it('should increment invalidations counter', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordInvalidation('test reason'); - expect(monitor.metrics.invalidations).toBe(1); - consoleSpy.mockRestore(); - }); - - it('should log invalidation reason', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordInvalidation('manual clear'); - expect(consoleSpy).toHaveBeenCalledWith( - '[BalanceCache] Invalidated: manual clear' - ); - consoleSpy.mockRestore(); - }); - }); - - describe('recordPartialUpdate', () => { - it('should increment partialUpdates counter', () => { - monitor.recordPartialUpdate(); - expect(monitor.metrics.partialUpdates).toBe(1); - }); - }); - - describe('getHitRate', () => { - it('should return 0.00% when no requests', () => { - expect(monitor.getHitRate()).toBe('0.00'); - }); - - it('should calculate 100% hit rate', () => { - monitor.recordHit(1000); - monitor.recordHit(2000); - expect(monitor.getHitRate()).toBe('100.00'); - }); - - it('should calculate 50% hit rate', () => { - monitor.recordHit(1000); - monitor.recordMiss(); - expect(monitor.getHitRate()).toBe('50.00'); - }); - - it('should calculate 33.33% hit rate', () => { - monitor.recordHit(1000); - monitor.recordMiss(); - monitor.recordMiss(); - expect(monitor.getHitRate()).toBe('33.33'); - }); - }); - - describe('getAvgCacheAge', () => { - it('should return 0 when no hits', () => { - expect(monitor.getAvgCacheAge()).toBe(0); - }); - - it('should calculate average cache age', () => { - monitor.recordHit(1000); - monitor.recordHit(3000); - expect(monitor.getAvgCacheAge()).toBe(2000); - }); - }); - - describe('getStats', () => { - it('should return comprehensive stats object', () => { - monitor.recordHit(1000); - monitor.recordMiss(); - monitor.recordSet(); - - const stats = monitor.getStats(); - - expect(stats).toMatchObject({ - hits: 1, - misses: 1, - staleHits: 0, - sets: 1, - evictions: 0, - invalidations: 0, - partialUpdates: 0, - totalCacheAge: 1000, - }); - expect(stats.hitRate).toBe('50.00%'); - expect(stats.avgCacheAge).toBe('1.0s'); - expect(stats.staleHitRate).toBe('0.00%'); - }); - - it('should calculate stale hit rate correctly', () => { - monitor.recordHit(100000); - jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordHit(350000); // stale - console.warn.mockRestore(); - - const stats = monitor.getStats(); - expect(stats.staleHitRate).toBe('50.00%'); - }); - }); - - describe('reset', () => { - it('should reset all metrics to zero', () => { - monitor.recordHit(1000); - monitor.recordMiss(); - monitor.recordSet(); - monitor.recordEviction(); - jest.spyOn(console, 'warn').mockImplementation(); - monitor.recordInvalidation('test'); - console.warn.mockRestore(); - monitor.recordPartialUpdate(); - - monitor.reset(); - - expect(monitor.metrics).toEqual({ - hits: 0, - misses: 0, - staleHits: 0, - sets: 0, - evictions: 0, - invalidations: 0, - partialUpdates: 0, - totalCacheAge: 0, - }); - }); - }); -}); - -describe('BalanceCache', () => { - let cache; - - beforeEach(() => { - cache = new BalanceCache(); - }); - - afterEach(() => { - cache.stopCleanup(); - }); - - describe('Constructor', () => { - it('should initialize with default TTL of 180000ms (3 minutes)', () => { - expect(cache.defaultTTL).toBe(180000); - }); - - it('should accept custom TTL', () => { - const customCache = new BalanceCache(60000); - expect(customCache.defaultTTL).toBe(60000); - customCache.stopCleanup(); - }); - - it('should create empty cache and expirations maps', () => { - expect(cache.cache.size).toBe(0); - expect(cache.expirations.size).toBe(0); - }); - - it('should create monitor instance', () => { - expect(cache.monitor).toBeDefined(); - expect(cache.monitor.metrics).toBeDefined(); - }); - - it('should start cleanup interval automatically', () => { - expect(cache.cleanupInterval).not.toBeNull(); - }); - }); - - describe('generateKey (static)', () => { - it('should generate key with chainId and address only', () => { - const key = BalanceCache.generateKey(1, '0xABCD'); - expect(key).toBe('balance:1:0xabcd:all'); - }); - - it('should normalize chainId to lowercase string', () => { - const key1 = BalanceCache.generateKey(1, '0xAddress'); - const key2 = BalanceCache.generateKey('1', '0xAddress'); - expect(key1).toBe(key2); - }); - - it('should normalize address to lowercase', () => { - const key = BalanceCache.generateKey(1, '0xABCDEF'); - expect(key).toBe('balance:1:0xabcdef:all'); - }); - - it('should include sorted token addresses', () => { - const tokens = ['0xToken2', '0xToken1', '0xToken3']; - const key = BalanceCache.generateKey(1, '0xAddr', tokens); - expect(key).toBe('balance:1:0xaddr:0xtoken1,0xtoken2,0xtoken3'); - }); - - it('should normalize and sort token addresses', () => { - const tokens = ['0xBBB', '0xAAA', '0xCCC']; - const key = BalanceCache.generateKey(1, '0xAddr', tokens); - expect(key).toContain('0xaaa,0xbbb,0xccc'); - }); - - it('should handle empty token array as "all"', () => { - const key = BalanceCache.generateKey(1, '0xAddr', []); - expect(key).toBe('balance:1:0xaddr:all'); - }); - - it('should handle null token array as "all"', () => { - const key = BalanceCache.generateKey(1, '0xAddr', null); - expect(key).toBe('balance:1:0xaddr:all'); - }); - }); - - describe('set and get', () => { - it('should store and retrieve data', () => { - const key = 'test:key'; - const data = { balances: [], value: 100 }; - - cache.set(key, data); - const retrieved = cache.get(key); - - expect(retrieved).toMatchObject(data); - expect(retrieved.timestamp).toBeDefined(); - }); - - it('should add timestamp to data if not present', () => { - const key = 'test:key'; - const data = { value: 100 }; - - cache.set(key, data); - const retrieved = cache.get(key); - - expect(retrieved.timestamp).toBeDefined(); - expect(typeof retrieved.timestamp).toBe('number'); - }); - - it('should preserve existing timestamp', () => { - const key = 'test:key'; - const timestamp = 1234567890; - const data = { value: 100, timestamp }; - - cache.set(key, data); - const retrieved = cache.get(key); - - expect(retrieved.timestamp).toBe(timestamp); - }); - - it('should use custom TTL when provided', () => { - const key = 'test:key'; - const data = { value: 100 }; - const customTTL = 60000; - - cache.set(key, data, customTTL); - const expiresAt = cache.expirations.get(key); - - expect(expiresAt - Date.now()).toBeGreaterThan(55000); - expect(expiresAt - Date.now()).toBeLessThanOrEqual(customTTL); - }); - - it('should return null for non-existent key', () => { - const retrieved = cache.get('non-existent'); - expect(retrieved).toBeNull(); - }); - - it('should record set metric', () => { - cache.set('test:key', { value: 100 }); - expect(cache.monitor.metrics.sets).toBe(1); - }); - - it('should record hit metric on successful get', () => { - cache.set('test:key', { value: 100 }); - cache.get('test:key'); - expect(cache.monitor.metrics.hits).toBe(1); - }); - - it('should record miss metric on failed get', () => { - cache.get('non-existent'); - expect(cache.monitor.metrics.misses).toBe(1); - }); - - it('should handle edge case where expiration exists but cache data is missing', () => { - const key = 'test:key'; - - // Manually create expiration without cache data (edge case) - cache.expirations.set(key, Date.now() + 60000); - - const retrieved = cache.get(key); - expect(retrieved).toBeNull(); - expect(cache.monitor.metrics.misses).toBe(1); - }); - }); - - describe('TTL expiration', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should expire data after default TTL', () => { - const key = 'test:key'; - const data = { value: 100 }; - - cache.set(key, data); - expect(cache.get(key)).not.toBeNull(); - - // Advance time past TTL - jest.advanceTimersByTime(180001); - - const retrieved = cache.get(key); - expect(retrieved).toBeNull(); - }); - - it('should expire data after custom TTL', () => { - const key = 'test:key'; - const data = { value: 100 }; - const customTTL = 60000; - - cache.set(key, data, customTTL); - expect(cache.get(key)).not.toBeNull(); - - // Advance time past custom TTL - jest.advanceTimersByTime(60001); - - expect(cache.get(key)).toBeNull(); - }); - - it('should not expire data before TTL', () => { - const key = 'test:key'; - const data = { value: 100 }; - - cache.set(key, data); - - // Advance time but stay within TTL - jest.advanceTimersByTime(179000); - - const retrieved = cache.get(key); - expect(retrieved).not.toBeNull(); - }); - - it('should delete expired entry when accessed', () => { - const key = 'test:key'; - cache.set(key, { value: 100 }); - - jest.advanceTimersByTime(180001); - cache.get(key); - - expect(cache.cache.has(key)).toBe(false); - expect(cache.expirations.has(key)).toBe(false); - }); - - it('should record eviction when expired entry is accessed', () => { - const key = 'test:key'; - cache.set(key, { value: 100 }); - - jest.advanceTimersByTime(180001); - cache.get(key); - - expect(cache.monitor.metrics.evictions).toBe(1); - }); - }); - - describe('delete', () => { - it('should remove cache entry', () => { - const key = 'test:key'; - cache.set(key, { value: 100 }); - - const deleted = cache.delete(key); - - expect(deleted).toBe(true); - expect(cache.cache.has(key)).toBe(false); - expect(cache.expirations.has(key)).toBe(false); - }); - - it('should return false when deleting non-existent key', () => { - const deleted = cache.delete('non-existent'); - expect(deleted).toBe(false); - }); - }); - - describe('clear', () => { - it('should remove all cache entries', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('key1', { value: 1 }); - cache.set('key2', { value: 2 }); - cache.set('key3', { value: 3 }); - - cache.clear(); - - expect(cache.cache.size).toBe(0); - expect(cache.expirations.size).toBe(0); - - consoleSpy.mockRestore(); - }); - - it('should record invalidation with entry count', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('key1', { value: 1 }); - cache.set('key2', { value: 2 }); - - cache.clear(); - - expect(cache.monitor.metrics.invalidations).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('full clear (2 entries)') - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('updateTokenBalance', () => { - it('should update specific token balance', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const data = { - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - ], - }; - - cache.set(key, data); - const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); - - expect(updated).toBe(true); - - const cached = cache.get(key); - expect(cached.balances[0].balance).toBe('150'); - expect(cached.balances[1].balance).toBe('200'); - expect(cached.partialUpdate).toBe(true); - expect(cached.lastPartialUpdate).toBeDefined(); - - consoleSpy.mockRestore(); - }); - - it('should add lastUpdated timestamp to updated token', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const data = { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }; - - cache.set(key, data); - cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); - - const cached = cache.get(key); - expect(cached.balances[0].lastUpdated).toBeDefined(); - - consoleSpy.mockRestore(); - }); - - it('should be case-insensitive for token address', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const data = { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }; - - cache.set(key, data); - const updated = cache.updateTokenBalance(1, '0xAddress', '0xTOKEN1', '150'); - - expect(updated).toBe(true); - - consoleSpy.mockRestore(); - }); - - it('should return false when cache miss', () => { - const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); - expect(updated).toBe(false); - }); - - it('should return false when token not found in balances', () => { - const key = BalanceCache.generateKey(1, '0xAddress', null); - const data = { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }; - - cache.set(key, data); - const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken2', '150'); - - expect(updated).toBe(false); - }); - - it('should return false when cached data has no balances', () => { - const key = BalanceCache.generateKey(1, '0xAddress', null); - cache.set(key, { value: 100 }); - - const updated = cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); - expect(updated).toBe(false); - }); - - it('should record partial update metric', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const data = { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }; - - cache.set(key, data); - cache.updateTokenBalance(1, '0xAddress', '0xToken1', '150'); - - expect(cache.monitor.metrics.partialUpdates).toBe(1); - - consoleSpy.mockRestore(); - }); - }); - - describe('refreshTokens', () => { - it('should fetch and set fresh data when no existing cache', async () => { - const fetchFn = jest.fn().mockResolvedValue({ - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - ], - }); - - const result = await cache.refreshTokens( - 1, - '0xAddress', - ['0xToken1', '0xToken2'], - fetchFn - ); - - expect(fetchFn).toHaveBeenCalledWith(1, '0xAddress', [ - '0xToken1', - '0xToken2', - ]); - expect(result.balances).toHaveLength(2); - - const key = BalanceCache.generateKey(1, '0xAddress', null); - const cached = cache.get(key); - expect(cached.balances).toHaveLength(2); - }); - - it('should merge fresh data with existing cache', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const existingData = { - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - { tokenAddress: '0xToken3', balance: '300' }, - ], - }; - - cache.set(key, existingData); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '150' }], - }); - - const result = await cache.refreshTokens( - 1, - '0xAddress', - ['0xToken1'], - fetchFn - ); - - // Should have all 3 tokens, with Token1 updated - expect(result.balances).toHaveLength(3); - - const updatedToken = result.balances.find( - b => b.tokenAddress === '0xToken1' - ); - expect(updatedToken.balance).toBe('150'); - - const preservedToken2 = result.balances.find( - b => b.tokenAddress === '0xToken2' - ); - expect(preservedToken2.balance).toBe('200'); - - consoleSpy.mockRestore(); - }); - - it('should preserve non-refreshed tokens', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const existingData = { - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - ], - }; - - cache.set(key, existingData); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '150' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); - - const cached = cache.get(key); - const token2 = cached.balances.find(b => b.tokenAddress === '0xToken2'); - expect(token2.balance).toBe('200'); - - consoleSpy.mockRestore(); - }); - - it('should mark as partial update', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - cache.set(key, { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '150' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); - - const cached = cache.get(key); - expect(cached.partialUpdate).toBe(true); - expect(cached.lastPartialUpdate).toBeDefined(); - - consoleSpy.mockRestore(); - }); - - it('should update timestamp', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - const oldTimestamp = Date.now() - 10000; - cache.set(key, { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - timestamp: oldTimestamp, - }); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '150' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); - - const cached = cache.get(key); - expect(cached.timestamp).toBeGreaterThan(oldTimestamp); - - consoleSpy.mockRestore(); - }); - - it('should record partial update metric when merging with existing cache', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - cache.set(key, { - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '150' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); - - expect(cache.monitor.metrics.partialUpdates).toBe(1); - - consoleSpy.mockRestore(); - }); - - it('should not record partial update metric when no existing cache', async () => { - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xToken1', balance: '100' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xToken1'], fetchFn); - - expect(cache.monitor.metrics.partialUpdates).toBe(0); - }); - - it('should handle case-insensitive token matching', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const key = BalanceCache.generateKey(1, '0xAddress', null); - cache.set(key, { - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - ], - }); - - const fetchFn = jest.fn().mockResolvedValue({ - balances: [{ tokenAddress: '0xTOKEN1', balance: '150' }], - }); - - await cache.refreshTokens(1, '0xAddress', ['0xTOKEN1'], fetchFn); - - const cached = cache.get(key); - // Should only have Token2 from old cache + Token1 from fresh data - expect(cached.balances).toHaveLength(2); - - consoleSpy.mockRestore(); - }); - }); - - describe('clearAddress', () => { - it('should clear all entries for an address', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - cache.set('balance:1:0xaddr2:all', { value: 2 }); - cache.set('balance:2:0xaddr1:all', { value: 3 }); - - const cleared = cache.clearAddress('0xAddr1'); - - expect(cleared).toBe(2); - expect(cache.get('balance:1:0xaddr1:all')).toBeNull(); - expect(cache.get('balance:2:0xaddr1:all')).toBeNull(); - expect(cache.get('balance:1:0xaddr2:all')).not.toBeNull(); - - consoleSpy.mockRestore(); - }); - - it('should be case-insensitive', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - - const cleared = cache.clearAddress('0xADDR1'); - expect(cleared).toBe(1); - - consoleSpy.mockRestore(); - }); - - it('should record invalidation with reason', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - cache.clearAddress('0xAddr1', 'transaction'); - - expect(cache.monitor.metrics.invalidations).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('address 0xAddr1 (transaction)') - ); - - consoleSpy.mockRestore(); - }); - - it('should return 0 when no entries cleared', () => { - const cleared = cache.clearAddress('0xNonExistent'); - expect(cleared).toBe(0); - }); - - it('should not record invalidation when no entries cleared', () => { - cache.clearAddress('0xNonExistent'); - expect(cache.monitor.metrics.invalidations).toBe(0); - }); - }); - - describe('clearChain', () => { - it('should clear all entries for a chain', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - cache.set('balance:1:0xaddr2:all', { value: 2 }); - cache.set('balance:2:0xaddr1:all', { value: 3 }); - - const cleared = cache.clearChain(1); - - expect(cleared).toBe(2); - expect(cache.get('balance:1:0xaddr1:all')).toBeNull(); - expect(cache.get('balance:1:0xaddr2:all')).toBeNull(); - expect(cache.get('balance:2:0xaddr1:all')).not.toBeNull(); - - consoleSpy.mockRestore(); - }); - - it('should normalize chainId to string', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - - const cleared = cache.clearChain('1'); - expect(cleared).toBe(1); - - consoleSpy.mockRestore(); - }); - - it('should record invalidation with reason', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - cache.clearChain(1, 'reorg'); - - expect(cache.monitor.metrics.invalidations).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('chain 1 (reorg)') - ); - - consoleSpy.mockRestore(); - }); - - it('should return 0 when no entries cleared', () => { - const cleared = cache.clearChain(999); - expect(cleared).toBe(0); - }); - }); - - describe('invalidateAfterTransaction', () => { - it('should call clearAddress with transaction reason', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('balance:1:0xaddr1:all', { value: 1 }); - const cleared = cache.invalidateAfterTransaction(1, '0xAddr1', 'zapIn'); - - expect(cleared).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('transaction:zapIn') - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('cleanup', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should remove expired entries', () => { - cache.set('key1', { value: 1 }); - cache.set('key2', { value: 2 }); - - jest.advanceTimersByTime(180001); - - const cleaned = cache.cleanup(); - - expect(cleaned).toBe(2); - expect(cache.cache.size).toBe(0); - }); - - it('should not remove non-expired entries', () => { - cache.set('key1', { value: 1 }); - cache.set('key2', { value: 2 }); - - jest.advanceTimersByTime(90000); - - const cleaned = cache.cleanup(); - - expect(cleaned).toBe(0); - expect(cache.cache.size).toBe(2); - }); - - it('should record eviction metrics', () => { - cache.set('key1', { value: 1 }); - cache.set('key2', { value: 2 }); - - jest.advanceTimersByTime(180001); - cache.cleanup(); - - expect(cache.monitor.metrics.evictions).toBe(2); - }); - - it('should return 0 when no expired entries', () => { - cache.set('key1', { value: 1 }); - - const cleaned = cache.cleanup(); - expect(cleaned).toBe(0); - }); - }); - - describe('startCleanup and stopCleanup', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should run cleanup at specified interval', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - // Create a new cache with fake timers active to ensure interval is mocked - const testCache = new BalanceCache(); - - testCache.set('key1', { value: 1 }); - - // Advance past TTL - jest.advanceTimersByTime(180001); - - // Advance to trigger cleanup interval (default 60000ms) - jest.advanceTimersByTime(60000); - - expect(testCache.cache.size).toBe(0); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Cleaned up 1 expired entries') - ); - - testCache.stopCleanup(); - consoleSpy.mockRestore(); - }); - - it('should accept custom cleanup interval', () => { - cache.stopCleanup(); - cache.startCleanup(30000); - - cache.set('key1', { value: 1 }); - jest.advanceTimersByTime(180001); - - jest.advanceTimersByTime(30000); - - expect(cache.cache.size).toBe(0); - }); - - it('should replace existing cleanup interval', () => { - cache.startCleanup(10000); - expect(cache.cleanupInterval).not.toBeNull(); - - const oldInterval = cache.cleanupInterval; - cache.startCleanup(20000); - - expect(cache.cleanupInterval).not.toBe(oldInterval); - }); - - it('should stop cleanup interval', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.stopCleanup(); - expect(cache.cleanupInterval).toBeNull(); - - cache.set('key1', { value: 1 }); - jest.advanceTimersByTime(240001); // Past TTL and cleanup interval - - // Cleanup should not run automatically - expect(cache.cache.has('key1')).toBe(true); - - consoleSpy.mockRestore(); - }); - - it('should not log when no entries cleaned', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('key1', { value: 1 }); - - // Advance cleanup interval but not past TTL - jest.advanceTimersByTime(60000); - - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Cleaned up') - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('getStats', () => { - it('should return comprehensive statistics', () => { - cache.set('key1', { value: 1 }); - cache.get('key1'); - - const stats = cache.getStats(); - - expect(stats).toMatchObject({ - size: 1, - defaultTTL: 180000, - hits: 1, - misses: 0, - sets: 1, - }); - expect(stats.hitRate).toBeDefined(); - expect(stats.avgCacheAge).toBeDefined(); - expect(stats.memoryUsage).toBeDefined(); - }); - - it('should include monitor stats', () => { - const stats = cache.getStats(); - - expect(stats.hitRate).toBeDefined(); - expect(stats.staleHitRate).toBeDefined(); - expect(stats.avgCacheAge).toBeDefined(); - }); - }); - - describe('estimateMemoryUsage', () => { - it('should return KB for small cache', () => { - cache.set('key1', { value: 1 }); - - const usage = cache.estimateMemoryUsage(); - expect(usage).toContain('KB'); - }); - - it('should return MB for large cache', () => { - // Add many entries to exceed 1024 KB threshold - for (let i = 0; i < 3000; i++) { - cache.set(`key${i}`, { value: i }); - } - - const usage = cache.estimateMemoryUsage(); - expect(usage).toContain('MB'); - }); - }); - - describe('resetStats', () => { - it('should reset monitor statistics', () => { - cache.set('key1', { value: 1 }); - cache.get('key1'); - - cache.resetStats(); - - expect(cache.monitor.metrics.hits).toBe(0); - expect(cache.monitor.metrics.sets).toBe(0); - }); - }); - - describe('logHealthReport', () => { - it('should log formatted health report', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - cache.set('key1', { value: 1 }); - cache.get('key1'); - - cache.logHealthReport(); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Balance Cache Health Report') - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Cache Size:') - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Hit Rate:') - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('Edge Cases and Integration', () => { - it('should handle multiple simultaneous operations', () => { - const key1 = BalanceCache.generateKey(1, '0xAddr1'); - const key2 = BalanceCache.generateKey(2, '0xAddr2'); - - cache.set(key1, { value: 1 }); - cache.set(key2, { value: 2 }); - - expect(cache.get(key1)).not.toBeNull(); - expect(cache.get(key2)).not.toBeNull(); - - cache.delete(key1); - - expect(cache.get(key1)).toBeNull(); - expect(cache.get(key2)).not.toBeNull(); - }); - - it('should maintain cache integrity across operations', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - const key = BalanceCache.generateKey(1, '0xAddr', null); - cache.set(key, { - balances: [ - { tokenAddress: '0xToken1', balance: '100' }, - { tokenAddress: '0xToken2', balance: '200' }, - ], - }); - - cache.updateTokenBalance(1, '0xAddr', '0xToken1', '150'); - const retrieved = cache.get(key); - - expect(retrieved.balances[0].balance).toBe('150'); - expect(retrieved.balances[1].balance).toBe('200'); - expect(cache.monitor.metrics.hits).toBe(2); // Once in update, once in get - expect(cache.monitor.metrics.partialUpdates).toBe(1); - - consoleSpy.mockRestore(); - }); - - it('should handle empty cache gracefully', () => { - expect(cache.cache.size).toBe(0); - expect(cache.get('any-key')).toBeNull(); - expect(cache.cleanup()).toBe(0); - - const stats = cache.getStats(); - expect(stats.size).toBe(0); - expect(stats.hitRate).toBe('0.00%'); - }); - - it('should handle clearing empty address', () => { - const cleared = cache.clearAddress('0xEmpty'); - expect(cleared).toBe(0); - expect(cache.monitor.metrics.invalidations).toBe(0); - }); - - it('should handle special characters in addresses', () => { - const key = BalanceCache.generateKey( - 1, - '0xABCDEF1234567890', - ['0xToken-1', '0xToken_2'] - ); - - expect(key).toContain('0xabcdef1234567890'); - expect(key).toContain('0xtoken-1'); - expect(key).toContain('0xtoken_2'); - }); - }); -}); From e5d7850f1b3fbaa464eab3082f770659f16a6af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Fri, 10 Oct 2025 21:30:15 +0900 Subject: [PATCH 12/29] fix: weth should use eth's price + remove unused tests --- src/config/priceConfig.js | 2 + .../__tests__/balanceRateLimit.test.js | 227 ----- src/middleware/balanceRateLimit.js | 166 ---- src/routes/swap.js | 26 - test/middleware/balanceRateLimit.test.js | 807 ------------------ 5 files changed, 2 insertions(+), 1226 deletions(-) delete mode 100644 src/middleware/__tests__/balanceRateLimit.test.js delete mode 100644 src/middleware/balanceRateLimit.js delete mode 100644 test/middleware/balanceRateLimit.test.js diff --git a/src/config/priceConfig.js b/src/config/priceConfig.js index 3d2d361..3ad3659 100644 --- a/src/config/priceConfig.js +++ b/src/config/priceConfig.js @@ -59,6 +59,7 @@ const priceConfig = { // CoinMarketCap uses numeric IDs btc: '1', eth: '1027', + weth: '1027', usdc: '3408', usdt: '825', bnb: '1839', @@ -144,6 +145,7 @@ const priceConfig = { // For major coins, we can use coin IDs directly btc: 'bitcoin', eth: 'ethereum', + weth: 'ethereum', usdc: 'usd-coin', usdt: 'tether', bnb: 'binancecoin', diff --git a/src/middleware/__tests__/balanceRateLimit.test.js b/src/middleware/__tests__/balanceRateLimit.test.js deleted file mode 100644 index 8b181e6..0000000 --- a/src/middleware/__tests__/balanceRateLimit.test.js +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Tests for Balance Rate Limiting Middleware - */ - -const balanceRateLimit = require('../balanceRateLimit'); -const { RateLimiter } = require('../balanceRateLimit'); - -describe('RateLimiter', () => { - let limiter; - - beforeEach(() => { - limiter = new RateLimiter(); - }); - - afterEach(() => { - limiter.destroy(); - }); - - describe('Per-wallet limit', () => { - it('should allow 10 requests per wallet per minute', () => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - // First 10 should be allowed - for (let i = 0; i < 10; i++) { - const result = limiter.checkLimit(wallet); - expect(result.allowed).toBe(true); - } - - // 11th should be blocked - const result = limiter.checkLimit(wallet); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('wallet'); - expect(result.retryAfter).toBeGreaterThan(0); - }); - - it('should normalize wallet addresses to lowercase', () => { - const wallet1 = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - const wallet2 = '0x742D35CC6634C0532925A3B844BC9E7595F0BEB'; - - // Should count against same limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(i % 2 === 0 ? wallet1 : wallet2); - } - - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - }); - - it('should allow different wallets independently', () => { - const wallet1 = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - const wallet2 = '0x8ba1f109551bD432803012645Ac136ddd64DBA72'; - - // Each wallet should get 10 requests - for (let i = 0; i < 10; i++) { - expect(limiter.checkLimit(wallet1).allowed).toBe(true); - expect(limiter.checkLimit(wallet2).allowed).toBe(true); - } - - // Both should be limited independently - expect(limiter.checkLimit(wallet1).allowed).toBe(false); - expect(limiter.checkLimit(wallet2).allowed).toBe(false); - }); - }); - - describe('Global limit', () => { - it('should enforce global limit of 100 requests per minute', () => { - const wallets = Array.from( - { length: 20 }, - (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` - ); - - let requestCount = 0; - - // Should allow 100 requests across all wallets - for (let i = 0; i < 100; i++) { - const wallet = wallets[i % wallets.length]; - const result = limiter.checkLimit(wallet); - - if (result.allowed) { - requestCount++; - } else { - break; - } - } - - expect(requestCount).toBe(100); - - // 101st should hit global limit - const result = limiter.checkLimit(wallets[0]); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('global'); - }); - }); - - describe('Status tracking', () => { - it('should track wallet and global counts', () => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - limiter.checkLimit(wallet); - limiter.checkLimit(wallet); - limiter.checkLimit(wallet); - - const status = limiter.getStatus(wallet); - expect(status.walletCount).toBe(3); - expect(status.globalCount).toBe(3); - expect(status.walletReset).toBeGreaterThan(Date.now()); - expect(status.globalReset).toBeGreaterThan(Date.now()); - }); - }); - - describe('Cleanup', () => { - it('should clean up expired entries', done => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - // Manually set an expired entry - limiter.walletLimits.set(wallet.toLowerCase(), { - count: 5, - resetAt: Date.now() - 1000, // Expired - }); - - expect(limiter.walletLimits.size).toBe(1); - - limiter.cleanup(); - - expect(limiter.walletLimits.size).toBe(0); - done(); - }); - }); -}); - -describe('balanceRateLimit middleware', () => { - let req, res, next; - - beforeEach(() => { - req = { - query: {}, - body: {}, - }; - res = { - set: jest.fn().mockReturnThis(), - status: jest.fn().mockReturnThis(), - json: jest.fn(), - }; - next = jest.fn(); - - // Clear rate limiter between tests - const { rateLimiter } = require('../balanceRateLimit'); - rateLimiter.walletLimits.clear(); - rateLimiter.globalLimit = { count: 0, resetAt: Date.now() + 60000 }; - }); - - it('should pass through when no wallet provided', () => { - balanceRateLimit(req, res, next); - expect(next).toHaveBeenCalled(); - }); - - it('should set rate limit headers on success', () => { - req.query.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - balanceRateLimit(req, res, next); - - expect(res.set).toHaveBeenCalledWith( - expect.objectContaining({ - 'X-RateLimit-Limit-Wallet': '10', - 'X-RateLimit-Remaining-Wallet': expect.any(Number), - 'X-RateLimit-Reset-Wallet': expect.any(Number), - 'X-RateLimit-Limit-Global': '100', - 'X-RateLimit-Remaining-Global': expect.any(Number), - }) - ); - expect(next).toHaveBeenCalled(); - }); - - it('should return 429 when wallet limit exceeded', () => { - const { rateLimiter } = require('../balanceRateLimit'); - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - req.query.wallet = wallet; - - // Exhaust the limit - for (let i = 0; i < 10; i++) { - rateLimiter.checkLimit(wallet); - } - - balanceRateLimit(req, res, next); - - expect(res.status).toHaveBeenCalledWith(429); - expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(Number)); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Too Many Requests', - limitType: 'wallet', - retryAfter: expect.any(Number), - }) - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('should return 429 when global limit exceeded', () => { - const { rateLimiter } = require('../balanceRateLimit'); - - // Exhaust global limit - for (let i = 0; i < 100; i++) { - rateLimiter.checkLimit(`0x${'0'.repeat(39)}${i}`); - } - - req.query.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - balanceRateLimit(req, res, next); - - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Too Many Requests', - limitType: 'global', - }) - ); - }); - - it('should handle wallet from body', () => { - req.body.wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - balanceRateLimit(req, res, next); - - expect(res.set).toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/src/middleware/balanceRateLimit.js b/src/middleware/balanceRateLimit.js deleted file mode 100644 index b464ba9..0000000 --- a/src/middleware/balanceRateLimit.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Rate Limiting Middleware for Balance Endpoint - * - * Implements two-tier rate limiting: - * - Per-wallet: 10 requests/minute - * - Global: 100 requests/minute - * - * Uses in-memory Map storage with automatic cleanup of expired entries. - */ - -class RateLimiter { - constructor() { - // Map - this.walletLimits = new Map(); - this.globalLimit = { count: 0, resetAt: Date.now() + 60000 }; - - // Cleanup expired entries every 2 minutes - this.cleanupInterval = setInterval(() => this.cleanup(), 120000); - } - - /** - * Remove expired rate limit entries to prevent memory leaks - */ - cleanup() { - const now = Date.now(); - for (const [wallet, data] of this.walletLimits.entries()) { - if (data.resetAt < now) { - this.walletLimits.delete(wallet); - } - } - } - - /** - * Check and increment rate limits for a wallet - * @param {string} wallet - Ethereum wallet address - * @returns {{ allowed: boolean, retryAfter?: number, limit?: string }} - */ - checkLimit(wallet) { - const now = Date.now(); - - // Check global limit (100 req/min) - if (this.globalLimit.resetAt < now) { - this.globalLimit = { count: 0, resetAt: now + 60000 }; - } - - if (this.globalLimit.count >= 100) { - const retryAfter = Math.ceil((this.globalLimit.resetAt - now) / 1000); - return { - allowed: false, - retryAfter, - limit: 'global', - message: - 'Global rate limit exceeded. Too many requests across all wallets.', - }; - } - - // Check per-wallet limit (10 req/min) - const walletKey = wallet.toLowerCase(); - let walletData = this.walletLimits.get(walletKey); - - if (!walletData || walletData.resetAt < now) { - walletData = { count: 0, resetAt: now + 60000 }; - this.walletLimits.set(walletKey, walletData); - } - - if (walletData.count >= 10) { - const retryAfter = Math.ceil((walletData.resetAt - now) / 1000); - return { - allowed: false, - retryAfter, - limit: 'wallet', - message: `Rate limit exceeded for wallet ${wallet}. Maximum 10 requests per minute.`, - }; - } - - // Increment both counters - walletData.count++; - this.globalLimit.count++; - - return { allowed: true }; - } - - /** - * Get current limit status for a wallet (for monitoring/debugging) - * @param {string} wallet - Ethereum wallet address - * @returns {{ walletCount: number, globalCount: number, walletReset: number, globalReset: number }} - */ - getStatus(wallet) { - const walletKey = wallet.toLowerCase(); - const walletData = this.walletLimits.get(walletKey); - - return { - walletCount: walletData?.count || 0, - globalCount: this.globalLimit.count, - walletReset: walletData?.resetAt || Date.now(), - globalReset: this.globalLimit.resetAt, - }; - } - - /** - * Cleanup resources - */ - destroy() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - } - this.walletLimits.clear(); - } -} - -// Singleton instance -const rateLimiter = new RateLimiter(); - -/** - * Express middleware for balance endpoint rate limiting - * - * Usage: - * router.get('/balance', balanceRateLimit, balanceValidator, getBalance); - * - * Response headers: - * X-RateLimit-Limit-Wallet: 10 - * X-RateLimit-Remaining-Wallet: 7 - * X-RateLimit-Reset-Wallet: 1633024800 - * X-RateLimit-Limit-Global: 100 - * X-RateLimit-Remaining-Global: 85 - * Retry-After: 45 (only on 429) - */ -const balanceRateLimit = (req, res, next) => { - // Extract wallet from query params or body - const wallet = req.query.wallet || req.body?.wallet; - - if (!wallet) { - // If no wallet provided, validation will catch it later - return next(); - } - - const result = rateLimiter.checkLimit(wallet); - - // Add rate limit headers - const status = rateLimiter.getStatus(wallet); - res.set({ - 'X-RateLimit-Limit-Wallet': '10', - 'X-RateLimit-Remaining-Wallet': Math.max(0, 10 - status.walletCount), - 'X-RateLimit-Reset-Wallet': Math.floor(status.walletReset / 1000), - 'X-RateLimit-Limit-Global': '100', - 'X-RateLimit-Remaining-Global': Math.max(0, 100 - status.globalCount), - }); - - if (!result.allowed) { - res.set('Retry-After', result.retryAfter); - return res.status(429).json({ - error: 'Too Many Requests', - message: result.message, - limitType: result.limit, - retryAfter: result.retryAfter, - retryAfterMs: result.retryAfter * 1000, - }); - } - - next(); -}; - -// Export for testing -module.exports = balanceRateLimit; -module.exports.RateLimiter = RateLimiter; -module.exports.rateLimiter = rateLimiter; diff --git a/src/routes/swap.js b/src/routes/swap.js index 79bba62..c794537 100644 --- a/src/routes/swap.js +++ b/src/routes/swap.js @@ -481,30 +481,4 @@ router.get('/tokens/providers', (req, res) => { }); }); -/** - * @swagger - * /health: - * get: - * tags: - * - Health - * summary: Basic health check - * description: Simple health check endpoint to verify API is running - * responses: - * 200: - * description: API is healthy and operational - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/HealthResponse' - * examples: - * healthCheck: - * summary: Healthy response - * value: - * status: "healthy" - * timestamp: "2024-01-01T00:00:00.000Z" - */ -router.get('/health', (req, res) => { - res.json({ status: 'healthy', timestamp: new Date().toISOString() }); -}); - module.exports = router; diff --git a/test/middleware/balanceRateLimit.test.js b/test/middleware/balanceRateLimit.test.js deleted file mode 100644 index 939b154..0000000 --- a/test/middleware/balanceRateLimit.test.js +++ /dev/null @@ -1,807 +0,0 @@ -const balanceRateLimit = require('../../src/middleware/balanceRateLimit'); -const { RateLimiter, rateLimiter: singletonRateLimiter } = balanceRateLimit; - -// Helper to create mock Express req/res/next objects -const makeMockReqRes = (walletSource = {}) => { - const req = { - query: walletSource.query || {}, - body: walletSource.body || {}, - }; - const res = { - headers: {}, - statusCode: 200, - jsonData: null, - set(header, value) { - if (typeof header === 'object') { - for (const key in header) { - this.headers[key.toLowerCase()] = header[key]; - } - } else { - this.headers[header.toLowerCase()] = value; - } - return this; - }, - status(code) { - this.statusCode = code; - return this; - }, - json(data) { - this.jsonData = data; - return this; - }, - }; - const next = jest.fn(); - return { req, res, next }; -}; - -describe('balanceRateLimit', () => { - let limiter; - const wallet1 = '0x1234567890123456789012345678901234567890'; - const wallet2 = '0xabcDEF0123456789012345678901234567890abc'; - - beforeEach(() => { - // Use fake timers to control time-based logic like window resets - jest.useFakeTimers(); - // Set a consistent starting time for Date.now() - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - // Create a new RateLimiter instance for each test to ensure isolation - limiter = new RateLimiter(); - }); - - afterEach(() => { - // Clean up the limiter instance - limiter.destroy(); - // Clean up the singleton instance and restore real timers - singletonRateLimiter.destroy(); - jest.useRealTimers(); - }); - - describe('RateLimiter Class', () => { - describe('Constructor', () => { - it('should initialize with empty walletLimits Map', () => { - expect(limiter.walletLimits).toBeInstanceOf(Map); - expect(limiter.walletLimits.size).toBe(0); - }); - - it('should initialize globalLimit with count 0', () => { - expect(limiter.globalLimit.count).toBe(0); - expect(limiter.globalLimit.resetAt).toBeGreaterThan(Date.now()); - }); - - it('should set up cleanup interval', () => { - expect(limiter.cleanupInterval).toBeDefined(); - }); - }); - - describe('checkLimit() - Per-wallet limit', () => { - it('should allow the first request for a wallet', () => { - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(true); - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(1); - expect(status.globalCount).toBe(1); - }); - - it('should handle wallet addresses case-insensitively', () => { - const walletUpper = '0X1234567890123456789012345678901234567890'; - limiter.checkLimit(wallet1); - const result = limiter.checkLimit(walletUpper); - expect(result.allowed).toBe(true); - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(2); - }); - - it('should allow requests 1-10 for a single wallet', () => { - for (let i = 0; i < 10; i++) { - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(true); - } - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(10); - }); - - it('should block the 11th request for a single wallet', () => { - // Make 10 successful requests - for (let i = 0; i < 10; i++) { - expect(limiter.checkLimit(wallet1).allowed).toBe(true); - } - - // The 11th request should be denied - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('wallet'); - expect(result.retryAfter).toBe(60); - expect(result.message).toContain( - `Rate limit exceeded for wallet ${wallet1}` - ); - expect(result.message).toContain('Maximum 10 requests per minute'); - - // Global count should be 10, not 11 - const status = limiter.getStatus(wallet1); - expect(status.globalCount).toBe(10); - }); - - it('should reset the wallet limit after the time window expires', () => { - const startTime = new Date('2024-01-01T00:00:00Z').getTime(); - - // Exhaust the wallet limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - expect(limiter.checkLimit(wallet1).allowed).toBe(false); - - // Advance time by just over 60 seconds (both timers and system time) - jest.advanceTimersByTime(60001); - jest.setSystemTime(startTime + 60001); - - // The next request should be allowed - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(true); - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(1); - }); - - it('should correctly calculate retryAfter partway through a window', () => { - // Exhaust the limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - // Advance time by 20 seconds - jest.advanceTimersByTime(20 * 1000); - - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - // Should be 40 seconds remaining (60 - 20) - expect(result.retryAfter).toBe(40); - }); - - it('should track different wallets separately', () => { - // Use up wallet1 limit - for (let i = 0; i < 10; i++) { - expect(limiter.checkLimit(wallet1).allowed).toBe(true); - } - expect(limiter.checkLimit(wallet1).allowed).toBe(false); - - // Wallet2 should still work - expect(limiter.checkLimit(wallet2).allowed).toBe(true); - }); - - it('should reset wallet window when expired before checking limit', () => { - // Make 5 requests - for (let i = 0; i < 5; i++) { - limiter.checkLimit(wallet1); - } - - // Advance time past expiration - jest.advanceTimersByTime(61 * 1000); - - // Next request should reset and be allowed - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(true); - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(1); - }); - }); - - describe('checkLimit() - Global limit', () => { - it('should increment global counter for each request', () => { - limiter.checkLimit(wallet1); - limiter.checkLimit(wallet2); - const status = limiter.getStatus(wallet1); - expect(status.globalCount).toBe(2); - }); - - it('should block the 101st global request', () => { - // Make 100 requests from 100 different wallets - for (let i = 0; i < 100; i++) { - expect(limiter.checkLimit(`0xwallet${i}`).allowed).toBe(true); - } - - // The 101st request from a new wallet should be denied - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('global'); - expect(result.retryAfter).toBe(60); - expect(result.message).toContain('Global rate limit exceeded'); - expect(result.message).toContain( - 'Too many requests across all wallets' - ); - - // Wallet count for the new wallet should be 0 - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(0); - expect(status.globalCount).toBe(100); - }); - - it('should reset the global limit after the time window expires', () => { - const startTime = new Date('2024-01-01T00:00:00Z').getTime(); - - // Exhaust the global limit - for (let i = 0; i < 100; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - expect(limiter.checkLimit(wallet1).allowed).toBe(false); - - // Advance time by just over 60 seconds (both timers and system time) - jest.advanceTimersByTime(60001); - jest.setSystemTime(startTime + 60001); - - // The next request should be allowed - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(true); - const status = limiter.getStatus(wallet1); - expect(status.globalCount).toBe(1); - }); - - it('should check global limit before wallet limit', () => { - // Exhaust global limit - for (let i = 0; i < 100; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - - // Try with a wallet that has no requests yet - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('global'); - - // Wallet count should still be 0 - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(0); - }); - - it('should calculate retryAfter for global limit', () => { - // Exhaust global limit - for (let i = 0; i < 100; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - - // Advance 15 seconds - jest.advanceTimersByTime(15 * 1000); - - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - expect(result.retryAfter).toBe(45); // 60 - 15 - }); - }); - - describe('checkLimit() - Combined scenarios', () => { - it('should allow wallet limit to be reached with global capacity available', () => { - // Make 10 requests (wallet limit) - for (let i = 0; i < 10; i++) { - expect(limiter.checkLimit(wallet1).allowed).toBe(true); - } - - // 11th should fail on wallet limit - const result = limiter.checkLimit(wallet1); - expect(result.allowed).toBe(false); - expect(result.limit).toBe('wallet'); - - // Global should still have capacity - const status = limiter.getStatus(wallet1); - expect(status.globalCount).toBe(10); - }); - - it('should handle multiple wallets exhausting their limits independently', () => { - // Exhaust wallet1 - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - // Exhaust wallet2 - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet2); - } - - // Both should be blocked at wallet level - expect(limiter.checkLimit(wallet1).limit).toBe('wallet'); - expect(limiter.checkLimit(wallet2).limit).toBe('wallet'); - - // Global should be at 20 - expect(limiter.getStatus(wallet1).globalCount).toBe(20); - }); - - it('should reset windows independently', () => { - const startTime = new Date('2024-01-01T00:00:00Z').getTime(); - - limiter.checkLimit(wallet1); - - // Advance time by 30 seconds - jest.advanceTimersByTime(30 * 1000); - jest.setSystemTime(startTime + 30 * 1000); - - limiter.checkLimit(wallet2); - limiter.checkLimit(wallet2); // Make a second request for wallet2 - - // Advance time by 31 seconds (61 total for wallet1, 31 for wallet2) - jest.advanceTimersByTime(31 * 1000); - jest.setSystemTime(startTime + 61 * 1000); - - // Wallet1 should have reset (> 60 seconds since first check) - const result1 = limiter.checkLimit(wallet1); - expect(result1.allowed).toBe(true); - expect(limiter.getStatus(wallet1).walletCount).toBe(1); - - // Wallet2 should not have reset (only 31 seconds since first check) - expect(limiter.getStatus(wallet2).walletCount).toBe(2); - }); - }); - - describe('getStatus()', () => { - it('should return correct counts for tracked wallet', () => { - limiter.checkLimit(wallet1); - limiter.checkLimit(wallet1); - - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(2); - expect(status.globalCount).toBe(2); - expect(status.walletReset).toBeGreaterThan(Date.now()); - expect(status.globalReset).toBeGreaterThan(Date.now()); - }); - - it('should return zeros for untracked wallet', () => { - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(0); - expect(status.globalCount).toBe(0); - expect(status.walletReset).toBeGreaterThanOrEqual(Date.now()); - }); - - it('should handle case-insensitive wallet lookup', () => { - limiter.checkLimit(wallet1.toLowerCase()); - - const statusUpper = limiter.getStatus(wallet1.toUpperCase()); - expect(statusUpper.walletCount).toBe(1); - }); - - it('should return current global count for any wallet', () => { - limiter.checkLimit(wallet1); - limiter.checkLimit(wallet2); - - const status = limiter.getStatus('0xrandom'); - expect(status.globalCount).toBe(2); - }); - }); - - describe('cleanup()', () => { - it('should remove expired entries from walletLimits', () => { - // Make a request to add an entry - limiter.checkLimit(wallet1); - expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(true); - - // Advance time past the reset window - jest.advanceTimersByTime(61 * 1000); - - // Manually trigger cleanup - limiter.cleanup(); - - // The entry should be gone - expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(false); - }); - - it('should keep valid entries', () => { - limiter.checkLimit(wallet1); - limiter.checkLimit(wallet2); - - // Advance time but not past expiration - jest.advanceTimersByTime(30 * 1000); - - limiter.cleanup(); - - // Both entries should still be present - expect(limiter.walletLimits.has(wallet1.toLowerCase())).toBe(true); - expect(limiter.walletLimits.has(wallet2.toLowerCase())).toBe(true); - }); - - it('should handle empty map', () => { - expect(() => limiter.cleanup()).not.toThrow(); - expect(limiter.walletLimits.size).toBe(0); - }); - - it('should run automatically every 2 minutes', () => { - limiter.checkLimit(wallet1); - - // Advance past entry expiration but less than cleanup interval - jest.advanceTimersByTime(61 * 1000); - expect(limiter.walletLimits.size).toBe(1); - - // Advance to cleanup interval (120 seconds total) - jest.advanceTimersByTime(59 * 1000); - - // Trigger any pending timers - jest.runOnlyPendingTimers(); - - expect(limiter.walletLimits.size).toBe(0); - }); - - it('should remove multiple expired entries', () => { - // Create entries for 5 wallets - for (let i = 0; i < 5; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - expect(limiter.walletLimits.size).toBe(5); - - // Advance time past expiration - jest.advanceTimersByTime(61 * 1000); - - limiter.cleanup(); - - expect(limiter.walletLimits.size).toBe(0); - }); - }); - - describe('destroy()', () => { - it('should clear the interval and the limits map', () => { - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - limiter.checkLimit(wallet1); - expect(limiter.walletLimits.size).toBe(1); - - limiter.destroy(); - - expect(clearIntervalSpy).toHaveBeenCalledWith(limiter.cleanupInterval); - expect(limiter.walletLimits.size).toBe(0); - clearIntervalSpy.mockRestore(); - }); - - it('should handle being called multiple times', () => { - limiter.destroy(); - expect(() => limiter.destroy()).not.toThrow(); - }); - - it('should clear all wallet entries', () => { - for (let i = 0; i < 10; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - expect(limiter.walletLimits.size).toBe(10); - - limiter.destroy(); - - expect(limiter.walletLimits.size).toBe(0); - }); - }); - }); - - describe('balanceRateLimit Middleware', () => { - // The middleware uses a singleton, so we need to replace its implementation - // with our isolated instance for these tests. - let originalCheckLimit; - let originalGetStatus; - - beforeEach(() => { - originalCheckLimit = singletonRateLimiter.checkLimit; - originalGetStatus = singletonRateLimiter.getStatus; - singletonRateLimiter.checkLimit = (...args) => - limiter.checkLimit(...args); - singletonRateLimiter.getStatus = (...args) => limiter.getStatus(...args); - }); - - afterEach(() => { - singletonRateLimiter.checkLimit = originalCheckLimit; - singletonRateLimiter.getStatus = originalGetStatus; - }); - - describe('Success path (within limits)', () => { - it('should call next() and set headers for an allowed request from query', () => { - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - - balanceRateLimit(req, res, next); - - expect(next).toHaveBeenCalledTimes(1); - expect(res.statusCode).toBe(200); - expect(res.jsonData).toBeNull(); - expect(res.headers['x-ratelimit-limit-wallet']).toBe('10'); - expect(res.headers['x-ratelimit-remaining-wallet']).toBe(9); - expect(res.headers['x-ratelimit-limit-global']).toBe('100'); - expect(res.headers['x-ratelimit-remaining-global']).toBe(99); - expect(res.headers['x-ratelimit-reset-wallet']).toBeGreaterThan(0); - }); - - it('should call next() and set headers for an allowed request from body', () => { - const { req, res, next } = makeMockReqRes({ - body: { wallet: wallet1 }, - }); - - balanceRateLimit(req, res, next); - - expect(next).toHaveBeenCalledTimes(1); - expect(res.headers['x-ratelimit-limit-wallet']).toBe('10'); - }); - - it('should prioritize query over body for wallet parameter', () => { - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - body: { wallet: wallet2 }, - }); - - balanceRateLimit(req, res, next); - - const status = limiter.getStatus(wallet1); - expect(status.walletCount).toBe(1); - expect(limiter.getStatus(wallet2).walletCount).toBe(0); - }); - - it('should set correct remaining count after multiple requests', () => { - // Make 5 requests - for (let i = 0; i < 5; i++) { - limiter.checkLimit(wallet1); - } - - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.headers['x-ratelimit-remaining-wallet']).toBe(4); // 10 - 6 - }); - - it('should never show negative remaining counts', () => { - // Exhaust limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); - expect(res.headers['x-ratelimit-remaining-global']).toBe(90); - }); - }); - - describe('429 responses', () => { - it('should return 429 when wallet limit is exceeded', () => { - // Exhaust the limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - const { req, res, next } = makeMockReqRes({ - body: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(429); - expect(res.jsonData.error).toBe('Too Many Requests'); - expect(res.jsonData.limitType).toBe('wallet'); - expect(res.jsonData.retryAfter).toBe(60); - expect(res.jsonData.retryAfterMs).toBe(60000); - expect(res.jsonData.message).toContain( - 'Rate limit exceeded for wallet' - ); - expect(res.headers['retry-after']).toBe(60); - // Remaining should be 0, not negative - expect(res.headers['x-ratelimit-remaining-wallet']).toBe(0); - }); - - it('should return 429 when global limit is exceeded', () => { - // Exhaust global limit - for (let i = 0; i < 100; i++) { - limiter.checkLimit(`0xwallet${i}`); - } - - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(429); - expect(res.jsonData.error).toBe('Too Many Requests'); - expect(res.jsonData.limitType).toBe('global'); - expect(res.jsonData.retryAfter).toBe(60); - expect(res.jsonData.message).toContain('Global rate limit exceeded'); - expect(res.headers['retry-after']).toBe(60); - expect(res.headers['x-ratelimit-remaining-global']).toBe(0); - }); - - it('should include correct Retry-After header value', () => { - // Exhaust limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - // Advance time by 25 seconds - jest.advanceTimersByTime(25 * 1000); - - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.headers['retry-after']).toBe(35); // 60 - 25 - expect(res.jsonData.retryAfter).toBe(35); - }); - - it('should return 429 with all required response fields', () => { - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.jsonData).toEqual({ - error: 'Too Many Requests', - message: expect.stringContaining('Rate limit exceeded'), - limitType: 'wallet', - retryAfter: 60, - retryAfterMs: 60000, - }); - }); - }); - - describe('Missing wallet parameter', () => { - it('should call next() immediately if no wallet is provided', () => { - const { req, res, next } = makeMockReqRes(); // No wallet in query or body - balanceRateLimit(req, res, next); - - expect(next).toHaveBeenCalledTimes(1); - // No rate limit headers should be set - expect(res.headers['x-ratelimit-limit-wallet']).toBeUndefined(); - expect(res.headers['x-ratelimit-limit-global']).toBeUndefined(); - }); - - it('should not increment any counters when wallet is missing', () => { - const initialStatus = limiter.getStatus('0xrandom'); - const initialGlobalCount = initialStatus.globalCount; - - const { req, res, next } = makeMockReqRes(); - balanceRateLimit(req, res, next); - - const finalStatus = limiter.getStatus('0xrandom'); - expect(finalStatus.globalCount).toBe(initialGlobalCount); - }); - - it('should handle empty string wallet', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: '' } }); - balanceRateLimit(req, res, next); - - // Should pass through since empty string is falsy - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should handle null wallet', () => { - const { req, res, next } = makeMockReqRes({ query: { wallet: null } }); - balanceRateLimit(req, res, next); - - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should handle undefined wallet in body', () => { - const { req, res, next } = makeMockReqRes({ - body: { wallet: undefined }, - }); - balanceRateLimit(req, res, next); - - expect(next).toHaveBeenCalledTimes(1); - }); - }); - - describe('Rate limit headers', () => { - it('should set all required rate limit headers', () => { - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.headers['x-ratelimit-limit-wallet']).toBeDefined(); - expect(res.headers['x-ratelimit-remaining-wallet']).toBeDefined(); - expect(res.headers['x-ratelimit-reset-wallet']).toBeDefined(); - expect(res.headers['x-ratelimit-limit-global']).toBeDefined(); - expect(res.headers['x-ratelimit-remaining-global']).toBeDefined(); - }); - - it('should set X-RateLimit-Reset-Wallet as Unix timestamp in seconds', () => { - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - const resetTimestamp = parseInt( - res.headers['x-ratelimit-reset-wallet'] - ); - const now = Math.floor(Date.now() / 1000); - - // Should be a valid Unix timestamp in the future - expect(resetTimestamp).toBeGreaterThan(now); - expect(resetTimestamp).toBeLessThan(now + 61); // Within 61 seconds - }); - - it('should update headers correctly across multiple requests', () => { - // First request - const mock1 = makeMockReqRes({ query: { wallet: wallet1 } }); - balanceRateLimit(mock1.req, mock1.res, mock1.next); - expect(mock1.res.headers['x-ratelimit-remaining-wallet']).toBe(9); - - // Second request - const mock2 = makeMockReqRes({ query: { wallet: wallet1 } }); - balanceRateLimit(mock2.req, mock2.res, mock2.next); - expect(mock2.res.headers['x-ratelimit-remaining-wallet']).toBe(8); - }); - - it('should not set Retry-After header for allowed requests', () => { - const { req, res, next } = makeMockReqRes({ - query: { wallet: wallet1 }, - }); - balanceRateLimit(req, res, next); - - expect(res.headers['retry-after']).toBeUndefined(); - }); - }); - - describe('Integration scenarios', () => { - it('should handle rapid sequential requests correctly', () => { - const requests = []; - - // Make 12 rapid requests - for (let i = 0; i < 12; i++) { - const mock = makeMockReqRes({ query: { wallet: wallet1 } }); - balanceRateLimit(mock.req, mock.res, mock.next); - requests.push(mock); - } - - // First 10 should succeed - for (let i = 0; i < 10; i++) { - expect(requests[i].next).toHaveBeenCalled(); - expect(requests[i].res.statusCode).toBe(200); - } - - // Last 2 should fail - for (let i = 10; i < 12; i++) { - expect(requests[i].next).not.toHaveBeenCalled(); - expect(requests[i].res.statusCode).toBe(429); - } - }); - - it('should handle concurrent requests from different wallets', () => { - const wallets = []; - for (let i = 0; i < 15; i++) { - wallets.push(`0xwallet${i}`); - } - - // Each wallet makes 5 requests - wallets.forEach(wallet => { - for (let i = 0; i < 5; i++) { - const mock = makeMockReqRes({ query: { wallet } }); - balanceRateLimit(mock.req, mock.res, mock.next); - expect(mock.res.statusCode).toBe(200); - } - }); - - // Global count should be 75 (15 wallets * 5 requests) - const status = limiter.getStatus(wallets[0]); - expect(status.globalCount).toBe(75); - }); - - it('should recover after time window reset', () => { - const startTime = new Date('2024-01-01T00:00:00Z').getTime(); - - // Exhaust limit - for (let i = 0; i < 10; i++) { - limiter.checkLimit(wallet1); - } - - // Verify blocked - const mock1 = makeMockReqRes({ query: { wallet: wallet1 } }); - balanceRateLimit(mock1.req, mock1.res, mock1.next); - expect(mock1.res.statusCode).toBe(429); - - // Advance time by just over 60 seconds (both timers and system time) - jest.advanceTimersByTime(60001); - jest.setSystemTime(startTime + 60001); - - // Should work again - const mock2 = makeMockReqRes({ query: { wallet: wallet1 } }); - balanceRateLimit(mock2.req, mock2.res, mock2.next); - expect(mock2.res.statusCode).toBe(200); - expect(mock2.next).toHaveBeenCalled(); - }); - }); - }); -}); From 652e591a207675b6cc2a8f05c917747cb6bb6ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sat, 11 Oct 2025 13:14:08 +0900 Subject: [PATCH 13/29] fix: weth price + ethers + remove duplicate code --- src/config/priceConfig.js | 4 +- src/protocols/PendlePTProtocol.js | 8 +- src/routes/intents.js | 4 +- src/services/SwapProcessingService.js | 13 - .../__tests__/balanceValidator.test.js | 268 ----------------- .../services}/balanceService.test.js | 4 +- .../utils}/balanceCache.test.js | 2 +- test/validators/balanceValidator.test.js | 281 ++++++++++++++---- 8 files changed, 239 insertions(+), 345 deletions(-) delete mode 100644 src/validators/__tests__/balanceValidator.test.js rename {src/services/__tests__ => test/services}/balanceService.test.js (98%) rename {src/utils/__tests__ => test/utils}/balanceCache.test.js (99%) diff --git a/src/config/priceConfig.js b/src/config/priceConfig.js index 3ad3659..10136cb 100644 --- a/src/config/priceConfig.js +++ b/src/config/priceConfig.js @@ -59,7 +59,7 @@ const priceConfig = { // CoinMarketCap uses numeric IDs btc: '1', eth: '1027', - weth: '1027', + weth: '2396', usdc: '3408', usdt: '825', bnb: '1839', @@ -145,7 +145,7 @@ const priceConfig = { // For major coins, we can use coin IDs directly btc: 'bitcoin', eth: 'ethereum', - weth: 'ethereum', + weth: 'weth', usdc: 'usd-coin', usdt: 'tether', bnb: 'binancecoin', diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 20c11fc..5cea89e 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -210,10 +210,10 @@ class PendlePTProtocol extends BaseProtocolV2 { tokenIn: this.underlyingTokenAddress, netTokenIn: netTokenIn, tokenMintSy: this.underlyingTokenAddress, - pendleSwap: ethers.constants.AddressZero, + pendleSwap: ethers.ZeroAddress, swapData: { swapType: 0, - extRouter: ethers.constants.AddressZero, + extRouter: ethers.ZeroAddress, extCalldata: '0x', needScale: false, }, @@ -324,10 +324,10 @@ class PendlePTProtocol extends BaseProtocolV2 { tokenOut: this.underlyingTokenAddress, minTokenOut: 0n, // Would calculate based on slippage tokenRedeemSy: this.underlyingTokenAddress, - pendleSwap: ethers.constants.AddressZero, + pendleSwap: ethers.ZeroAddress, swapData: { swapType: 0, - extRouter: ethers.constants.AddressZero, + extRouter: ethers.ZeroAddress, extCalldata: '0x', needScale: false, }, diff --git a/src/routes/intents.js b/src/routes/intents.js index 889d51b..0c25b6e 100644 --- a/src/routes/intents.js +++ b/src/routes/intents.js @@ -504,13 +504,15 @@ router.post( ); // Legacy rebalance endpoint (deprecated - use optimize instead) +// REMOVAL SCHEDULED: 2025-11-09 (30 days from 2025-10-10) router.post('/api/v1/intents/rebalance', validateIntentRequest, (req, res) => { res.status(301).json({ success: false, error: { code: 'ENDPOINT_DEPRECATED', message: - 'This endpoint is deprecated. Use POST /api/v1/intents/optimize with operations: ["rebalance"]', + 'This endpoint is deprecated and will be removed on 2025-11-09. Use POST /api/v1/intents/optimize with operations: ["rebalance"]', + removalDate: '2025-11-09', }, redirectTo: '/api/v1/intents/optimize', }); diff --git a/src/services/SwapProcessingService.js b/src/services/SwapProcessingService.js index b6bb510..0a4e673 100644 --- a/src/services/SwapProcessingService.js +++ b/src/services/SwapProcessingService.js @@ -83,19 +83,6 @@ class SwapProcessingService { return this.tokenBatchProcessor.processTokenBatchWithSSE(params); } - /** - * Handle token processing result and update progress - * @deprecated Use TokenBatchProcessor directly for new implementations - * Maintained for backward compatibility - * @param {Object} params - Result handling parameters - * @returns {number} Updated transaction index - */ - _handleTokenProcessingResult(params) { - const progressTracker = this.tokenBatchProcessor.progressTracker; - const result = progressTracker.handleTokenProcessingResult(params); - return result.updatedTransactionIndex; - } - /** * Extract processing context from request parameters * @param {Object} executionContext - Execution context from intent handler diff --git a/src/validators/__tests__/balanceValidator.test.js b/src/validators/__tests__/balanceValidator.test.js deleted file mode 100644 index 5a06162..0000000 --- a/src/validators/__tests__/balanceValidator.test.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Tests for Balance Validator Middleware - */ - -const { - balanceValidationRules, - SUPPORTED_CHAIN_IDS, - SUPPORTED_CHAINS, - isValidAddress, - areValidAddresses, -} = require('../balanceValidator'); - -describe('Validation Helper Functions', () => { - describe('isValidAddress', () => { - it('should validate correct Ethereum addresses', () => { - expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb')).toBe( - true - ); - expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( - true - ); - }); - - it('should reject invalid addresses', () => { - expect(isValidAddress('not-an-address')).toBe(false); - expect(isValidAddress('0x123')).toBe(false); - expect(isValidAddress('')).toBe(false); - expect(isValidAddress(null)).toBe(false); - expect(isValidAddress(undefined)).toBe(false); - }); - - it('should handle checksummed addresses', () => { - expect(isValidAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7')).toBe( - true - ); - }); - }); - - describe('areValidAddresses', () => { - it('should validate comma-separated addresses', () => { - const valid = - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; - expect(areValidAddresses(valid)).toBe(true); - }); - - it('should allow empty/undefined (optional field)', () => { - expect(areValidAddresses('')).toBe(true); - expect(areValidAddresses(null)).toBe(true); - expect(areValidAddresses(undefined)).toBe(true); - }); - - it('should reject lists with invalid addresses', () => { - const invalid = - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,invalid-address'; - expect(areValidAddresses(invalid)).toBe(false); - }); - - it('should reject lists with too many addresses (>50)', () => { - const tooMany = Array.from( - { length: 51 }, - (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` - ).join(','); - - expect(areValidAddresses(tooMany)).toBe(false); - }); - - it('should handle whitespace in addresses', () => { - const withSpaces = - ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; - expect(areValidAddresses(withSpaces)).toBe(true); - }); - }); -}); - -describe('Supported Chains', () => { - it('should include major chains', () => { - expect(SUPPORTED_CHAIN_IDS).toContain(1); // Ethereum - expect(SUPPORTED_CHAIN_IDS).toContain(10); // Optimism - expect(SUPPORTED_CHAIN_IDS).toContain(137); // Polygon - expect(SUPPORTED_CHAIN_IDS).toContain(8453); // Base - expect(SUPPORTED_CHAIN_IDS).toContain(42161); // Arbitrum - }); - - it('should have descriptive names', () => { - expect(SUPPORTED_CHAINS[1]).toBe('Ethereum Mainnet'); - expect(SUPPORTED_CHAINS[10]).toBe('Optimism'); - expect(SUPPORTED_CHAINS[137]).toBe('Polygon'); - expect(SUPPORTED_CHAINS[8453]).toBe('Base'); - expect(SUPPORTED_CHAINS[42161]).toBe('Arbitrum One'); - }); -}); - -describe('Balance Validator Integration', () => { - // Mock express-validator for integration tests - const mockValidate = (rules, req) => { - // Simplified mock - in real tests, use express-validator's test utilities - const errors = []; - - // Manually check chainId - if (!req.query.chainId) { - errors.push({ path: 'chainId', msg: 'chainId is required' }); - } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { - errors.push({ - path: 'chainId', - msg: 'chainId must be one of supported chains', - }); - } - - // Manually check wallet - if (!req.query.wallet) { - errors.push({ path: 'wallet', msg: 'wallet address is required' }); - } else if (!isValidAddress(req.query.wallet)) { - errors.push({ - path: 'wallet', - msg: 'wallet must be a valid Ethereum address', - }); - } - - // Manually check tokens (optional) - if (req.query.tokens && !areValidAddresses(req.query.tokens)) { - errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); - } - - return errors; - }; - - it('should validate correct request', async () => { - const req = { - query: { - chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors).toHaveLength(0); - }); - - it('should require chainId', async () => { - const req = { - query: { - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'chainId')).toBe(true); - }); - - it('should require wallet', async () => { - const req = { - query: { - chainId: '1', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'wallet')).toBe(true); - }); - - it('should reject unsupported chainId', async () => { - const req = { - query: { - chainId: '999', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'chainId')).toBe(true); - }); - - it('should reject invalid wallet address', async () => { - const req = { - query: { - chainId: '1', - wallet: 'not-a-valid-address', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'wallet')).toBe(true); - }); - - it('should validate optional tokens parameter', async () => { - const req = { - query: { - chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - tokens: - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors).toHaveLength(0); - }); - - it('should reject invalid token addresses', async () => { - const req = { - query: { - chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', - tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'tokens')).toBe(true); - }); - - it('should validate all supported chains', async () => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; - - for (const chainId of SUPPORTED_CHAIN_IDS) { - const req = { query: { chainId: chainId.toString(), wallet } }; - const errors = await mockValidate(balanceValidationRules, req); - expect(errors).toHaveLength(0); - } - }); -}); - -describe('Error Message Quality', () => { - it('should provide helpful error messages', async () => { - const req = { - query: { - chainId: '999', - wallet: 'invalid', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - - // Errors should be descriptive - expect(errors.length).toBeGreaterThan(0); - expect(errors.some(e => e.msg.includes('chainId'))).toBe(true); - expect(errors.some(e => e.msg.includes('wallet'))).toBe(true); - }); -}); - -// Helper function for mock validation -function mockValidate(rules, req) { - const errors = []; - - if (!req.query.chainId) { - errors.push({ path: 'chainId', msg: 'chainId is required' }); - } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { - errors.push({ - path: 'chainId', - msg: 'chainId must be one of supported chains', - }); - } - - if (!req.query.wallet) { - errors.push({ path: 'wallet', msg: 'wallet address is required' }); - } else if (!isValidAddress(req.query.wallet)) { - errors.push({ - path: 'wallet', - msg: 'wallet must be a valid Ethereum address', - }); - } - - if (req.query.tokens && !areValidAddresses(req.query.tokens)) { - errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); - } - - return errors; -} diff --git a/src/services/__tests__/balanceService.test.js b/test/services/balanceService.test.js similarity index 98% rename from src/services/__tests__/balanceService.test.js rename to test/services/balanceService.test.js index 7f16e85..5ccb963 100644 --- a/src/services/__tests__/balanceService.test.js +++ b/test/services/balanceService.test.js @@ -1,5 +1,5 @@ -const BalanceService = require('../balanceService'); -const BalanceCache = require('../../utils/balanceCache'); +const BalanceService = require('../../src/services/balanceService'); +const BalanceCache = require('../../src/utils/balanceCache'); // Mock axios jest.mock('axios'); diff --git a/src/utils/__tests__/balanceCache.test.js b/test/utils/balanceCache.test.js similarity index 99% rename from src/utils/__tests__/balanceCache.test.js rename to test/utils/balanceCache.test.js index eff4395..3880c07 100644 --- a/src/utils/__tests__/balanceCache.test.js +++ b/test/utils/balanceCache.test.js @@ -1,4 +1,4 @@ -const BalanceCache = require('../balanceCache'); +const BalanceCache = require('../../src/utils/balanceCache'); describe('BalanceCache', () => { let cache; diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js index 5f7843e..683fd26 100644 --- a/test/validators/balanceValidator.test.js +++ b/test/validators/balanceValidator.test.js @@ -1,95 +1,268 @@ /** - * Balance Validator Unit Tests + * Tests for Balance Validator Middleware */ const { - handleValidationErrors, - SUPPORTED_CHAINS, + balanceValidationRules, SUPPORTED_CHAIN_IDS, + SUPPORTED_CHAINS, isValidAddress, areValidAddresses, } = require('../../src/validators/balanceValidator'); -describe('Balance Validator', () => { +describe('Validation Helper Functions', () => { describe('isValidAddress', () => { - test('should return true for valid address', () => { + it('should validate correct Ethereum addresses', () => { + expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb')).toBe( + true + ); expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( true ); }); - test('should return false for invalid address', () => { - expect(isValidAddress('0xinvalid')).toBe(false); + it('should reject invalid addresses', () => { expect(isValidAddress('not-an-address')).toBe(false); + expect(isValidAddress('0x123')).toBe(false); expect(isValidAddress('')).toBe(false); expect(isValidAddress(null)).toBe(false); + expect(isValidAddress(undefined)).toBe(false); + }); + + it('should handle checksummed addresses', () => { + expect(isValidAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7')).toBe( + true + ); }); }); describe('areValidAddresses', () => { - test('should return true for valid comma-separated addresses', () => { - expect( - areValidAddresses( - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7' - ) - ).toBe(true); + it('should validate comma-separated addresses', () => { + const valid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + expect(areValidAddresses(valid)).toBe(true); }); - test('should return true for empty string (optional)', () => { + it('should allow empty/undefined (optional field)', () => { expect(areValidAddresses('')).toBe(true); expect(areValidAddresses(null)).toBe(true); + expect(areValidAddresses(undefined)).toBe(true); }); - test('should return false for invalid address in list', () => { - expect( - areValidAddresses( - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xinvalid' - ) - ).toBe(false); + it('should reject lists with invalid addresses', () => { + const invalid = + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,invalid-address'; + expect(areValidAddresses(invalid)).toBe(false); }); - test('should return false for more than 50 addresses', () => { - const addresses = Array(51) - .fill('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48') - .join(','); - expect(areValidAddresses(addresses)).toBe(false); + it('should reject lists with too many addresses (>50)', () => { + const tooMany = Array.from( + { length: 51 }, + (_, i) => `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` + ).join(','); + + expect(areValidAddresses(tooMany)).toBe(false); }); - }); - describe('SUPPORTED_CHAINS', () => { - test('should contain expected chains', () => { - expect(SUPPORTED_CHAINS).toHaveProperty('1'); - expect(SUPPORTED_CHAINS).toHaveProperty('137'); - expect(SUPPORTED_CHAINS).toHaveProperty('42161'); - expect(SUPPORTED_CHAINS).toHaveProperty('8453'); - expect(SUPPORTED_CHAINS).toHaveProperty('10'); + it('should handle whitespace in addresses', () => { + const withSpaces = + ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; + expect(areValidAddresses(withSpaces)).toBe(true); }); }); +}); - describe('SUPPORTED_CHAIN_IDS', () => { - test('should be array of numbers', () => { - expect(Array.isArray(SUPPORTED_CHAIN_IDS)).toBe(true); - expect(SUPPORTED_CHAIN_IDS.every(id => typeof id === 'number')).toBe( - true - ); - expect(SUPPORTED_CHAIN_IDS).toContain(1); - expect(SUPPORTED_CHAIN_IDS).toContain(137); - }); +describe('Supported Chains', () => { + it('should include major chains', () => { + expect(SUPPORTED_CHAIN_IDS).toContain(1); // Ethereum + expect(SUPPORTED_CHAIN_IDS).toContain(10); // Optimism + expect(SUPPORTED_CHAIN_IDS).toContain(137); // Polygon + expect(SUPPORTED_CHAIN_IDS).toContain(8453); // Base + expect(SUPPORTED_CHAIN_IDS).toContain(42161); // Arbitrum }); - describe('handleValidationErrors', () => { - test('should call next() when no errors', () => { - const req = {}; - const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; - const next = jest.fn(); + it('should have descriptive names', () => { + expect(SUPPORTED_CHAINS[1]).toBe('Ethereum Mainnet'); + expect(SUPPORTED_CHAINS[10]).toBe('Optimism'); + expect(SUPPORTED_CHAINS[137]).toBe('Polygon'); + expect(SUPPORTED_CHAINS[8453]).toBe('Base'); + expect(SUPPORTED_CHAINS[42161]).toBe('Arbitrum One'); + }); +}); - // Mock validationResult to return no errors - jest.mock('express-validator', () => ({ - validationResult: jest.fn(() => ({ isEmpty: () => true })), - })); +describe('Balance Validator Integration', () => { + // Mock express-validator for integration tests + const mockValidate = (rules, req) => { + // Simplified mock - in real tests, use express-validator's test utilities + const errors = []; - handleValidationErrors(req, res, next); - expect(next).toHaveBeenCalled(); - }); + // Manually check chainId + if (!req.query.chainId) { + errors.push({ path: 'chainId', msg: 'chainId is required' }); + } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { + errors.push({ + path: 'chainId', + msg: 'chainId must be one of supported chains', + }); + } + + // Manually check wallet + if (!req.query.wallet) { + errors.push({ path: 'wallet', msg: 'wallet address is required' }); + } else if (!isValidAddress(req.query.wallet)) { + errors.push({ + path: 'wallet', + msg: 'wallet must be a valid Ethereum address', + }); + } + + // Manually check tokens (optional) + if (req.query.tokens && !areValidAddresses(req.query.tokens)) { + errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); + } + + return errors; + }; + + it('should validate correct request', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + }); + + it('should require chainId', async () => { + const req = { + query: { + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'chainId')).toBe(true); + }); + + it('should require wallet', async () => { + const req = { + query: { + chainId: '1', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'wallet')).toBe(true); + }); + + it('should reject unsupported chainId', async () => { + const req = { + query: { + chainId: '999', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'chainId')).toBe(true); + }); + + it('should reject invalid wallet address', async () => { + const req = { + query: { + chainId: '1', + wallet: 'not-a-valid-address', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'wallet')).toBe(true); + }); + + it('should validate optional tokens parameter', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + }); + + it('should reject invalid token addresses', async () => { + const req = { + query: { + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + expect(errors.some(e => e.path === 'tokens')).toBe(true); + }); + + it('should validate all supported chains', async () => { + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + for (const chainId of SUPPORTED_CHAIN_IDS) { + const req = { query: { chainId: chainId.toString(), wallet } }; + const errors = await mockValidate(balanceValidationRules, req); + expect(errors).toHaveLength(0); + } }); }); + +describe('Error Message Quality', () => { + it('should provide helpful error messages', async () => { + const req = { + query: { + chainId: '999', + wallet: 'invalid', + }, + }; + + const errors = await mockValidate(balanceValidationRules, req); + + // Errors should be descriptive + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.msg.includes('chainId'))).toBe(true); + expect(errors.some(e => e.msg.includes('wallet'))).toBe(true); + }); +}); + +// Helper function for mock validation +function mockValidate(rules, req) { + const errors = []; + + if (!req.query.chainId) { + errors.push({ path: 'chainId', msg: 'chainId is required' }); + } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { + errors.push({ + path: 'chainId', + msg: 'chainId must be one of supported chains', + }); + } + + if (!req.query.wallet) { + errors.push({ path: 'wallet', msg: 'wallet address is required' }); + } else if (!isValidAddress(req.query.wallet)) { + errors.push({ + path: 'wallet', + msg: 'wallet must be a valid Ethereum address', + }); + } + + if (req.query.tokens && !areValidAddresses(req.query.tokens)) { + errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); + } + + return errors; +} From e154040024d2a51ae3986c6f1130cb6e142ce267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sun, 12 Oct 2025 13:57:05 +0900 Subject: [PATCH 14/29] refactor: modularize multiple configs --- src/config/priceConfig.js | 178 +---- src/config/swagger/balances.js | 119 +++ src/config/swagger/common.js | 91 +++ src/config/swagger/health.js | 28 + src/config/swagger/index.js | 49 ++ src/config/swagger/intents.js | 188 +++++ src/config/swagger/swaps.js | 153 ++++ src/config/swagger/tokens.js | 98 +++ src/config/swagger/vaults.js | 73 ++ src/config/swaggerConfig.js | 715 +----------------- src/config/tokenMappings/coingecko.json | 84 ++ src/config/tokenMappings/coinmarketcap.json | 84 ++ src/handlers/BaseStreamHandler.js | 2 +- src/intents/DustZapIntentHandler.js | 2 +- src/services/SSEStreamManager.js | 166 +--- src/services/fee/FeeCalculator.js | 174 +++++ src/services/fee/FeeInsertionStrategy.js | 315 ++++++++ src/services/fee/index.js | 12 + .../orchestrators/DustZapSSEOrchestrator.js | 169 +++++ src/services/orchestrators/index.js | 10 + src/utils/logger.js | 62 ++ test/SSEStreamManager.test.js | 6 +- test/unifiedZapExecutor.test.js | 92 +++ 23 files changed, 1817 insertions(+), 1053 deletions(-) create mode 100644 src/config/swagger/balances.js create mode 100644 src/config/swagger/common.js create mode 100644 src/config/swagger/health.js create mode 100644 src/config/swagger/index.js create mode 100644 src/config/swagger/intents.js create mode 100644 src/config/swagger/swaps.js create mode 100644 src/config/swagger/tokens.js create mode 100644 src/config/swagger/vaults.js create mode 100644 src/config/tokenMappings/coingecko.json create mode 100644 src/config/tokenMappings/coinmarketcap.json create mode 100644 src/services/fee/FeeCalculator.js create mode 100644 src/services/fee/FeeInsertionStrategy.js create mode 100644 src/services/fee/index.js create mode 100644 src/services/orchestrators/DustZapSSEOrchestrator.js create mode 100644 src/services/orchestrators/index.js create mode 100644 src/utils/logger.js create mode 100644 test/unifiedZapExecutor.test.js diff --git a/src/config/priceConfig.js b/src/config/priceConfig.js index 10136cb..da9aa5d 100644 --- a/src/config/priceConfig.js +++ b/src/config/priceConfig.js @@ -3,6 +3,9 @@ * Defines provider priorities, rate limits, and settings */ +const coinmarketcapMapping = require('./tokenMappings/coinmarketcap.json'); +const coingeckoMapping = require('./tokenMappings/coingecko.json'); + const priceConfig = { // Provider configurations in priority order providers: { @@ -53,179 +56,10 @@ const priceConfig = { retryDelay: 1000, }, - // Token symbol mappings for different providers + // Token symbol mappings for different providers (loaded from JSON files) tokenMappings: { - coinmarketcap: { - // CoinMarketCap uses numeric IDs - btc: '1', - eth: '1027', - weth: '2396', - usdc: '3408', - usdt: '825', - bnb: '1839', - ada: '2010', - sol: '5426', - xrp: '52', - dot: '6636', - doge: '74', - avax: '5805', - shib: '5994', - matic: '3890', - ltc: '2', - link: '1975', - uni: '7083', - atom: '3794', - etc: '1321', - xlm: '512', - algo: '4030', - vet: '3077', - icp: '8916', - fil: '2280', - trx: '1958', - eos: '1765', - aave: '7278', - mkr: '1518', - comp: '5692', - sushi: '6758', - snx: '2586', - crv: '6538', - yfi: '5864', - '1inch': '8104', - bal: '5728', - lrc: '1934', - zrx: '1896', - knc: '1982', - ren: '2539', - storj: '1772', - gnt: '1455', - bat: '1697', - zil: '2469', - icx: '2099', - qtum: '1684', - omg: '1808', - lsk: '1214', - ark: '1586', - strat: '1343', - waves: '1274', - dcr: '1168', - sc: '1042', - dgb: '109', - sys: '541', - pivx: '1169', - nxt: '66', - maid: '291', - gbyte: '1492', - rep: '1104', - fct: '1087', - game: '1027', - bts: '463', - steem: '1230', - exp: '1070', - amp: '6945', - lpt: '3640', - rpl: '2943', - enj: '2130', - mana: '1966', - sand: '6210', - axs: '6783', - gala: '7080', - chz: '4066', - flow: '4558', - imx: '10603', - apt: '21794', - sui: '20947', - arb: '21711', - op: '11840', - blur: '23121', - pepe: '24478', - floki: '23229', - }, - coingecko: { - // CoinGecko uses chain/address format or coin IDs - // For major coins, we can use coin IDs directly - btc: 'bitcoin', - eth: 'ethereum', - weth: 'weth', - usdc: 'usd-coin', - usdt: 'tether', - bnb: 'binancecoin', - ada: 'cardano', - sol: 'solana', - xrp: 'ripple', - dot: 'polkadot', - doge: 'dogecoin', - avax: 'avalanche-2', - shib: 'shiba-inu', - matic: 'matic-network', - ltc: 'litecoin', - link: 'chainlink', - uni: 'uniswap', - atom: 'cosmos', - etc: 'ethereum-classic', - xlm: 'stellar', - algo: 'algorand', - vet: 'vechain', - icp: 'internet-computer', - fil: 'filecoin', - trx: 'tron', - eos: 'eos', - aave: 'aave', - mkr: 'maker', - comp: 'compound-governance-token', - sushi: 'sushi', - snx: 'havven', - crv: 'curve-dao-token', - yfi: 'yearn-finance', - '1inch': '1inch', - bal: 'balancer', - lrc: 'loopring', - zrx: '0x', - knc: 'kyber-network-crystal', - ren: 'republic-protocol', - storj: 'storj', - gnt: 'golem', - bat: 'basic-attention-token', - zil: 'zilliqa', - icx: 'icon', - qtum: 'qtum', - omg: 'omisego', - lsk: 'lisk', - ark: 'ark', - strat: 'stratis', - waves: 'waves', - dcr: 'decred', - sc: 'siacoin', - dgb: 'digibyte', - sys: 'syscoin', - pivx: 'pivx', - nxt: 'nxt', - maid: 'maidsafecoin', - gbyte: 'byteball', - rep: 'augur', - fct: 'factom', - game: 'gamecredits', - bts: 'bitshares', - steem: 'steem', - exp: 'expanse', - amp: 'amp-token', - lpt: 'livepeer', - rpl: 'rocket-pool', - enj: 'enjincoin', - mana: 'decentraland', - sand: 'the-sandbox', - axs: 'axie-infinity', - gala: 'gala', - chz: 'chiliz', - flow: 'flow', - imx: 'immutable-x', - apt: 'aptos', - sui: 'sui', - arb: 'arbitrum', - op: 'optimism', - blur: 'blur', - pepe: 'pepe', - floki: 'floki', - }, + coinmarketcap: coinmarketcapMapping, + coingecko: coingeckoMapping, }, }; diff --git a/src/config/swagger/balances.js b/src/config/swagger/balances.js new file mode 100644 index 0000000..abf8528 --- /dev/null +++ b/src/config/swagger/balances.js @@ -0,0 +1,119 @@ +/** + * Swagger schemas for Balance operations + */ + +module.exports = { + schemas: { + BalanceRequest: { + type: 'object', + properties: { + tokens: { + type: 'array', + description: 'Optional list of token addresses to filter balances', + items: { + $ref: '#/components/schemas/EthereumAddress', + }, + }, + }, + }, + + BalanceResponse: { + type: 'object', + required: [ + 'success', + 'chainId', + 'address', + 'balances', + 'totalBalanceUSD', + 'timestamp', + 'metadata', + ], + properties: { + success: { + type: 'boolean', + example: true, + }, + chainId: { + $ref: '#/components/schemas/ChainId', + }, + address: { + $ref: '#/components/schemas/EthereumAddress', + }, + balances: { + type: 'array', + items: { + type: 'object', + required: [ + 'token', + 'symbol', + 'balance', + 'balanceUSD', + 'decimals', + 'price', + ], + properties: { + token: { + $ref: '#/components/schemas/EthereumAddress', + }, + symbol: { + type: 'string', + example: 'USDC', + }, + balance: { + type: 'string', + description: "Balance in token's smallest unit (wei/satoshis)", + example: '1000000000', + }, + balanceUSD: { + type: 'number', + description: 'Total balance value in USD', + example: 1000.5, + }, + decimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + }, + price: { + type: 'number', + description: 'Token price in USD', + example: 1.0, + }, + }, + }, + }, + totalBalanceUSD: { + type: 'number', + description: 'Total balance across all tokens in USD', + example: 5250.75, + }, + timestamp: { + type: 'string', + format: 'date-time', + description: 'Timestamp of balance retrieval', + }, + metadata: { + type: 'object', + properties: { + fromCache: { + type: 'boolean', + description: 'Whether the balance was retrieved from cache', + example: true, + }, + cacheAge: { + type: 'integer', + description: 'Age of cached data in seconds', + example: 15, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Balances', + description: 'Multi-chain token balance retrieval', + }, +}; diff --git a/src/config/swagger/common.js b/src/config/swagger/common.js new file mode 100644 index 0000000..fc9c241 --- /dev/null +++ b/src/config/swagger/common.js @@ -0,0 +1,91 @@ +/** + * Common Swagger schemas used across multiple domains + */ + +module.exports = { + schemas: { + EthereumAddress: { + type: 'string', + pattern: '^0x[a-fA-F0-9]{40}$', + example: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', + description: 'Valid Ethereum address', + }, + ChainId: { + type: 'integer', + enum: [1, 10, 137, 42161, 8453], + example: 1, + description: 'Supported blockchain network ID', + }, + ErrorResponse: { + type: 'object', + required: ['success', 'error'], + properties: { + success: { + type: 'boolean', + example: false, + }, + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'string', + example: 'INVALID_INPUT', + }, + message: { + type: 'string', + example: 'Invalid userAddress: must be a valid Ethereum address', + }, + details: { + type: 'object', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + + responses: { + BadRequest: { + description: 'Bad request - Invalid input parameters', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + NotFound: { + description: 'Resource not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + InternalServerError: { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + ServiceUnavailable: { + description: 'Service unavailable - External service error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + }, +}; diff --git a/src/config/swagger/health.js b/src/config/swagger/health.js new file mode 100644 index 0000000..2e18fb2 --- /dev/null +++ b/src/config/swagger/health.js @@ -0,0 +1,28 @@ +/** + * Swagger schemas for Health check operations + */ + +module.exports = { + schemas: { + HealthResponse: { + type: 'object', + required: ['status', 'timestamp'], + properties: { + status: { + type: 'string', + enum: ['healthy'], + example: 'healthy', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, + }, + }, + + tag: { + name: 'Health', + description: 'API health checks', + }, +}; diff --git a/src/config/swagger/index.js b/src/config/swagger/index.js new file mode 100644 index 0000000..f0e9557 --- /dev/null +++ b/src/config/swagger/index.js @@ -0,0 +1,49 @@ +/** + * Swagger Configuration Module Index + * Aggregates all domain-specific swagger definitions + */ + +const common = require('./common'); +const intents = require('./intents'); +const swaps = require('./swaps'); +const tokens = require('./tokens'); +const balances = require('./balances'); +const health = require('./health'); +const vaults = require('./vaults'); + +/** + * Merge all schemas from domain modules + * @returns {Object} Combined schemas object + */ +function mergeSchemas() { + return { + ...common.schemas, + ...intents.schemas, + ...swaps.schemas, + ...tokens.schemas, + ...balances.schemas, + ...vaults.schemas, + ...health.schemas, + }; +} + +/** + * Collect all tags from domain modules + * @returns {Array} Array of tag definitions + */ +function collectTags() { + return [ + intents.tag, + swaps.tag, + tokens.tag, + balances.tag, + vaults.tag, + health.tag, + ]; +} + +module.exports = { + schemas: mergeSchemas(), + responses: common.responses, + tags: collectTags(), +}; diff --git a/src/config/swagger/intents.js b/src/config/swagger/intents.js new file mode 100644 index 0000000..d43c049 --- /dev/null +++ b/src/config/swagger/intents.js @@ -0,0 +1,188 @@ +/** + * Swagger schemas for Intent-based operations + */ + +module.exports = { + schemas: { + IntentRequest: { + type: 'object', + required: ['userAddress', 'chainId', 'params'], + properties: { + userAddress: { + $ref: '#/components/schemas/EthereumAddress', + }, + chainId: { + $ref: '#/components/schemas/ChainId', + }, + params: { + type: 'object', + additionalProperties: true, + }, + }, + }, + + DustZapParams: { + type: 'object', + required: ['toTokenAddress', 'toTokenDecimals'], + properties: { + dustThreshold: { + type: 'number', + minimum: 0, + example: 5, + description: 'Minimum USD value threshold for dust tokens', + }, + targetToken: { + type: 'string', + enum: ['ETH'], + example: 'ETH', + description: 'Target token symbol (currently only ETH supported)', + }, + referralAddress: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Optional referral address for fee sharing', + }, + toTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Target token contract address', + }, + toTokenDecimals: { + type: 'integer', + minimum: 1, + maximum: 18, + example: 18, + description: 'Number of decimals for target token', + }, + slippage: { + type: 'number', + minimum: 0, + maximum: 100, + example: 1, + description: 'Slippage tolerance percentage', + }, + dustTokens: { + type: 'array', + items: { + type: 'object', + required: [ + 'address', + 'symbol', + 'amount', + 'price', + 'decimals', + 'raw_amount_hex_str', + ], + properties: { + address: { + $ref: '#/components/schemas/EthereumAddress', + description: 'Token contract address', + }, + symbol: { + type: 'string', + example: 'OpenUSDT', + description: 'Token symbol', + }, + amount: { + type: 'number', + example: 0.943473, + description: 'Token amount in human readable format', + }, + price: { + type: 'number', + example: 0.99985, + description: 'Token price in USD', + }, + decimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + description: 'Number of decimals for the token', + }, + raw_amount_hex_str: { + type: 'string', + example: '0xe6571', + description: 'Token amount in hex string format', + }, + }, + }, + example: [ + { + address: '0x1217bfe6c773eec6cc4a38b5dc45b92292b6e189', + symbol: 'OpenUSDT', + amount: 0.943473, + price: 0.99985, + decimals: 6, + raw_amount_hex_str: '0xe6571', + }, + { + address: '0x526728dbc96689597f85ae4cd716d4f7fccbae9d', + symbol: 'msUSD', + amount: 0.040852155251341185, + price: 0.9962465895840099, + decimals: 18, + raw_amount_hex_str: '0x9122d19a10b77f', + }, + ], + description: 'Array of dust tokens to be converted (dynamic length)', + }, + }, + }, + + DustZapResponse: { + type: 'object', + required: [ + 'success', + 'intentType', + 'mode', + 'intentId', + 'streamUrl', + 'metadata', + ], + properties: { + success: { + type: 'boolean', + example: true, + }, + intentType: { + type: 'string', + example: 'dustZap', + }, + mode: { + type: 'string', + example: 'streaming', + }, + intentId: { + type: 'string', + example: 'dustZap_1640995200000_abc123_def456789abcdef0', + }, + streamUrl: { + type: 'string', + example: + '/api/dustzap/dustZap_1640995200000_abc123_def456789abcdef0/stream', + }, + metadata: { + type: 'object', + properties: { + totalTokens: { + type: 'integer', + example: 5, + }, + estimatedDuration: { + type: 'string', + example: '5-10 seconds', + }, + streamingEnabled: { + type: 'boolean', + example: true, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Intents', + description: 'Intent-based DeFi operations', + }, +}; diff --git a/src/config/swagger/swaps.js b/src/config/swagger/swaps.js new file mode 100644 index 0000000..77e069e --- /dev/null +++ b/src/config/swagger/swaps.js @@ -0,0 +1,153 @@ +/** + * Swagger schemas for Swap operations + */ + +module.exports = { + schemas: { + SwapQuoteRequest: { + type: 'object', + required: [ + 'chainId', + 'fromTokenAddress', + 'fromTokenDecimals', + 'toTokenAddress', + 'toTokenDecimals', + 'amount', + 'fromAddress', + 'slippage', + 'to_token_price', + ], + properties: { + chainId: { + $ref: '#/components/schemas/ChainId', + }, + fromTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + example: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + }, + fromTokenDecimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 18, + }, + toTokenAddress: { + $ref: '#/components/schemas/EthereumAddress', + example: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + toTokenDecimals: { + type: 'integer', + minimum: 0, + maximum: 18, + example: 6, + }, + amount: { + type: 'string', + example: '1000000000000000000', + description: 'Amount to swap in smallest token unit (wei)', + }, + fromAddress: { + $ref: '#/components/schemas/EthereumAddress', + }, + slippage: { + type: 'number', + minimum: 0, + maximum: 100, + example: 1, + }, + to_token_price: { + type: 'number', + example: 1000, + description: 'Destination token price in USD', + }, + eth_price: { + type: 'number', + example: 3000, + description: 'ETH price in USD (optional, default: 1000)', + }, + }, + }, + + SwapQuoteResponse: { + type: 'object', + required: [ + 'approve_to', + 'to', + 'toAmount', + 'minToAmount', + 'data', + 'gasCostUSD', + 'gas', + 'custom_slippage', + 'toUsd', + 'provider', + ], + properties: { + approve_to: { + $ref: '#/components/schemas/EthereumAddress', + }, + to: { + $ref: '#/components/schemas/EthereumAddress', + }, + toAmount: { + type: 'string', + example: '1000000000', + }, + minToAmount: { + type: 'string', + example: '990000000', + }, + data: { + type: 'string', + example: '0x...', + }, + gasCostUSD: { + type: 'number', + example: 25.5, + }, + gas: { + type: 'string', + example: '200000', + }, + custom_slippage: { + type: 'number', + example: 100, + }, + toUsd: { + type: 'number', + example: 974.5, + }, + provider: { + type: 'string', + enum: ['1inch', 'paraswap', '0x'], + example: '1inch', + }, + allQuotes: { + type: 'array', + items: { + type: 'object', + properties: { + provider: { + type: 'string', + }, + toUsd: { + type: 'number', + }, + gasCostUSD: { + type: 'number', + }, + toAmount: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + + tag: { + name: 'Swaps', + description: 'DEX aggregator swap operations', + }, +}; diff --git a/src/config/swagger/tokens.js b/src/config/swagger/tokens.js new file mode 100644 index 0000000..4bbfba7 --- /dev/null +++ b/src/config/swagger/tokens.js @@ -0,0 +1,98 @@ +/** + * Swagger schemas for Token price operations + */ + +module.exports = { + schemas: { + TokenPricesResponse: { + type: 'object', + required: [ + 'results', + 'errors', + 'totalRequested', + 'fromCache', + 'fromProviders', + 'failed', + 'timestamp', + ], + properties: { + results: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: true, + }, + price: { + type: 'number', + example: 45000.5, + }, + symbol: { + type: 'string', + example: 'btc', + }, + provider: { + type: 'string', + example: 'coinmarketcap', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + fromCache: { + type: 'boolean', + example: false, + }, + metadata: { + type: 'object', + properties: { + tokenId: { + type: 'string', + }, + marketCap: { + type: 'number', + }, + volume24h: { + type: 'number', + }, + percentChange24h: { + type: 'number', + }, + }, + }, + }, + }, + }, + errors: { + type: 'array', + items: { + type: 'string', + }, + }, + totalRequested: { + type: 'integer', + }, + fromCache: { + type: 'integer', + }, + fromProviders: { + type: 'integer', + }, + failed: { + type: 'integer', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, + }, + }, + + tag: { + name: 'Prices', + description: 'Token price data with fallback providers', + }, +}; diff --git a/src/config/swagger/vaults.js b/src/config/swagger/vaults.js new file mode 100644 index 0000000..6589e59 --- /dev/null +++ b/src/config/swagger/vaults.js @@ -0,0 +1,73 @@ +/** + * Swagger schemas for Vault operations + */ + +module.exports = { + schemas: { + VaultInfo: { + type: 'object', + required: [ + 'id', + 'name', + 'description', + 'riskLevel', + 'expectedAPR', + 'supportedChains', + 'totalTVL', + 'status', + ], + properties: { + id: { + type: 'string', + example: 'stablecoin-vault', + }, + name: { + type: 'string', + example: 'Stablecoin Vault', + }, + description: { + type: 'string', + example: 'Low-risk yield generation with stablecoins', + }, + riskLevel: { + type: 'string', + enum: ['low', 'medium', 'medium-high', 'high'], + example: 'low', + }, + expectedAPR: { + type: 'object', + properties: { + min: { + type: 'number', + example: 5, + }, + max: { + type: 'number', + example: 15, + }, + }, + }, + supportedChains: { + type: 'array', + items: { + $ref: '#/components/schemas/ChainId', + }, + }, + totalTVL: { + type: 'number', + example: 0, + }, + status: { + type: 'string', + enum: ['active', 'inactive', 'deprecated'], + example: 'active', + }, + }, + }, + }, + + tag: { + name: 'Vaults', + description: 'Vault strategy information', + }, +}; diff --git a/src/config/swaggerConfig.js b/src/config/swaggerConfig.js index 141c74a..586dde8 100644 --- a/src/config/swaggerConfig.js +++ b/src/config/swaggerConfig.js @@ -1,7 +1,9 @@ const swaggerJsdoc = require('swagger-jsdoc'); +const swaggerDefinitions = require('./swagger'); /** * Swagger configuration for Intent Engine API + * Refactored to use modular domain-specific definitions */ const swaggerOptions = { definition: { @@ -27,717 +29,10 @@ const swaggerOptions = { }, ], components: { - schemas: { - // Common schemas - EthereumAddress: { - type: 'string', - pattern: '^0x[a-fA-F0-9]{40}$', - example: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', - description: 'Valid Ethereum address', - }, - ChainId: { - type: 'integer', - enum: [1, 10, 137, 42161, 8453], - example: 1, - description: 'Supported blockchain network ID', - }, - ErrorResponse: { - type: 'object', - required: ['success', 'error'], - properties: { - success: { - type: 'boolean', - example: false, - }, - error: { - type: 'object', - required: ['code', 'message'], - properties: { - code: { - type: 'string', - example: 'INVALID_INPUT', - }, - message: { - type: 'string', - example: - 'Invalid userAddress: must be a valid Ethereum address', - }, - details: { - type: 'object', - additionalProperties: true, - }, - }, - }, - }, - }, - - // Intent schemas - IntentRequest: { - type: 'object', - required: ['userAddress', 'chainId', 'params'], - properties: { - userAddress: { - $ref: '#/components/schemas/EthereumAddress', - }, - chainId: { - $ref: '#/components/schemas/ChainId', - }, - params: { - type: 'object', - additionalProperties: true, - }, - }, - }, - - DustZapParams: { - type: 'object', - required: ['toTokenAddress', 'toTokenDecimals'], - properties: { - dustThreshold: { - type: 'number', - minimum: 0, - example: 5, - description: 'Minimum USD value threshold for dust tokens', - }, - targetToken: { - type: 'string', - enum: ['ETH'], - example: 'ETH', - description: 'Target token symbol (currently only ETH supported)', - }, - referralAddress: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Optional referral address for fee sharing', - }, - toTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Target token contract address', - }, - toTokenDecimals: { - type: 'integer', - minimum: 1, - maximum: 18, - example: 18, - description: 'Number of decimals for target token', - }, - slippage: { - type: 'number', - minimum: 0, - maximum: 100, - example: 1, - description: 'Slippage tolerance percentage', - }, - dustTokens: { - type: 'array', - items: { - type: 'object', - required: [ - 'address', - 'symbol', - 'amount', - 'price', - 'decimals', - 'raw_amount_hex_str', - ], - properties: { - address: { - $ref: '#/components/schemas/EthereumAddress', - description: 'Token contract address', - }, - symbol: { - type: 'string', - example: 'OpenUSDT', - description: 'Token symbol', - }, - amount: { - type: 'number', - example: 0.943473, - description: 'Token amount in human readable format', - }, - price: { - type: 'number', - example: 0.99985, - description: 'Token price in USD', - }, - decimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 6, - description: 'Number of decimals for the token', - }, - raw_amount_hex_str: { - type: 'string', - example: '0xe6571', - description: 'Token amount in hex string format', - }, - }, - }, - example: [ - { - address: '0x1217bfe6c773eec6cc4a38b5dc45b92292b6e189', - symbol: 'OpenUSDT', - amount: 0.943473, - price: 0.99985, - decimals: 6, - raw_amount_hex_str: '0xe6571', - }, - { - address: '0x526728dbc96689597f85ae4cd716d4f7fccbae9d', - symbol: 'msUSD', - amount: 0.040852155251341185, - price: 0.9962465895840099, - decimals: 18, - raw_amount_hex_str: '0x9122d19a10b77f', - }, - ], - description: - 'Array of dust tokens to be converted (dynamic length)', - }, - }, - }, - - DustZapResponse: { - type: 'object', - required: [ - 'success', - 'intentType', - 'mode', - 'intentId', - 'streamUrl', - 'metadata', - ], - properties: { - success: { - type: 'boolean', - example: true, - }, - intentType: { - type: 'string', - example: 'dustZap', - }, - mode: { - type: 'string', - example: 'streaming', - }, - intentId: { - type: 'string', - example: 'dustZap_1640995200000_abc123_def456789abcdef0', - }, - streamUrl: { - type: 'string', - example: - '/api/dustzap/dustZap_1640995200000_abc123_def456789abcdef0/stream', - }, - metadata: { - type: 'object', - properties: { - totalTokens: { - type: 'integer', - example: 5, - }, - estimatedDuration: { - type: 'string', - example: '5-10 seconds', - }, - streamingEnabled: { - type: 'boolean', - example: true, - }, - }, - }, - }, - }, - - // Swap schemas - SwapQuoteRequest: { - type: 'object', - required: [ - 'chainId', - 'fromTokenAddress', - 'fromTokenDecimals', - 'toTokenAddress', - 'toTokenDecimals', - 'amount', - 'fromAddress', - 'slippage', - 'to_token_price', - ], - properties: { - chainId: { - $ref: '#/components/schemas/ChainId', - }, - fromTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - example: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - }, - fromTokenDecimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 18, - }, - toTokenAddress: { - $ref: '#/components/schemas/EthereumAddress', - example: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - }, - toTokenDecimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 6, - }, - amount: { - type: 'string', - example: '1000000000000000000', - description: 'Amount to swap in smallest token unit (wei)', - }, - fromAddress: { - $ref: '#/components/schemas/EthereumAddress', - }, - slippage: { - type: 'number', - minimum: 0, - maximum: 100, - example: 1, - }, - to_token_price: { - type: 'number', - example: 1000, - description: 'Destination token price in USD', - }, - eth_price: { - type: 'number', - example: 3000, - description: 'ETH price in USD (optional, default: 1000)', - }, - }, - }, - - SwapQuoteResponse: { - type: 'object', - required: [ - 'approve_to', - 'to', - 'toAmount', - 'minToAmount', - 'data', - 'gasCostUSD', - 'gas', - 'custom_slippage', - 'toUsd', - 'provider', - ], - properties: { - approve_to: { - $ref: '#/components/schemas/EthereumAddress', - }, - to: { - $ref: '#/components/schemas/EthereumAddress', - }, - toAmount: { - type: 'string', - example: '1000000000', - }, - minToAmount: { - type: 'string', - example: '990000000', - }, - data: { - type: 'string', - example: '0x...', - }, - gasCostUSD: { - type: 'number', - example: 25.5, - }, - gas: { - type: 'string', - example: '200000', - }, - custom_slippage: { - type: 'number', - example: 100, - }, - toUsd: { - type: 'number', - example: 974.5, - }, - provider: { - type: 'string', - enum: ['1inch', 'paraswap', '0x'], - example: '1inch', - }, - allQuotes: { - type: 'array', - items: { - type: 'object', - properties: { - provider: { - type: 'string', - }, - toUsd: { - type: 'number', - }, - gasCostUSD: { - type: 'number', - }, - toAmount: { - type: 'string', - }, - }, - }, - }, - }, - }, - - // Price schemas - TokenPricesResponse: { - type: 'object', - required: [ - 'results', - 'errors', - 'totalRequested', - 'fromCache', - 'fromProviders', - 'failed', - 'timestamp', - ], - properties: { - results: { - type: 'object', - additionalProperties: { - type: 'object', - properties: { - success: { - type: 'boolean', - example: true, - }, - price: { - type: 'number', - example: 45000.5, - }, - symbol: { - type: 'string', - example: 'btc', - }, - provider: { - type: 'string', - example: 'coinmarketcap', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - fromCache: { - type: 'boolean', - example: false, - }, - metadata: { - type: 'object', - properties: { - tokenId: { - type: 'string', - }, - marketCap: { - type: 'number', - }, - volume24h: { - type: 'number', - }, - percentChange24h: { - type: 'number', - }, - }, - }, - }, - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - totalRequested: { - type: 'integer', - }, - fromCache: { - type: 'integer', - }, - fromProviders: { - type: 'integer', - }, - failed: { - type: 'integer', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, - - // Health check schemas - HealthResponse: { - type: 'object', - required: ['status', 'timestamp'], - properties: { - status: { - type: 'string', - enum: ['healthy'], - example: 'healthy', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, - - // Vault schemas - VaultInfo: { - type: 'object', - required: [ - 'id', - 'name', - 'description', - 'riskLevel', - 'expectedAPR', - 'supportedChains', - 'totalTVL', - 'status', - ], - properties: { - id: { - type: 'string', - example: 'stablecoin-vault', - }, - name: { - type: 'string', - example: 'Stablecoin Vault', - }, - description: { - type: 'string', - example: 'Low-risk yield generation with stablecoins', - }, - riskLevel: { - type: 'string', - enum: ['low', 'medium', 'medium-high', 'high'], - example: 'low', - }, - expectedAPR: { - type: 'object', - properties: { - min: { - type: 'number', - example: 5, - }, - max: { - type: 'number', - example: 15, - }, - }, - }, - supportedChains: { - type: 'array', - items: { - $ref: '#/components/schemas/ChainId', - }, - }, - totalTVL: { - type: 'number', - example: 0, - }, - status: { - type: 'string', - enum: ['active', 'inactive', 'deprecated'], - example: 'active', - }, - }, - }, - - // Balance schemas - BalanceRequest: { - type: 'object', - properties: { - tokens: { - type: 'array', - description: - 'Optional list of token addresses to filter balances', - items: { - $ref: '#/components/schemas/EthereumAddress', - }, - }, - }, - }, - - BalanceResponse: { - type: 'object', - required: [ - 'success', - 'chainId', - 'address', - 'balances', - 'totalBalanceUSD', - 'timestamp', - 'metadata', - ], - properties: { - success: { - type: 'boolean', - example: true, - }, - chainId: { - $ref: '#/components/schemas/ChainId', - }, - address: { - $ref: '#/components/schemas/EthereumAddress', - }, - balances: { - type: 'array', - items: { - type: 'object', - required: [ - 'token', - 'symbol', - 'balance', - 'balanceUSD', - 'decimals', - 'price', - ], - properties: { - token: { - $ref: '#/components/schemas/EthereumAddress', - }, - symbol: { - type: 'string', - example: 'USDC', - }, - balance: { - type: 'string', - description: - "Balance in token's smallest unit (wei/satoshis)", - example: '1000000000', - }, - balanceUSD: { - type: 'number', - description: 'Total balance value in USD', - example: 1000.5, - }, - decimals: { - type: 'integer', - minimum: 0, - maximum: 18, - example: 6, - }, - price: { - type: 'number', - description: 'Token price in USD', - example: 1.0, - }, - }, - }, - }, - totalBalanceUSD: { - type: 'number', - description: 'Total balance across all tokens in USD', - example: 5250.75, - }, - timestamp: { - type: 'string', - format: 'date-time', - description: 'Timestamp of balance retrieval', - }, - metadata: { - type: 'object', - properties: { - fromCache: { - type: 'boolean', - description: 'Whether the balance was retrieved from cache', - example: true, - }, - cacheAge: { - type: 'integer', - description: 'Age of cached data in seconds', - example: 15, - }, - }, - }, - }, - }, - }, - - responses: { - BadRequest: { - description: 'Bad request - Invalid input parameters', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - NotFound: { - description: 'Resource not found', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - InternalServerError: { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - ServiceUnavailable: { - description: 'Service unavailable - External service error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/ErrorResponse', - }, - }, - }, - }, - }, + schemas: swaggerDefinitions.schemas, + responses: swaggerDefinitions.responses, }, - - tags: [ - { - name: 'Intents', - description: 'Intent-based DeFi operations', - }, - { - name: 'Swaps', - description: 'DEX aggregator swap operations', - }, - { - name: 'Prices', - description: 'Token price data with fallback providers', - }, - { - name: 'Balances', - description: 'Multi-chain token balance retrieval', - }, - { - name: 'Vaults', - description: 'Vault strategy information', - }, - { - name: 'Health', - description: 'API health checks', - }, - ], + tags: swaggerDefinitions.tags, }, apis: [ './src/routes/*.js', // Path to the API docs diff --git a/src/config/tokenMappings/coingecko.json b/src/config/tokenMappings/coingecko.json new file mode 100644 index 0000000..19714f3 --- /dev/null +++ b/src/config/tokenMappings/coingecko.json @@ -0,0 +1,84 @@ +{ + "btc": "bitcoin", + "eth": "ethereum", + "weth": "weth", + "usdc": "usd-coin", + "usdt": "tether", + "bnb": "binancecoin", + "ada": "cardano", + "sol": "solana", + "xrp": "ripple", + "dot": "polkadot", + "doge": "dogecoin", + "avax": "avalanche-2", + "shib": "shiba-inu", + "matic": "matic-network", + "ltc": "litecoin", + "link": "chainlink", + "uni": "uniswap", + "atom": "cosmos", + "etc": "ethereum-classic", + "xlm": "stellar", + "algo": "algorand", + "vet": "vechain", + "icp": "internet-computer", + "fil": "filecoin", + "trx": "tron", + "eos": "eos", + "aave": "aave", + "mkr": "maker", + "comp": "compound-governance-token", + "sushi": "sushi", + "snx": "havven", + "crv": "curve-dao-token", + "yfi": "yearn-finance", + "1inch": "1inch", + "bal": "balancer", + "lrc": "loopring", + "zrx": "0x", + "knc": "kyber-network-crystal", + "ren": "republic-protocol", + "storj": "storj", + "gnt": "golem", + "bat": "basic-attention-token", + "zil": "zilliqa", + "icx": "icon", + "qtum": "qtum", + "omg": "omisego", + "lsk": "lisk", + "ark": "ark", + "strat": "stratis", + "waves": "waves", + "dcr": "decred", + "sc": "siacoin", + "dgb": "digibyte", + "sys": "syscoin", + "pivx": "pivx", + "nxt": "nxt", + "maid": "maidsafecoin", + "gbyte": "byteball", + "rep": "augur", + "fct": "factom", + "game": "gamecredits", + "bts": "bitshares", + "steem": "steem", + "exp": "expanse", + "amp": "amp-token", + "lpt": "livepeer", + "rpl": "rocket-pool", + "enj": "enjincoin", + "mana": "decentraland", + "sand": "the-sandbox", + "axs": "axie-infinity", + "gala": "gala", + "chz": "chiliz", + "flow": "flow", + "imx": "immutable-x", + "apt": "aptos", + "sui": "sui", + "arb": "arbitrum", + "op": "optimism", + "blur": "blur", + "pepe": "pepe", + "floki": "floki" +} diff --git a/src/config/tokenMappings/coinmarketcap.json b/src/config/tokenMappings/coinmarketcap.json new file mode 100644 index 0000000..f5cdfb7 --- /dev/null +++ b/src/config/tokenMappings/coinmarketcap.json @@ -0,0 +1,84 @@ +{ + "btc": "1", + "eth": "1027", + "weth": "2396", + "usdc": "3408", + "usdt": "825", + "bnb": "1839", + "ada": "2010", + "sol": "5426", + "xrp": "52", + "dot": "6636", + "doge": "74", + "avax": "5805", + "shib": "5994", + "matic": "3890", + "ltc": "2", + "link": "1975", + "uni": "7083", + "atom": "3794", + "etc": "1321", + "xlm": "512", + "algo": "4030", + "vet": "3077", + "icp": "8916", + "fil": "2280", + "trx": "1958", + "eos": "1765", + "aave": "7278", + "mkr": "1518", + "comp": "5692", + "sushi": "6758", + "snx": "2586", + "crv": "6538", + "yfi": "5864", + "1inch": "8104", + "bal": "5728", + "lrc": "1934", + "zrx": "1896", + "knc": "1982", + "ren": "2539", + "storj": "1772", + "gnt": "1455", + "bat": "1697", + "zil": "2469", + "icx": "2099", + "qtum": "1684", + "omg": "1808", + "lsk": "1214", + "ark": "1586", + "strat": "1343", + "waves": "1274", + "dcr": "1168", + "sc": "1042", + "dgb": "109", + "sys": "541", + "pivx": "1169", + "nxt": "66", + "maid": "291", + "gbyte": "1492", + "rep": "1104", + "fct": "1087", + "game": "1027", + "bts": "463", + "steem": "1230", + "exp": "1070", + "amp": "6945", + "lpt": "3640", + "rpl": "2943", + "enj": "2130", + "mana": "1966", + "sand": "6210", + "axs": "6783", + "gala": "7080", + "chz": "4066", + "flow": "4558", + "imx": "10603", + "apt": "21794", + "sui": "20947", + "arb": "21711", + "op": "11840", + "blur": "23121", + "pepe": "24478", + "floki": "23229" +} diff --git a/src/handlers/BaseStreamHandler.js b/src/handlers/BaseStreamHandler.js index 25b15b3..24677ef 100644 --- a/src/handlers/BaseStreamHandler.js +++ b/src/handlers/BaseStreamHandler.js @@ -4,7 +4,7 @@ */ const IntentIdGenerator = require('../utils/intentIdGenerator'); -const { SSEStreamManager } = require('../services/SSEStreamManager'); +const SSEStreamManager = require('../services/SSEStreamManager'); class BaseStreamHandler { constructor(intentService) { diff --git a/src/intents/DustZapIntentHandler.js b/src/intents/DustZapIntentHandler.js index 2bd8541..5261edc 100644 --- a/src/intents/DustZapIntentHandler.js +++ b/src/intents/DustZapIntentHandler.js @@ -92,7 +92,7 @@ class DustZapIntentHandler extends BaseIntentHandler { */ processTokensWithSSEStreaming(executionContext, streamWriter) { // Import here to avoid circular dependencies - const { DustZapSSEOrchestrator } = require('../services/SSEStreamManager'); + const DustZapSSEOrchestrator = require('../services/orchestrators/DustZapSSEOrchestrator'); const sseOrchestrator = new DustZapSSEOrchestrator(this); return sseOrchestrator.orchestrateSSEStreaming( diff --git a/src/services/SSEStreamManager.js b/src/services/SSEStreamManager.js index 7eef850..a7e1f34 100644 --- a/src/services/SSEStreamManager.js +++ b/src/services/SSEStreamManager.js @@ -294,168 +294,4 @@ class SSEStreamManager { } } -/** - * DustZapSSEOrchestrator - Orchestrates SSE streaming for DustZap intents - * Separates infrastructure concerns from business logic - */ -class DustZapSSEOrchestrator { - constructor(dustZapHandler) { - this.dustZapHandler = dustZapHandler; - } - - /** - * Handle complete DustZap SSE streaming workflow - * @param {Object} executionContext - Execution context - * @param {Function} streamWriter - SSE stream writer - * @returns {Promise} - Final processing results - */ - async orchestrateSSEStreaming(executionContext, streamWriter) { - try { - // 1. Send initial connection confirmation (already handled by SSEStreamManager) - - // 2. Process tokens through business logic with SSE events - const processingResults = await this.processTokensWithStreaming( - executionContext, - streamWriter - ); - - // 3. Send completion event - const completionEvent = this.createCompletionEvent( - processingResults, - executionContext - ); - streamWriter(completionEvent); - - return processingResults; - } catch (error) { - console.error('SSE orchestration error:', error); - - const errorEvent = this.createErrorEvent(error, { - processedTokens: 0, - totalTokens: executionContext.dustTokens?.length || 0, - }); - streamWriter(errorEvent); - throw error; - } - } - - /** - * Process tokens with streaming events (pure orchestration) - * @param {Object} executionContext - Execution context - * @param {Function} streamWriter - SSE stream writer - * @returns {Promise} - Processing results - */ - async processTokensWithStreaming(executionContext, streamWriter) { - // Create processing context for swap service (same as business logic) - const processingContext = - SwapProcessingService.createProcessingContext(executionContext); - - // Calculate fee transactions and insertion strategy (same as business logic) - const { dustTokens, params } = executionContext; - const { referralAddress } = params; - - // Calculate estimated total value for fee calculations - let estimatedTotalValueUSD = 0; - for (const token of dustTokens) { - estimatedTotalValueUSD += token.amount * token.price || 0; - } - - // Pre-calculate fee transactions using estimated value - const { txBuilder: feeTxBuilder, feeAmounts } = - this.dustZapHandler.executor.feeCalculationService.createFeeTransactions( - estimatedTotalValueUSD, - executionContext.ethPrice, - executionContext.chainId, - referralAddress - ); - - const feeTransactions = feeTxBuilder.getTransactions(); - - // Calculate insertion strategy - const batches = executionContext.batches || [dustTokens]; - const totalExpectedTransactions = dustTokens.length * 2; - const insertionStrategy = - this.dustZapHandler.executor.smartFeeInsertionService.calculateInsertionStrategy( - batches, - feeAmounts.totalFeeETH, - totalExpectedTransactions, - feeTransactions.length - ); - - // ✅ FIX: Use processTokenBatchWithSSE for real-time streaming - const batchResults = - await this.dustZapHandler.executor.swapProcessingService.processTokenBatchWithSSE( - { - tokens: dustTokens, - context: processingContext, - streamWriter: streamWriter, // This enables real-time streaming - feeTransactions: feeTransactions, - insertionStrategy: insertionStrategy, - } - ); - - // Calculate actual total value from successful swaps - let actualTotalValueUSD = 0; - for (const result of batchResults.successful) { - actualTotalValueUSD += result.inputValueUSD || 0; - } - - const feeInfo = - this.dustZapHandler.executor.feeCalculationService.buildFeeInfo( - actualTotalValueUSD, - referralAddress, - true // useWETHPattern - ); - - return { - allTransactions: [...batchResults.transactions], - totalValueUSD: actualTotalValueUSD, - processedTokens: - batchResults.successful.length + batchResults.failed.length, - successfulTokens: batchResults.successful.length, - failedTokens: batchResults.failed.length, - successful: batchResults.successful, - failed: batchResults.failed, - feeInsertionStrategy: insertionStrategy, - feeInfo, - }; - } - - /** - * Create completion event for SSE stream - * @param {Object} processingResults - Processing results - * @param {Object} executionContext - Execution context - * @returns {Object} - SSE completion event - */ - createCompletionEvent(processingResults, executionContext) { - return SSEEventFactory.createCompletionEvent({ - transactions: processingResults.allTransactions || [], - metadata: { - totalTokens: executionContext.dustTokens?.length || 0, - processedTokens: - (processingResults.successful?.length || 0) + - (processingResults.failed?.length || 0), - successfulTokens: processingResults.successful?.length || 0, - failedTokens: processingResults.failed?.length || 0, - totalValueUSD: processingResults.totalValueUSD || 0, - feeInfo: processingResults.feeInfo || null, - feeInsertionStrategy: processingResults.feeInsertionStrategy || null, - }, - }); - } - - /** - * Create error event for SSE stream - * @param {Error} error - Error that occurred - * @param {Object} context - Additional context - * @returns {Object} - SSE error event - */ - createErrorEvent(error, context) { - return SSEEventFactory.createErrorEvent(error, context); - } -} - -module.exports = { - SSEStreamManager, - DustZapSSEOrchestrator, -}; +module.exports = SSEStreamManager; diff --git a/src/services/fee/FeeCalculator.js b/src/services/fee/FeeCalculator.js new file mode 100644 index 0000000..7a44228 --- /dev/null +++ b/src/services/fee/FeeCalculator.js @@ -0,0 +1,174 @@ +/** + * Fee Calculator + * Handles calculation logic for fee thresholds and insertion points + */ + +const crypto = require('crypto'); +const DUST_ZAP_CONFIG = require('../../config/dustZapConfig'); + +class FeeCalculator { + /** + * Calculate minimum threshold for fee insertion based on expected ETH availability + * @param {Array} batches - Array of token batches to be processed + * @param {number} totalFeeETH - Total fee amount needed in ETH + * @param {Object} options - Configuration options + * @param {number} options.minimumThresholdPercentage - Minimum percentage of swaps to complete (default: 0.4) + * @param {number} options.safetyBuffer - Additional safety buffer (default: 0.1) + * @returns {number} - Minimum transaction index where fees can be inserted + */ + calculateMinimumThreshold(batches, totalFeeETH, options = {}) { + const { + minimumThresholdPercentage = 0.4, // Wait for 40% of swaps to complete + safetyBuffer = 0.1, // Add 10% safety buffer + } = options; + + // Calculate total number of swap transactions (approve + swap per token) + const totalTokens = batches.reduce((sum, batch) => sum + batch.length, 0); + const totalSwapTransactions = + totalTokens * DUST_ZAP_CONFIG.TRANSACTIONS_PER_TOKEN; + + // Calculate minimum threshold with safety buffer + const minimumSwapPercentage = minimumThresholdPercentage + safetyBuffer; + const minimumSwapsNeeded = Math.ceil( + totalSwapTransactions * minimumSwapPercentage + ); + + // Ensure we have at least some transactions completed before inserting fees + const absoluteMinimum = Math.ceil(totalTokens * 0.2); // At least 20% of tokens processed + + return Math.max(minimumSwapsNeeded, absoluteMinimum); + } + + /** + * Generate random insertion points for fee transactions + * @param {number} minimumIndex - Minimum index where fees can be inserted + * @param {number} maxIndex - Maximum index (total transaction count) + * @param {number} feeTransactionCount - Number of fee transactions to insert + * @param {Object} options - Configuration options + * @param {number} options.spreadFactor - How spread out the fee transactions should be (default: 0.3) + * @returns {Array} - Sorted array of insertion indices + */ + generateRandomInsertionPoints( + minimumIndex, + maxIndex, + feeTransactionCount, + options = {} + ) { + const { spreadFactor = 0.3 } = options; + + if (minimumIndex >= maxIndex) { + // Fallback: if minimum index is too high, insert near the end but still randomized + const fallbackRange = Math.max(3, Math.floor(maxIndex * 0.1)); // Use last 10% or at least 3 positions + const fallbackStart = Math.max(0, maxIndex - fallbackRange); + return this.generateRandomInsertionPointsInRange( + fallbackStart, + maxIndex, + feeTransactionCount + ); + } + + const availableRange = maxIndex - minimumIndex; + + if (availableRange < feeTransactionCount) { + // Not enough space to spread out, place sequentially with small random offsets + return this.generateSequentialWithRandomOffset( + minimumIndex, + maxIndex, + feeTransactionCount + ); + } + + // Calculate optimal spread based on available range + const spreadRange = Math.floor(availableRange * spreadFactor); + const insertionPoints = []; + + // Generate random points with good distribution + for (let i = 0; i < feeTransactionCount; i++) { + const basePosition = + minimumIndex + Math.floor((i * availableRange) / feeTransactionCount); + const maxRandomOffset = Math.min(spreadRange, availableRange); + const randomOffset = + maxRandomOffset > 0 ? crypto.randomInt(0, maxRandomOffset) : 0; + const insertionPoint = Math.min( + basePosition + randomOffset, + maxIndex - 1 + ); + + insertionPoints.push(insertionPoint); + } + + // Sort and ensure uniqueness + const uniquePoints = [...new Set(insertionPoints)].sort((a, b) => a - b); + + // If we lost some points due to duplicates, fill in randomly + while ( + uniquePoints.length < feeTransactionCount && + uniquePoints[uniquePoints.length - 1] < maxIndex - 1 + ) { + const randomPoint = crypto.randomInt(minimumIndex, maxIndex); + if (!uniquePoints.includes(randomPoint)) { + uniquePoints.push(randomPoint); + uniquePoints.sort((a, b) => a - b); + } + } + + return uniquePoints.slice(0, feeTransactionCount); + } + + /** + * Generate random insertion points within a specific range + * @param {number} startIndex - Start of range + * @param {number} endIndex - End of range + * @param {number} count - Number of points to generate + * @returns {Array} - Random insertion points + */ + generateRandomInsertionPointsInRange(startIndex, endIndex, count) { + const points = []; + const range = endIndex - startIndex; + + for (let i = 0; i < count && points.length < range; i++) { + let randomPoint; + let attempts = 0; + + do { + randomPoint = startIndex + crypto.randomInt(0, range); + attempts++; + } while (points.includes(randomPoint) && attempts < 10); + + if (!points.includes(randomPoint)) { + points.push(randomPoint); + } + } + + return points.sort((a, b) => a - b); + } + + /** + * Generate sequential points with small random offsets + * @param {number} minimumIndex - Minimum starting index + * @param {number} maxIndex - Maximum index + * @param {number} count - Number of points needed + * @returns {Array} - Sequential points with random offsets + */ + generateSequentialWithRandomOffset(minimumIndex, maxIndex, count) { + const points = []; + const spacing = Math.max(1, Math.floor((maxIndex - minimumIndex) / count)); + + for (let i = 0; i < count; i++) { + const basePosition = minimumIndex + i * spacing; + const maxOffset = Math.min(spacing - 1, 2); // Small random offset + const randomOffset = + maxOffset > 0 ? crypto.randomInt(0, maxOffset + 1) : 0; + const insertionPoint = Math.min( + basePosition + randomOffset, + maxIndex - 1 + ); + + points.push(insertionPoint); + } + + return points; + } +} + +module.exports = FeeCalculator; diff --git a/src/services/fee/FeeInsertionStrategy.js b/src/services/fee/FeeInsertionStrategy.js new file mode 100644 index 0000000..4b34f39 --- /dev/null +++ b/src/services/fee/FeeInsertionStrategy.js @@ -0,0 +1,315 @@ +/** + * Fee Insertion Strategy + * Handles strategy calculation and execution for fee transaction insertion + */ + +const FeeCalculator = require('./FeeCalculator'); +const InsertionStrategyParams = require('../../valueObjects/InsertionStrategyParams'); + +class FeeInsertionStrategy { + constructor() { + this.calculator = new FeeCalculator(); + } + + /** + * Calculate insertion strategy using InsertionStrategyParams (recommended approach) + * @param {InsertionStrategyParams} params - Insertion strategy parameters + * @returns {Object} - Complete insertion strategy + */ + calculateInsertionStrategyWithParams(params) { + if (!(params instanceof InsertionStrategyParams)) { + throw new Error('Expected InsertionStrategyParams instance'); + } + + const [ + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options, + ] = params.toMethodParameters(); + + return this.calculateInsertionStrategy( + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options + ); + } + + /** + * Calculate insertion strategy with comprehensive analysis + * @param {Array} batches - Token batches + * @param {number} totalFeeETH - Total fee in ETH + * @param {number} totalTransactionCount - Total number of transactions + * @param {number} feeTransactionCount - Number of fee transactions + * @param {Object} options - Strategy options + * @returns {Object} - Complete insertion strategy + */ + calculateInsertionStrategy( + batches, + totalFeeETH, + totalTransactionCount, + feeTransactionCount, + options = {} + ) { + const minimumThreshold = this.calculator.calculateMinimumThreshold( + batches, + totalFeeETH, + options + ); + const insertionPoints = this.calculator.generateRandomInsertionPoints( + minimumThreshold, + totalTransactionCount, + feeTransactionCount, + options + ); + + return { + minimumThreshold, + insertionPoints, + strategy: + minimumThreshold >= totalTransactionCount ? 'fallback' : 'random', + metadata: { + totalTokens: batches.reduce((sum, batch) => sum + batch.length, 0), + totalTransactions: totalTransactionCount, + feeTransactionCount, + availableRange: Math.max(0, totalTransactionCount - minimumThreshold), + }, + }; + } + + /** + * Validate insertion strategy for safety + * @param {Object} strategy - Insertion strategy object + * @param {number} totalTransactionCount - Total transaction count + * @returns {boolean} - Whether strategy is valid + */ + validateInsertionStrategy(strategy, totalTransactionCount) { + const { insertionPoints, minimumThreshold } = strategy; + + // Check all insertion points are within bounds + const validIndices = insertionPoints.every( + point => point >= 0 && point < totalTransactionCount + ); + + // Check insertion points are after minimum threshold (with some tolerance for fallback) + const afterMinimum = insertionPoints.every( + point => point >= minimumThreshold || strategy.strategy === 'fallback' + ); + + // Check for reasonable distribution + const sortedPoints = [...insertionPoints].sort((a, b) => a - b); + const isSequential = sortedPoints.every( + (point, index) => + index === 0 || + point === sortedPoints[index - 1] || + point > sortedPoints[index - 1] + ); + + return validIndices && afterMinimum && isSequential; + } + + /** + * Determine if the fee block should be inserted at the current position + * @param {number} currentTransactionCount - Current number of transactions + * @param {Object} insertionStrategy - Fee insertion strategy + * @param {number} processedTokenCount - Number of tokens processed so far + * @param {number} totalTokenCount - Total number of tokens to process + * @returns {boolean} - Whether to insert the fee block now + */ + shouldInsertFeeBlock( + currentTransactionCount, + insertionStrategy, + processedTokenCount, + totalTokenCount + ) { + // Extract insertion strategy details + const { + minimumThreshold, + insertionPoints = [], + strategy, + } = insertionStrategy; + + // For fallback strategy, wait until near the end + if (strategy === 'fallback') { + const progressPercentage = processedTokenCount / totalTokenCount; + return progressPercentage >= 0.8; // Insert when 80% of tokens are processed + } + + // For random strategy, use the first insertion point as the target + // (since we're inserting the entire fee block at once) + if (insertionPoints.length > 0) { + const targetInsertionPoint = insertionPoints[0]; + + // Insert when we've reached or passed the target insertion point + return currentTransactionCount >= targetInsertionPoint; + } + + // Fallback: use minimum threshold + return currentTransactionCount >= minimumThreshold; + } + + /** + * Execute fee block insertion based on insertion strategy + * @param {Object} params - Fee insertion execution parameters + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Object} params.insertionStrategy - Fee insertion strategy + * @param {Array} params.transactions - Current transaction array to insert into + * @param {number} params.currentTransactionCount - Current transaction count + * @param {number} params.processedTokenCount - Tokens processed so far + * @param {number} params.totalTokenCount - Total tokens to process + * @returns {Object} - Execution result with insertion status + */ + executeFeeBlockInsertion(params) { + const { + feeTransactions, + insertionStrategy, + transactions, + currentTransactionCount, + processedTokenCount, + totalTokenCount, + } = params; + + if ( + !feeTransactions || + !insertionStrategy || + feeTransactions.length === 0 + ) { + return { + inserted: false, + reason: 'No fee transactions or strategy provided', + }; + } + + const shouldInsert = this.shouldInsertFeeBlock( + currentTransactionCount, + insertionStrategy, + processedTokenCount, + totalTokenCount + ); + + if (shouldInsert) { + // Insert all fee transactions as a cohesive block (deposit + transfer(s)) + transactions.push(...feeTransactions); + + return { + inserted: true, + position: currentTransactionCount, + feeTransactionCount: feeTransactions.length, + reason: `Inserted fee block at position ${currentTransactionCount} (${insertionStrategy.strategy} strategy)`, + }; + } + + return { + inserted: false, + reason: `Conditions not met for fee insertion (current: ${currentTransactionCount}, strategy: ${insertionStrategy.strategy})`, + }; + } + + /** + * Execute fallback fee insertion at the end of transactions + * @param {Object} params - Fallback insertion parameters + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Array} params.transactions - Transaction array to insert into + * @returns {Object} - Insertion result + */ + executeFallbackFeeInsertion(params) { + const { feeTransactions, transactions } = params; + + if (!feeTransactions || feeTransactions.length === 0) { + return { + inserted: false, + reason: 'No fee transactions to insert', + }; + } + + const insertionPosition = transactions.length; + transactions.push(...feeTransactions); + + return { + inserted: true, + position: insertionPosition, + feeTransactionCount: feeTransactions.length, + reason: `Fallback insertion at end (position ${insertionPosition})`, + }; + } + + /** + * Process fee insertion logic with comprehensive state management + * @param {Object} params - Fee insertion state parameters + * @param {boolean} params.shouldInsertFees - Whether fee insertion is enabled + * @param {Array} params.insertionPoints - Current insertion points + * @param {number} params.currentTransactionIndex - Current transaction index + * @param {number} params.feesInserted - Number of fees inserted so far + * @param {Array} params.feeTransactions - Fee transactions to insert + * @param {Object} params.results - Results object with transactions array + * @returns {Object} - Updated insertion state + */ + processFeeInsertion(params) { + const { + shouldInsertFees, + insertionPoints, + currentTransactionIndex, + feesInserted, + feeTransactions, + results, + } = params; + + let updatedInsertionPoints = insertionPoints; + let updatedFeesInserted = feesInserted; + let updatedTransactionIndex = currentTransactionIndex; + + // Check if we should insert fee transactions before processing this token + if ( + shouldInsertFees && + updatedInsertionPoints.length > 0 && + updatedInsertionPoints[0] <= currentTransactionIndex + ) { + // Insert fee transactions at this point + const feesToInsert = Math.min( + feeTransactions.length - updatedFeesInserted, + updatedInsertionPoints.length + ); + + for (let j = 0; j < feesToInsert; j++) { + if (updatedFeesInserted < feeTransactions.length) { + results.transactions.push(feeTransactions[updatedFeesInserted]); + updatedFeesInserted++; + updatedTransactionIndex++; + } + } + + // Remove used insertion points + updatedInsertionPoints = updatedInsertionPoints.slice(feesToInsert); + } + + return { + insertionPoints: updatedInsertionPoints, + feesInserted: updatedFeesInserted, + currentTransactionIndex: updatedTransactionIndex, + }; + } + + /** + * Insert any remaining fee transactions as fallback + * @param {Object} params - Remaining fee insertion parameters + * @param {boolean} params.shouldInsertFees - Whether fee insertion is enabled + * @param {number} params.feesInserted - Number of fees inserted so far + * @param {Array} params.feeTransactions - Fee transactions array + * @param {Object} params.results - Results object with transactions array + */ + insertRemainingFees(params) { + const { shouldInsertFees, feesInserted, feeTransactions, results } = params; + + if (shouldInsertFees && feesInserted < feeTransactions.length) { + const remainingFees = feeTransactions.slice(feesInserted); + results.transactions.push(...remainingFees); + + // Inserted remaining fee transactions as fallback + } + } +} + +module.exports = FeeInsertionStrategy; diff --git a/src/services/fee/index.js b/src/services/fee/index.js new file mode 100644 index 0000000..81d0149 --- /dev/null +++ b/src/services/fee/index.js @@ -0,0 +1,12 @@ +/** + * Fee Services Index + * Barrel export for fee-related modules + */ + +const FeeCalculator = require('./FeeCalculator'); +const FeeInsertionStrategy = require('./FeeInsertionStrategy'); + +module.exports = { + FeeCalculator, + FeeInsertionStrategy, +}; diff --git a/src/services/orchestrators/DustZapSSEOrchestrator.js b/src/services/orchestrators/DustZapSSEOrchestrator.js new file mode 100644 index 0000000..dc4fe6e --- /dev/null +++ b/src/services/orchestrators/DustZapSSEOrchestrator.js @@ -0,0 +1,169 @@ +/** + * DustZapSSEOrchestrator - Orchestrates SSE streaming for DustZap intents + * Separates infrastructure concerns from business logic + */ + +const SSEEventFactory = require('../SSEEventFactory'); +const SwapProcessingService = require('../SwapProcessingService'); +const { createLogger } = require('../../utils/logger'); + +const logger = createLogger('DustZapSSEOrchestrator'); + +class DustZapSSEOrchestrator { + constructor(dustZapHandler) { + this.dustZapHandler = dustZapHandler; + } + + /** + * Handle complete DustZap SSE streaming workflow + * @param {Object} executionContext - Execution context + * @param {Function} streamWriter - SSE stream writer + * @returns {Promise} - Final processing results + */ + async orchestrateSSEStreaming(executionContext, streamWriter) { + try { + // 1. Send initial connection confirmation (already handled by SSEStreamManager) + + // 2. Process tokens through business logic with SSE events + const processingResults = await this.processTokensWithStreaming( + executionContext, + streamWriter + ); + + // 3. Send completion event + const completionEvent = this.createCompletionEvent( + processingResults, + executionContext + ); + streamWriter(completionEvent); + + return processingResults; + } catch (error) { + logger.error('SSE orchestration error', { error }); + + const errorEvent = this.createErrorEvent(error, { + processedTokens: 0, + totalTokens: executionContext.dustTokens?.length || 0, + }); + streamWriter(errorEvent); + throw error; + } + } + + /** + * Process tokens with streaming events (pure orchestration) + * @param {Object} executionContext - Execution context + * @param {Function} streamWriter - SSE stream writer + * @returns {Promise} - Processing results + */ + async processTokensWithStreaming(executionContext, streamWriter) { + // Create processing context for swap service (same as business logic) + const processingContext = + SwapProcessingService.createProcessingContext(executionContext); + + // Calculate fee transactions and insertion strategy (same as business logic) + const { dustTokens, params } = executionContext; + const { referralAddress } = params; + + // Calculate estimated total value for fee calculations + let estimatedTotalValueUSD = 0; + for (const token of dustTokens) { + estimatedTotalValueUSD += token.amount * token.price || 0; + } + + // Pre-calculate fee transactions using estimated value + const { txBuilder: feeTxBuilder, feeAmounts } = + this.dustZapHandler.executor.feeCalculationService.createFeeTransactions( + estimatedTotalValueUSD, + executionContext.ethPrice, + executionContext.chainId, + referralAddress + ); + + const feeTransactions = feeTxBuilder.getTransactions(); + + // Calculate insertion strategy + const batches = executionContext.batches || [dustTokens]; + const totalExpectedTransactions = dustTokens.length * 2; + const insertionStrategy = + this.dustZapHandler.executor.smartFeeInsertionService.calculateInsertionStrategy( + batches, + feeAmounts.totalFeeETH, + totalExpectedTransactions, + feeTransactions.length + ); + + // Use processTokenBatchWithSSE for real-time streaming + const batchResults = + await this.dustZapHandler.executor.swapProcessingService.processTokenBatchWithSSE( + { + tokens: dustTokens, + context: processingContext, + streamWriter: streamWriter, // This enables real-time streaming + feeTransactions: feeTransactions, + insertionStrategy: insertionStrategy, + } + ); + + // Calculate actual total value from successful swaps + let actualTotalValueUSD = 0; + for (const result of batchResults.successful) { + actualTotalValueUSD += result.inputValueUSD || 0; + } + + const feeInfo = + this.dustZapHandler.executor.feeCalculationService.buildFeeInfo( + actualTotalValueUSD, + referralAddress, + true // useWETHPattern + ); + + return { + allTransactions: [...batchResults.transactions], + totalValueUSD: actualTotalValueUSD, + processedTokens: + batchResults.successful.length + batchResults.failed.length, + successfulTokens: batchResults.successful.length, + failedTokens: batchResults.failed.length, + successful: batchResults.successful, + failed: batchResults.failed, + feeInsertionStrategy: insertionStrategy, + feeInfo, + }; + } + + /** + * Create completion event for SSE stream + * @param {Object} processingResults - Processing results + * @param {Object} executionContext - Execution context + * @returns {Object} - SSE completion event + */ + createCompletionEvent(processingResults, executionContext) { + return SSEEventFactory.createCompletionEvent({ + transactions: processingResults.allTransactions || [], + metadata: { + totalTokens: executionContext.dustTokens?.length || 0, + processedTokens: + (processingResults.successful?.length || 0) + + (processingResults.failed?.length || 0), + successfulTokens: processingResults.successful?.length || 0, + failedTokens: processingResults.failed?.length || 0, + totalValueUSD: processingResults.totalValueUSD || 0, + feeInfo: processingResults.feeInfo || null, + feeInsertionStrategy: processingResults.feeInsertionStrategy || null, + }, + }); + } + + /** + * Create error event for SSE stream + * @param {Error} error - Error that occurred + * @param {Object} context - Additional context + * @returns {Object} - SSE error event + */ + createErrorEvent(error, context) { + return SSEEventFactory.createErrorEvent(error, context); + } +} + +module.exports = DustZapSSEOrchestrator; diff --git a/src/services/orchestrators/index.js b/src/services/orchestrators/index.js new file mode 100644 index 0000000..d8c28c1 --- /dev/null +++ b/src/services/orchestrators/index.js @@ -0,0 +1,10 @@ +/** + * Orchestrators Index + * Barrel export for all orchestrator modules + */ + +const DustZapSSEOrchestrator = require('./DustZapSSEOrchestrator'); + +module.exports = { + DustZapSSEOrchestrator, +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..d43672f --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,62 @@ +/** + * Lightweight logging utility + * Provides structured logging with log levels + */ + +const LOG_LEVELS = { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3, +}; + +class Logger { + constructor(context = '') { + this.context = context; + this.level = + LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase()] ?? LOG_LEVELS.INFO; + } + + _formatMessage(level, message, meta = {}) { + const timestamp = new Date().toISOString(); + const contextStr = this.context ? `[${this.context}]` : ''; + const metaStr = Object.keys(meta).length > 0 ? JSON.stringify(meta) : ''; + + return `${timestamp} ${level} ${contextStr} ${message} ${metaStr}`.trim(); + } + + error(message, meta = {}) { + if (this.level >= LOG_LEVELS.ERROR) { + console.error(this._formatMessage('ERROR', message, meta)); + } + } + + warn(message, meta = {}) { + if (this.level >= LOG_LEVELS.WARN) { + console.warn(this._formatMessage('WARN', message, meta)); + } + } + + info(message, meta = {}) { + if (this.level >= LOG_LEVELS.INFO) { + console.info(this._formatMessage('INFO', message, meta)); + } + } + + debug(message, meta = {}) { + if (this.level >= LOG_LEVELS.DEBUG) { + console.log(this._formatMessage('DEBUG', message, meta)); + } + } +} + +/** + * Create a logger instance with optional context + * @param {string} context - Logger context (e.g., 'UnifiedZapExecutor', 'SwapService') + * @returns {Logger} - Logger instance + */ +function createLogger(context = '') { + return new Logger(context); +} + +module.exports = { createLogger, Logger }; diff --git a/test/SSEStreamManager.test.js b/test/SSEStreamManager.test.js index 5d9bf07..5164bf4 100644 --- a/test/SSEStreamManager.test.js +++ b/test/SSEStreamManager.test.js @@ -1,7 +1,5 @@ -const { - SSEStreamManager, - DustZapSSEOrchestrator, -} = require('../src/services/SSEStreamManager'); +const SSEStreamManager = require('../src/services/SSEStreamManager'); +const DustZapSSEOrchestrator = require('../src/services/orchestrators/DustZapSSEOrchestrator'); const SSEEventFactory = require('../src/services/SSEEventFactory'); const SwapProcessingService = require('../src/services/SwapProcessingService'); diff --git a/test/unifiedZapExecutor.test.js b/test/unifiedZapExecutor.test.js new file mode 100644 index 0000000..cfb4abd --- /dev/null +++ b/test/unifiedZapExecutor.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const UnifiedZapExecutor = require('../src/executors/UnifiedZapExecutor'); + +describe('UnifiedZapExecutor swap guards', () => { + let swapService; + let executor; + + beforeEach(() => { + swapService = { + getSecondBestSwapQuote: jest.fn(), + }; + + executor = new UnifiedZapExecutor( + swapService, + { getPrices: jest.fn() }, + {} + ); + }); + + test('skips swap when from and to token addresses are identical', async () => { + const amount = 123456n; + + const result = await executor._executeSwap({ + chainId: 8453, + userAddress: '0x0000000000000000000000000000000000000001', + fromTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + fromTokenDecimals: 6, + toTokenAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + toTokenDecimals: 6, + amount, + slippage: 0.5, + tokenPrices: {}, + toTokenSymbol: 'USDC', + }); + + expect(result.transactions).toEqual([]); + expect(result.depositAmount).toBe(amount); + expect(result.depositTokenAddress).toBe( + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + ); + expect(result.swapQuote).toBeNull(); + expect(swapService.getSecondBestSwapQuote).not.toHaveBeenCalled(); + }); + + test('skips swap leg for LP token matching the input token', async () => { + const lpAmount = 302474127n; + const expectedFirstLeg = lpAmount / 2n; + const swapQuote = { + approve_to: '0x0000000000000000000000000000000000000002', + to: '0x0000000000000000000000000000000000000003', + value: '0', + data: '0x1234', + gas: 210000, + minToAmount: '151237064', + }; + swapService.getSecondBestSwapQuote.mockResolvedValue(swapQuote); + + const result = await executor._prepareLPTokenSwaps({ + protocolAllocation: { + id: 'velodrome-bold-usdc-base', + chainId: 8453, + }, + tokenRequirements: { + protocolSpecific: { + token0: { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + symbol: 'USDC', + }, + token1: { + address: '0x1234567890abcdef1234567890abcdef12345678', + decimals: 18, + symbol: 'BOLD', + }, + }, + requiresSwap: true, + }, + userAddress: '0x0000000000000000000000000000000000000009', + inputToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + inputTokenDecimals: 6, + amount: lpAmount, + slippage: 0.5, + tokenPrices: {}, + }); + + expect(swapService.getSecondBestSwapQuote).toHaveBeenCalledTimes(1); + expect(result.swapTransactions).toHaveLength(2); + expect(result.depositParams.token0Amount).toBe(expectedFirstLeg); + expect(result.depositParams.token1Amount).toBe(151237064n); + }); +}); From 637476e8f26da9812c8f85d621fc5c9c575042ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sun, 12 Oct 2025 15:22:26 +0900 Subject: [PATCH 15/29] =?UTF-8?q?=E2=80=A2=20-=20Removed=20the=20unused=20?= =?UTF-8?q?token=20filtering/allocation/grouping=20helpers=20and=20their?= =?UTF-8?q?=20auxiliary=20type=20from=20src/utils/tokenUtils.ts:31-147,=20?= =?UTF-8?q?leaving=20only=20the=20utilities=20that=20ship=20in=20productio?= =?UTF-8?q?n.=20=20=20-=20Pruned=20the=20corresponding=20Vitest=20coverage?= =?UTF-8?q?=20and=20imports=20in=20tests/unit/utils/tokenUtils.test.ts:6-3?= =?UTF-8?q?5=20and=20dropped=20the=20now-obsolete=20suites=20so=20the=20te?= =?UTF-8?q?sts=20mirror=20the=20slimmer=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/executors/UnifiedZapExecutor.js | 85 +++++++++++++------ src/services/SSEStreamManager.js | 1 - src/services/balanceService.js | 2 +- src/utils/logger.js | 2 + src/validators/balanceValidator.js | 13 ++- test/SSEStreamManager.test.js | 6 +- .../integration/phasedZap.integration.test.js | 3 +- test/intent-controller-integration.test.js | 13 ++- test/services/balanceService.test.js | 12 +-- test/utils/balanceCache.test.js | 3 +- test/validators/balanceValidator.test.js | 20 ++--- 11 files changed, 108 insertions(+), 52 deletions(-) diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index b86ada1..18dd922 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -833,31 +833,58 @@ class UnifiedZapExecutor { token1.symbol ); - const swap0 = await this._executeSwap({ - chainId: protocolAllocation.chainId, - userAddress, - fromTokenAddress: inputToken, - fromTokenDecimals: inputTokenDecimals, - toTokenAddress: token0.address, - toTokenDecimals: token0.decimals, - amount: swapAmount0, - slippage, - tokenPrices, - toTokenSymbol: symbol0, - }); + // Normalize addresses for comparison + const normalizedInput = + this._normalizeSwapAddress(inputToken).toLowerCase(); + const normalizedToken0 = this._normalizeSwapAddress( + token0.address + ).toLowerCase(); + const normalizedToken1 = this._normalizeSwapAddress( + token1.address + ).toLowerCase(); + + // Execute swaps only for tokens that don't match input token + const swap0 = + normalizedInput === normalizedToken0 + ? { + transactions: [], + swapQuote: null, + depositAmount: swapAmount0, + depositTokenAddress: token0.address, + } + : await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token0.address, + toTokenDecimals: token0.decimals, + amount: swapAmount0, + slippage, + tokenPrices, + toTokenSymbol: symbol0, + }); - const swap1 = await this._executeSwap({ - chainId: protocolAllocation.chainId, - userAddress, - fromTokenAddress: inputToken, - fromTokenDecimals: inputTokenDecimals, - toTokenAddress: token1.address, - toTokenDecimals: token1.decimals, - amount: swapAmount1, - slippage, - tokenPrices, - toTokenSymbol: symbol1, - }); + const swap1 = + normalizedInput === normalizedToken1 + ? { + transactions: [], + swapQuote: null, + depositAmount: swapAmount1, + depositTokenAddress: token1.address, + } + : await this._executeSwap({ + chainId: protocolAllocation.chainId, + userAddress, + fromTokenAddress: inputToken, + fromTokenDecimals: inputTokenDecimals, + toTokenAddress: token1.address, + toTokenDecimals: token1.decimals, + amount: swapAmount1, + slippage, + tokenPrices, + toTokenSymbol: symbol1, + }); if (swap0.depositAmount <= 0n || swap1.depositAmount <= 0n) { throw new Error( @@ -893,6 +920,16 @@ class UnifiedZapExecutor { const normalizedFrom = this._normalizeSwapAddress(fromTokenAddress); const normalizedTo = this._normalizeSwapAddress(toTokenAddress); + // Skip swap if from and to tokens are identical (case-insensitive) + if (normalizedFrom.toLowerCase() === normalizedTo.toLowerCase()) { + return { + transactions: [], + swapQuote: null, + depositAmount: amount, + depositTokenAddress: toTokenAddress, + }; + } + const ethPrice = this._resolveTokenPrice('eth', tokenPrices) ?? this._resolveTokenPrice('weth', tokenPrices) ?? diff --git a/src/services/SSEStreamManager.js b/src/services/SSEStreamManager.js index a7e1f34..23cca99 100644 --- a/src/services/SSEStreamManager.js +++ b/src/services/SSEStreamManager.js @@ -4,7 +4,6 @@ */ const SSEEventFactory = require('./SSEEventFactory'); -const SwapProcessingService = require('./SwapProcessingService'); class SSEStreamManager { /** diff --git a/src/services/balanceService.js b/src/services/balanceService.js index 4e4e150..4b6f3e8 100644 --- a/src/services/balanceService.js +++ b/src/services/balanceService.js @@ -307,7 +307,7 @@ class BalanceService { })); return { - address, + address: address.toLowerCase(), chainId: String(chainId), balances, totalTokens: balances.length, diff --git a/src/utils/logger.js b/src/utils/logger.js index d43672f..fddf1eb 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -39,12 +39,14 @@ class Logger { info(message, meta = {}) { if (this.level >= LOG_LEVELS.INFO) { + // eslint-disable-next-line no-console console.info(this._formatMessage('INFO', message, meta)); } } debug(message, meta = {}) { if (this.level >= LOG_LEVELS.DEBUG) { + // eslint-disable-next-line no-console console.log(this._formatMessage('DEBUG', message, meta)); } } diff --git a/src/validators/balanceValidator.js b/src/validators/balanceValidator.js index 8349255..cf5b773 100644 --- a/src/validators/balanceValidator.js +++ b/src/validators/balanceValidator.js @@ -29,11 +29,20 @@ const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS).map(Number); * Custom validator: Check if value is a valid Ethereum address */ const isValidAddress = value => { - if (!value) { + if (!value || typeof value !== 'string') { return false; } + + try { + if (isAddress(value)) { + return true; + } + } catch { + // no-op, fall back to lowercase normalization + } + try { - return isAddress(value); + return isAddress(value.toLowerCase()); } catch { return false; } diff --git a/test/SSEStreamManager.test.js b/test/SSEStreamManager.test.js index 5164bf4..28a72f8 100644 --- a/test/SSEStreamManager.test.js +++ b/test/SSEStreamManager.test.js @@ -609,10 +609,8 @@ describe('DustZapSSEOrchestrator', () => { ) ).rejects.toThrow(error); - expect(consoleError).toHaveBeenCalledWith( - 'SSE orchestration error:', - error - ); + // Logger now uses structured format, so just verify console.error was called + expect(consoleError).toHaveBeenCalled(); expect(mockStreamWriter).toHaveBeenCalledWith({ type: 'error' }); consoleError.mockRestore(); diff --git a/test/integration/phasedZap.integration.test.js b/test/integration/phasedZap.integration.test.js index d4a28a6..b3bcb96 100644 --- a/test/integration/phasedZap.integration.test.js +++ b/test/integration/phasedZap.integration.test.js @@ -218,7 +218,8 @@ describe('Phased Zap Execution - Integration Tests', () => { expect(initResponse.body.success).toBe(true); expect(initResponse.body.phase).toBe(1); expect(initResponse.body.executionId).toBeDefined(); - expect(initResponse.body.transactions.length).toBeGreaterThan(0); + expect(initResponse.body.transactions.length).toBe(0); + expect(mockSwapService.getSecondBestSwapQuote).not.toHaveBeenCalled(); expect(initResponse.body.metadata.nextStep).toBeDefined(); const { executionId } = initResponse.body; diff --git a/test/intent-controller-integration.test.js b/test/intent-controller-integration.test.js index fb8308e..87be079 100644 --- a/test/intent-controller-integration.test.js +++ b/test/intent-controller-integration.test.js @@ -683,8 +683,17 @@ describe('IntentController Integration Tests', () => { const responses = await Promise.all(requests); - responses.forEach(response => { - expect([200, 503].includes(response.status)).toBe(true); + const expectations = [ + { endpoint: '/api/v1/intents', statuses: [200] }, + { endpoint: '/api/v1/intents/health', statuses: [200, 503] }, + { endpoint: '/api/v1/strategies', statuses: [200] }, + { endpoint: '/api/v1/intents', statuses: [200] }, + { endpoint: '/api/v1/intents/health', statuses: [200, 503] }, + ]; + + responses.forEach((response, index) => { + const { statuses } = expectations[index]; + expect(statuses.includes(response.status)).toBe(true); }); }); diff --git a/test/services/balanceService.test.js b/test/services/balanceService.test.js index 5ccb963..cfbcab6 100644 --- a/test/services/balanceService.test.js +++ b/test/services/balanceService.test.js @@ -56,7 +56,7 @@ describe('BalanceService', () => { }); describe('getBalances', () => { - const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; const mockResponse = [ { token_address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -188,7 +188,7 @@ describe('BalanceService', () => { }); describe('getNativeBalance', () => { - const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; const mockNativeResponse = { balance: '1500000000000000000', }; @@ -212,7 +212,7 @@ describe('BalanceService', () => { describe('Cache operations', () => { it('should clear address cache', () => { - const address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; const key = BalanceCache.generateKey(1, address); balanceService.cache.set(key, { test: 'data' }); @@ -277,7 +277,7 @@ describe('BalanceService', () => { .mockResolvedValueOnce({ data: [] }); const result = await balanceService.getBalances( - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', { chainId: 1, skipCache: true } ); @@ -293,7 +293,7 @@ describe('BalanceService', () => { await expect( balanceService.getBalances( - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', { chainId: 1, skipCache: true } ) ).rejects.toThrow(); @@ -306,7 +306,7 @@ describe('BalanceService', () => { await expect( balanceService.getBalances( - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', { chainId: 1, skipCache: true } ) ).rejects.toThrow('Invalid Moralis response format'); diff --git a/test/utils/balanceCache.test.js b/test/utils/balanceCache.test.js index 3880c07..a2c96ff 100644 --- a/test/utils/balanceCache.test.js +++ b/test/utils/balanceCache.test.js @@ -271,7 +271,8 @@ describe('BalanceCache', () => { cache.set('undefined', undefined); expect(cache.get('null')).toBeNull(); - expect(cache.get('undefined')).toBeUndefined(); + // Cache returns null for both undefined values and missing keys + expect(cache.get('undefined')).toBeNull(); }); it('should handle special characters in keys', () => { diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js index 683fd26..1666795 100644 --- a/test/validators/balanceValidator.test.js +++ b/test/validators/balanceValidator.test.js @@ -13,7 +13,7 @@ const { describe('Validation Helper Functions', () => { describe('isValidAddress', () => { it('should validate correct Ethereum addresses', () => { - expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb')).toBe( + expect(isValidAddress('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0')).toBe( true ); expect(isValidAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( @@ -39,7 +39,7 @@ describe('Validation Helper Functions', () => { describe('areValidAddresses', () => { it('should validate comma-separated addresses', () => { const valid = - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; expect(areValidAddresses(valid)).toBe(true); }); @@ -51,7 +51,7 @@ describe('Validation Helper Functions', () => { it('should reject lists with invalid addresses', () => { const invalid = - '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,invalid-address'; + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0,invalid-address'; expect(areValidAddresses(invalid)).toBe(false); }); @@ -66,7 +66,7 @@ describe('Validation Helper Functions', () => { it('should handle whitespace in addresses', () => { const withSpaces = - ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; + ' 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0 , 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 '; expect(areValidAddresses(withSpaces)).toBe(true); }); }); @@ -128,7 +128,7 @@ describe('Balance Validator Integration', () => { const req = { query: { chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', }, }; @@ -139,7 +139,7 @@ describe('Balance Validator Integration', () => { it('should require chainId', async () => { const req = { query: { - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', }, }; @@ -162,7 +162,7 @@ describe('Balance Validator Integration', () => { const req = { query: { chainId: '999', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', }, }; @@ -186,7 +186,7 @@ describe('Balance Validator Integration', () => { const req = { query: { chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', tokens: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', }, @@ -200,7 +200,7 @@ describe('Balance Validator Integration', () => { const req = { query: { chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', }, }; @@ -210,7 +210,7 @@ describe('Balance Validator Integration', () => { }); it('should validate all supported chains', async () => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; for (const chainId of SUPPORTED_CHAIN_IDS) { const req = { query: { chainId: chainId.toString(), wallet } }; From 5f9641e8c9b9b819845e1a5fed11b6ec319b6e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sun, 12 Oct 2025 18:52:35 +0900 Subject: [PATCH 16/29] refactor: reorg modules --- src/config/unifiedZapConfig.js | 132 ++++++++++++++-------------- src/executors/UnifiedZapExecutor.js | 1 + src/protocols/BaseProtocolV2.js | 16 +++- src/protocols/VelodromeProtocol.js | 103 +++++++++++++++++----- 4 files changed, 162 insertions(+), 90 deletions(-) diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index a731b38..f95fb23 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -13,42 +13,42 @@ const UNIFIED_ZAP_CONFIG = { targetAssets: ['USDC', 'USDT', 'DAI', 'EURC'], chains: ['arbitrum', 'base', 'optimism'], protocols: [ - // Aave lending on Base - { - id: 'aave-usdc-base', - name: 'Aave USDC (Base)', - implementation: 'AaveProtocol', - chain: 'base', - chainId: 8453, - weight: 20, - enabled: true, - config: { - mode: 'single', - symbolOfBestTokenToZapInOut: 'usdc', - zapInOutTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - assetAddress: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', - protocolAddress: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5', - assetDecimals: 6, - }, - }, - // Aave USDC on Arbitrum - { - id: 'aave-usdc-arbitrum', - name: 'Aave USDC (Arbitrum)', - implementation: 'AaveProtocol', - chain: 'arbitrum', - chainId: 42161, - weight: 100, - enabled: true, - config: { - mode: 'single', - symbolOfBestTokenToZapInOut: 'usdc', - zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', - assetAddress: '0x625e7708f30ca75bfd92586e17077590c60eb4cd', - protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', - assetDecimals: 6, - }, - }, + // // Aave lending on Base + // { + // id: 'aave-usdc-base', + // name: 'Aave USDC (Base)', + // implementation: 'AaveProtocol', + // chain: 'base', + // chainId: 8453, + // weight: 20, + // enabled: true, + // config: { + // mode: 'single', + // symbolOfBestTokenToZapInOut: 'usdc', + // zapInOutTokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + // assetAddress: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', + // protocolAddress: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5', + // assetDecimals: 6, + // }, + // }, + // // Aave USDC on Arbitrum + // { + // id: 'aave-usdc-arbitrum', + // name: 'Aave USDC (Arbitrum)', + // implementation: 'AaveProtocol', + // chain: 'arbitrum', + // chainId: 42161, + // weight: 100, + // enabled: true, + // config: { + // mode: 'single', + // symbolOfBestTokenToZapInOut: 'usdc', + // zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + // assetAddress: '0x625e7708f30ca75bfd92586e17077590c60eb4cd', + // protocolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + // assetDecimals: 6, + // }, + // }, // Pendle PT gUSDC on Arbitrum { id: 'pendle-pt-gusdc-arbitrum', @@ -62,7 +62,7 @@ const UNIFIED_ZAP_CONFIG = { mode: 'single', marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', - protocolAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', // Add this line - using marketAddress + protocolAddress: '0x888888888889758F76e7103c6CbF23ABbF58F946', // Add this line - using marketAddress ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', assetDecimals: 6, symbolOfBestTokenToZapOut: 'usdc', @@ -72,35 +72,35 @@ const UNIFIED_ZAP_CONFIG = { }, }, // Velodrome BOLD/USDC LP on Base - { - id: 'velodrome-bold-usdc-base', - name: 'Velodrome BOLD/USDC LP (Base)', - implementation: 'VelodromeProtocol', - chain: 'base', - chainId: 8453, - weight: 30, - enabled: true, - config: { - mode: 'LP', - protocolName: 'aerodrome', - protocolVersion: '0', - assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', - assetDecimals: 18, - routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', - guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', - lpTokens: [ - ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], - ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], - ], - rewards: [ - { - symbol: 'aero', - address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', - decimals: 18, - }, - ], - }, - }, + // { + // id: 'velodrome-bold-usdc-base', + // name: 'Velodrome BOLD/USDC LP (Base)', + // implementation: 'VelodromeProtocol', + // chain: 'base', + // chainId: 8453, + // weight: 30, + // enabled: true, + // config: { + // mode: 'LP', + // protocolName: 'aerodrome', + // protocolVersion: '0', + // assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', + // assetDecimals: 18, + // routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', + // guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', + // lpTokens: [ + // ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], + // ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], + // ], + // rewards: [ + // { + // symbol: 'aero', + // address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', + // decimals: 18, + // }, + // ], + // }, + // }, // // Velodrome USDC/sUSD LP on Optimism // { // id: 'velodrome-usdc-susd-optimism', diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index 18dd922..b6691ba 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -653,6 +653,7 @@ class UnifiedZapExecutor { depositAmount, { slippage } ); + console.log('depositTx', depositTx); transactions.push(depositTx); } else if (tokenRequirements.mode === 'LP') { const lpContext = tokenRequirements.protocolSpecific || {}; diff --git a/src/protocols/BaseProtocolV2.js b/src/protocols/BaseProtocolV2.js index 4f06eed..cc3ddca 100644 --- a/src/protocols/BaseProtocolV2.js +++ b/src/protocols/BaseProtocolV2.js @@ -243,9 +243,21 @@ class BaseProtocolV2 { * @protected */ _applySlippage(amount, slippage) { - const slippageBasisPoints = Math.floor(slippage * 100); + const amountBigInt = + typeof amount === 'bigint' ? amount : BigInt(amount.toString()); + + const numericSlippage = + slippage === undefined || slippage === null ? 0 : Number(slippage); + + if (!Number.isFinite(numericSlippage)) { + throw new Error(`Invalid slippage value: ${slippage}`); + } + + const clampedSlippage = Math.min(Math.max(numericSlippage, 0), 99.99); + const slippageBasisPoints = Math.floor(clampedSlippage * 100); const multiplier = BigInt(10000 - slippageBasisPoints); - return (amount * multiplier) / 10000n; + + return (amountBigInt * multiplier) / 10000n; } /** diff --git a/src/protocols/VelodromeProtocol.js b/src/protocols/VelodromeProtocol.js index 502be28..5d6e324 100644 --- a/src/protocols/VelodromeProtocol.js +++ b/src/protocols/VelodromeProtocol.js @@ -6,6 +6,23 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); +const STABLE_TOKEN_SYMBOLS = new Set([ + 'usdc', + 'usdt', + 'dai', + 'susd', + 'msusd', + 'eurc', + 'usdx', + 'susdx', + 'gusdc', + 'usdbc', + 'usdce', + 'usdp', + 'usd+', + 'bold', +]); + class VelodromeProtocol extends BaseProtocolV2 { constructor(config, chain, chainId) { super(config, chain, chainId); @@ -33,6 +50,11 @@ class VelodromeProtocol extends BaseProtocolV2 { address: this.lpTokens[1][1], decimals: this.lpTokens[1][2], }; + + this.isStablePool = + typeof config.isStablePool === 'boolean' + ? config.isStablePool + : this._inferStablePoolFromSymbols(); } /** @@ -51,7 +73,7 @@ class VelodromeProtocol extends BaseProtocolV2 { ) { this._validateAddress(userAddress); - const { token0Amount, token1Amount, slippage = 0.5 } = additionalParams; + const { token0Amount, token1Amount, slippage } = additionalParams; if (!token0Amount || !token1Amount) { throw new Error( @@ -73,9 +95,11 @@ class VelodromeProtocol extends BaseProtocolV2 { throw new Error('Both token amounts must be greater than zero'); } - // Calculate minimum amounts with slippage - const minAmount0 = this._applySlippage(amount0, slippage); - const minAmount1 = this._applySlippage(amount1, slippage); + const effectiveSlippage = this._resolveDepositSlippage(slippage); + + // Calculate minimum amounts with slippage buffer + const minAmount0 = this._applySlippage(amount0, effectiveSlippage); + const minAmount1 = this._applySlippage(amount1, effectiveSlippage); const deadline = this._getDeadline(); // Check if tokens are sorted correctly for Velodrome @@ -95,11 +119,12 @@ class VelodromeProtocol extends BaseProtocolV2 { minAmount1 ); - // Generate add liquidity transaction + const isStablePool = this._isStablePool(); + const addLiquidityData = this._encodeAddLiquidityCall( sortedToken0, sortedToken1, - this._isStablePool(), // Determine if stable or volatile pool + isStablePool, sortedAmount0, sortedAmount1, sortedMin0, @@ -265,27 +290,61 @@ class VelodromeProtocol extends BaseProtocolV2 { } /** - * Determine if this is a stable pool based on protocol name - * @returns {boolean} - Whether this is a stable pool + * Determine effective slippage buffer for LP deposits. + * Applies a more conservative floor for stable pools to mirror v1 behavior. + * @param {number} slippage - Requested slippage percentage (e.g. 0.5 for 0.5%) + * @returns {number} - Slippage percentage to apply + * @private + */ + _resolveDepositSlippage(slippage) { + const numericSlippage = + slippage === undefined || slippage === null + ? undefined + : Number(slippage); + + const baseSlippage = Number.isFinite(numericSlippage) + ? numericSlippage + : 0.5; + + const sanitizedSlippage = Math.max(baseSlippage, 0); + + if (this._isStablePool()) { + const configuredMinimum = + typeof this.config.minStableDepositSlippage === 'number' + ? this.config.minStableDepositSlippage + : 20; + + return Math.max(sanitizedSlippage, configuredMinimum); + } + + return sanitizedSlippage; + } + + /** + * Determine if this configuration targets a stable pool. + * @returns {boolean} * @private */ _isStablePool() { - // Usually stable pools are marked in configuration or can be inferred from tokens - // For now, assume correlated assets (stablecoins) use stable pools - const stableTokens = [ - 'usdc', - 'usdt', - 'dai', - 'susd', - 'eurc', - 'usdx', - 'susdx', - ]; - const token0Symbol = this.token0.symbol.toLowerCase(); - const token1Symbol = this.token1.symbol.toLowerCase(); + return !!this.isStablePool; + } + + /** + * Infer whether the pool should be treated as stable using token symbols. + * @returns {boolean} + * @private + */ + _inferStablePoolFromSymbols() { + const token0Symbol = (this.token0.symbol || '').toLowerCase(); + const token1Symbol = (this.token1.symbol || '').toLowerCase(); + + if (!token0Symbol || !token1Symbol) { + return false; + } return ( - stableTokens.includes(token0Symbol) && stableTokens.includes(token1Symbol) + STABLE_TOKEN_SYMBOLS.has(token0Symbol) && + STABLE_TOKEN_SYMBOLS.has(token1Symbol) ); } From c2f7c821264584d5a66c088de99b4157f371813d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sun, 12 Oct 2025 20:01:08 +0900 Subject: [PATCH 17/29] fix: pendle works now --- src/executors/UnifiedZapExecutor.js | 1 - src/protocols/PendlePTProtocol.js | 546 +++++++++++++++++++++------- src/services/SSEEventFactory.js | 9 +- 3 files changed, 432 insertions(+), 124 deletions(-) diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index b6691ba..18dd922 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -653,7 +653,6 @@ class UnifiedZapExecutor { depositAmount, { slippage } ); - console.log('depositTx', depositTx); transactions.push(depositTx); } else if (tokenRequirements.mode === 'LP') { const lpContext = tokenRequirements.protocolSpecific || {}; diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 5cea89e..4ae648e 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -5,6 +5,54 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); +const axios = require('axios'); +const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); + +const PENDLE_CORE_API_BASE = 'https://api-v2.pendle.finance/core/v1'; +const PENDLE_SDK_API_BASE = `${PENDLE_CORE_API_BASE}/sdk`; +const DEFAULT_SWAP_EXTRA_GAS = 600000n; +const DEFAULT_REDEEM_EXTRA_GAS = 750000n; +const PENDLE_MARKET_ABI = ['function isExpired() view returns (bool)']; + +const PROVIDER_CACHE = new Map(); + +function resolveRpcUrl(chain, chainId) { + if (chain && UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS?.[chain]?.rpcUrl) { + return UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS[chain].rpcUrl; + } + + if (chainId) { + const match = Object.values(UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {}).find( + config => config.chainId === chainId + ); + if (match?.rpcUrl) { + return match.rpcUrl; + } + } + + return null; +} + +function getRpcProvider(chain, chainId) { + const cacheKey = chainId || chain; + if (cacheKey && PROVIDER_CACHE.has(cacheKey)) { + return PROVIDER_CACHE.get(cacheKey); + } + + const rpcUrl = resolveRpcUrl(chain, chainId); + + if (!rpcUrl) { + throw new Error( + `No RPC URL configured for Pendle chain ${chain ?? 'unknown'} (${chainId ?? 'n/a'})` + ); + } + + const provider = new ethers.JsonRpcProvider(rpcUrl); + if (cacheKey) { + PROVIDER_CACHE.set(cacheKey, provider); + } + return provider; +} class PendlePTProtocol extends BaseProtocolV2 { constructor(config, chain, chainId) { @@ -19,6 +67,10 @@ class PendlePTProtocol extends BaseProtocolV2 { this.ytTokenAddress = config.ytAddress; this.underlyingTokenAddress = config.bestTokenAddressToZapOut; this.tokenDecimals = config.assetDecimals; + this.assetDecimals = config.assetDecimals; + this.bestTokenAddress = config.bestTokenAddressToZapOut; + this.bestTokenSymbol = config.symbolOfBestTokenToZapOut; + this.bestTokenDecimals = config.decimalOfBestTokenToZapOut; // Pendle router addresses (chain-specific) this.routerAddresses = this._getPendleRouterAddresses(); @@ -32,7 +84,7 @@ class PendlePTProtocol extends BaseProtocolV2 { * @param {Object} additionalParams - Slippage and other params * @returns {Promise} - Deposit transaction object */ - getDepositTransaction( + async getDepositTransaction( userAddress, inputToken, amount, @@ -48,28 +100,56 @@ class PendlePTProtocol extends BaseProtocolV2 { throw new Error('Deposit amount cannot be zero'); } - const slippage = additionalParams.slippage || 0.5; - const deadline = this._getDeadline(); - - // For Pendle, we need to mint PT+YT from underlying asset - // This typically involves swapping to underlying asset first (if needed) then minting - if ( - inputToken.toLowerCase() === this.underlyingTokenAddress.toLowerCase() + inputToken.toLowerCase() !== this.underlyingTokenAddress.toLowerCase() ) { - // Direct minting from underlying asset - return this._getMintPTTransaction( - userAddress, - depositAmount, - slippage, - deadline + throw new Error( + `Input token ${inputToken} must match underlying token ${this.underlyingTokenAddress} for Pendle deposit` ); - } else { - // Need to swap first, then mint - this would typically be handled at executor level + } + + const isExpired = await this._isMarketExpired(); + if (isExpired) { throw new Error( - `Input token ${inputToken} requires swap to ${this.underlyingTokenAddress} before Pendle minting` + `Pendle market ${this.marketAddress} is expired and cannot accept deposits` ); } + + const normalizedSlippage = this._normalizePendleSlippage( + additionalParams.slippage + ); + + const swapQuote = await this._fetchPendleSwapQuote({ + receiver: userAddress, + tokenIn: this.underlyingTokenAddress, + tokenOut: this.ptTokenAddress, + amountIn: depositAmount, + slippage: normalizedSlippage, + }); + + const latestPendleAssetPrice = await this._fetchPendleAssetPrice(); + + const baseTx = this._buildTransactionFromPendleQuote(swapQuote, { + defaultExtraGas: DEFAULT_SWAP_EXTRA_GAS, + }); + + return { + ...baseTx, + description: `Swap ${this._formatAmount( + depositAmount, + this.bestTokenDecimals + )} ${this.bestTokenSymbol.toUpperCase()} into Pendle PT`, + meta: { + ...baseTx.meta, + quoteType: 'swapDeposit', + tokenIn: this.underlyingTokenAddress, + tokenOut: this.ptTokenAddress, + amountIn: depositAmount.toString(), + estimatedAmountOut: swapQuote?.data?.amountOut ?? null, + slippage: normalizedSlippage, + latestPendleAssetPrice, + }, + }; } /** @@ -137,6 +217,7 @@ class PendlePTProtocol extends BaseProtocolV2 { 'bestTokenAddressToZapOut', 'assetDecimals', 'symbolOfBestTokenToZapOut', + 'decimalOfBestTokenToZapOut', ]; const missing = required.filter(key => !this.config[key]); @@ -156,75 +237,263 @@ class PendlePTProtocol extends BaseProtocolV2 { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); + + if ( + !Number.isInteger(this.config.assetDecimals) || + this.config.assetDecimals < 0 + ) { + throw new Error(`Invalid assetDecimals: ${this.config.assetDecimals}`); + } + + if ( + !Number.isInteger(this.config.decimalOfBestTokenToZapOut) || + this.config.decimalOfBestTokenToZapOut < 0 + ) { + throw new Error( + `Invalid decimalOfBestTokenToZapOut: ${this.config.decimalOfBestTokenToZapOut}` + ); + } } - /** - * Get PT minting transaction - * @param {string} userAddress - User address - * @param {BigInt} amount - Amount to mint - * @param {number} slippage - Slippage tolerance - * @param {number} deadline - Transaction deadline - * @returns {Object} - Mint transaction - * @private - */ - _getMintPTTransaction(userAddress, amount, slippage, deadline) { - // Calculate minimum PT out with slippage - const minPTOut = this._applySlippage(amount, slippage); - - // Encode mint transaction for Pendle Router - const mintData = this._encodeMintPTCall( - userAddress, - this.marketAddress, - amount, - minPTOut, - deadline + _getRpcProviderInstance() { + if (!this._rpcProvider) { + this._rpcProvider = getRpcProvider(this.chain, this.chainId); + } + return this._rpcProvider; + } + + _getMarketContract() { + if (!this._marketContract) { + this._marketContract = new ethers.Contract( + this.marketAddress, + PENDLE_MARKET_ABI, + this._getRpcProviderInstance() + ); + } + return this._marketContract; + } + + async _isMarketExpired() { + try { + const contract = this._getMarketContract(); + return await contract.isExpired(); + } catch (error) { + throw new Error( + `Failed to check Pendle market expiry: ${this._extractPendleError(error)}` + ); + } + } + + _normalizePendleSlippage(slippage) { + const numeric = + slippage === undefined || slippage === null ? 0.5 : Number(slippage); + + if (!Number.isFinite(numeric)) { + throw new Error(`Invalid slippage value for Pendle: ${slippage}`); + } + + const bounded = Math.min(Math.max(numeric, 0), 100); + return bounded / 100; + } + + async _fetchPendleSwapQuote({ + receiver, + tokenIn, + tokenOut, + amountIn, + slippage, + }) { + try { + const response = await axios.get( + `${PENDLE_SDK_API_BASE}/${this.chainId}/markets/${this.marketAddress}/swap`, + { + params: { + receiver, + slippage, + enableAggregator: true, + tokenIn, + tokenOut, + amountIn: this._toApiAmount(amountIn), + }, + } + ); + + if (!response.data?.tx) { + throw new Error('Missing transaction data in Pendle swap response'); + } + + return response.data; + } catch (error) { + throw new Error( + `Pendle swap quote failed: ${this._extractPendleError(error)}` + ); + } + } + + async _fetchPendleRedeemQuote({ receiver, tokenOut, amountIn, slippage }) { + try { + const response = await axios.get( + `${PENDLE_SDK_API_BASE}/${this.chainId}/redeem`, + { + params: { + receiver, + slippage, + enableAggregator: true, + yt: this.ytTokenAddress, + tokenOut, + amountIn: this._toApiAmount(amountIn), + }, + } + ); + + if (!response.data?.tx) { + throw new Error('Missing transaction data in Pendle redeem response'); + } + + return response.data; + } catch (error) { + throw new Error( + `Pendle redeem quote failed: ${this._extractPendleError(error)}` + ); + } + } + + async _fetchPendleAssetPrice() { + try { + const response = await axios.get( + `${PENDLE_CORE_API_BASE}/${this.chainId}/assets/prices`, + { + params: { + addresses: this.ptTokenAddress, + skip: 0, + }, + } + ); + + const priceMap = response.data?.prices || {}; + const key = this.ptTokenAddress.toLowerCase(); + + if (priceMap[key] === undefined) { + throw new Error( + `Price for ${this.ptTokenAddress} not found in Pendle response` + ); + } + + const rawPrice = Number(priceMap[key]); + if (!Number.isFinite(rawPrice)) { + throw new Error( + `Invalid price value returned for ${this.ptTokenAddress}: ${priceMap[key]}` + ); + } + + return rawPrice / Math.pow(10, this.assetDecimals); + } catch (error) { + throw new Error( + `Pendle asset price fetch failed: ${this._extractPendleError(error)}` + ); + } + } + + _buildTransactionFromPendleQuote(quote, { defaultExtraGas }) { + const tx = quote?.tx; + if (!tx?.to || !tx?.data) { + throw new Error('Pendle quote missing target transaction data'); + } + + const gasLimit = this._computeGasLimit( + tx.gas ?? tx.gasLimit, + defaultExtraGas ); return { - to: this.routerAddresses.router, - data: mintData, - value: '0', - gasLimit: null, - description: `Mint PT tokens from ${this._formatAmount(amount, this.tokenDecimals)} ${this.config.symbolOfBestTokenToZapOut}`, + to: tx.to, + data: tx.data, + value: this._normalizeTxValue(tx.value), + gasLimit, + meta: { + raw: quote, + }, }; } - /** - * Encode Pendle PT minting call - * @param {string} receiver - Address to receive PT+YT - * @param {string} market - Market address - * @param {BigInt} netTokenIn - Amount of underlying token - * @param {BigInt} minPTOut - Minimum PT tokens to receive - * @param {number} deadline - Transaction deadline - * @returns {string} - Encoded function data - * @private - */ - _encodeMintPTCall(receiver, market, netTokenIn, minPTOut, _deadline) { - // Simplified Pendle Router interface for PT minting - const routerInterface = new ethers.Interface([ - 'function mintPyFromToken(address receiver, address market, uint256 minPyOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netPyOut, uint256 netSyFee)', - ]); - - // Simplified parameters - in practice this would need more complex swap data - const tokenInput = { - tokenIn: this.underlyingTokenAddress, - netTokenIn: netTokenIn, - tokenMintSy: this.underlyingTokenAddress, - pendleSwap: ethers.ZeroAddress, - swapData: { - swapType: 0, - extRouter: ethers.ZeroAddress, - extCalldata: '0x', - needScale: false, - }, - }; + _computeGasLimit(baseGas, extraGas = 0n) { + let gas = null; - return routerInterface.encodeFunctionData('mintPyFromToken', [ - receiver, - market, - minPTOut, - tokenInput, - ]); + if (baseGas !== undefined && baseGas !== null) { + try { + gas = this._toBigInt(baseGas); + } catch (error) { + console.warn('Unable to parse Pendle gas estimate:', error); + } + } + + if (extraGas && extraGas > 0n) { + gas = (gas ?? 0n) + extraGas; + } + + return gas; + } + + _toApiAmount(amount) { + if (typeof amount === 'bigint') { + return amount.toString(); + } + if (typeof amount === 'number') { + return BigInt(Math.floor(amount)).toString(); + } + if (typeof amount === 'string') { + return amount; + } + if (amount && typeof amount.toString === 'function') { + return amount.toString(); + } + throw new Error(`Unsupported amount type for Pendle API: ${amount}`); + } + + _toBigInt(value) { + if (typeof value === 'bigint') { + return value; + } + if (typeof value === 'number') { + return BigInt(Math.floor(value)); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('0x') || trimmed.startsWith('0X')) { + return BigInt(trimmed); + } + return BigInt(trimmed); + } + throw new Error(`Cannot convert value to BigInt: ${value}`); + } + + _normalizeTxValue(value) { + if (value === undefined || value === null) { + return '0'; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (typeof value === 'number') { + return Math.max(value, 0).toString(); + } + return value.toString(); + } + + _extractPendleError(error) { + if (error.response?.data?.message) { + return error.response.data.message; + } + if (error.response?.data?.error) { + return error.response.data.error; + } + if (error.response?.status) { + return `status ${error.response.status}`; + } + return error.message || 'unknown error'; } /** @@ -280,65 +549,98 @@ class PendlePTProtocol extends BaseProtocolV2 { }; } + getAssetPrice() { + return this._fetchPendleAssetPrice(); + } + /** - * Get redemption transaction (for matured PT) + * Get redemption transaction (swap or redeem depending on market state) * @param {string} userAddress - User wallet address - * @param {BigInt|string} amount - PT amount to redeem + * @param {BigInt|string} amount - PT amount to process + * @param {Object} additionalParams - Options (slippage, isExpired override) * @returns {Promise} - Redemption transaction */ - getRedemptionTransaction(userAddress, amount) { + async getRedemptionTransaction(userAddress, amount, additionalParams = {}) { this._validateAddress(userAddress); const redeemAmount = typeof amount === 'bigint' ? amount : BigInt(amount.toString()); - const redeemData = this._encodeRedeemPTCall( - userAddress, - this.marketAddress, - redeemAmount + if (redeemAmount === 0n) { + throw new Error('Redeem amount cannot be zero'); + } + + const normalizedSlippage = this._normalizePendleSlippage( + additionalParams.slippage ); + const receiver = additionalParams.receiver || userAddress; + const isExpired = + typeof additionalParams.isExpired === 'boolean' + ? additionalParams.isExpired + : await this._isMarketExpired(); + + if (isExpired) { + const redeemQuote = await this._fetchPendleRedeemQuote({ + receiver, + tokenOut: this.bestTokenAddress, + amountIn: redeemAmount, + slippage: normalizedSlippage, + }); + + const baseTx = this._buildTransactionFromPendleQuote(redeemQuote, { + defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, + }); - return { - to: this.routerAddresses.router, - data: redeemData, - value: '0', - gasLimit: null, - description: `Redeem ${this._formatAmount(redeemAmount, this.tokenDecimals)} PT tokens`, - }; - } + return { + ...baseTx, + description: `Redeem ${this._formatAmount( + redeemAmount, + this.tokenDecimals + )} PT into ${this.bestTokenSymbol.toUpperCase()}`, + meta: { + ...baseTx.meta, + quoteType: 'redeem', + tokenIn: this.ptTokenAddress, + tokenOut: this.bestTokenAddress, + amountIn: redeemAmount.toString(), + slippage: normalizedSlippage, + isExpired: true, + }, + }; + } - /** - * Encode PT redemption call - * @param {string} receiver - Address to receive underlying tokens - * @param {string} market - Market address - * @param {BigInt} netPyIn - Amount of PT to redeem - * @returns {string} - Encoded function data - * @private - */ - _encodeRedeemPTCall(receiver, market, netPyIn) { - const routerInterface = new ethers.Interface([ - 'function redeemPyToToken(address receiver, address market, uint256 netPyIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData)) returns (uint256 netTokenOut, uint256 netSyFee)', - ]); - - const tokenOutput = { - tokenOut: this.underlyingTokenAddress, - minTokenOut: 0n, // Would calculate based on slippage - tokenRedeemSy: this.underlyingTokenAddress, - pendleSwap: ethers.ZeroAddress, - swapData: { - swapType: 0, - extRouter: ethers.ZeroAddress, - extCalldata: '0x', - needScale: false, + const swapQuote = await this._fetchPendleSwapQuote({ + receiver, + tokenIn: this.ptTokenAddress, + tokenOut: this.bestTokenAddress, + amountIn: redeemAmount, + slippage: normalizedSlippage, + }); + + const latestPendleAssetPrice = await this._fetchPendleAssetPrice(); + + const baseTx = this._buildTransactionFromPendleQuote(swapQuote, { + defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, + }); + + return { + ...baseTx, + description: `Swap ${this._formatAmount( + redeemAmount, + this.tokenDecimals + )} PT into ${this.bestTokenSymbol.toUpperCase()}`, + meta: { + ...baseTx.meta, + quoteType: 'swapRedeem', + tokenIn: this.ptTokenAddress, + tokenOut: this.bestTokenAddress, + amountIn: redeemAmount.toString(), + estimatedAmountOut: swapQuote?.data?.amountOut ?? null, + slippage: normalizedSlippage, + latestPendleAssetPrice, + isExpired: false, }, }; - - return routerInterface.encodeFunctionData('redeemPyToToken', [ - receiver, - market, - netPyIn, - tokenOutput, - ]); } } diff --git a/src/services/SSEEventFactory.js b/src/services/SSEEventFactory.js index 4053328..0492cfc 100644 --- a/src/services/SSEEventFactory.js +++ b/src/services/SSEEventFactory.js @@ -331,7 +331,14 @@ class SSEEventFactory { throw new Error('Invalid event structure for SSE transmission'); } - return `data: ${JSON.stringify(event)}\n\n`; + const serialized = JSON.stringify(event, (_key, value) => { + if (typeof value === 'bigint') { + return value.toString(); + } + return value; + }); + + return `data: ${serialized}\n\n`; } /** From 33f43a64d78e9a484c321632672af994eb626ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Sun, 12 Oct 2025 20:57:44 +0900 Subject: [PATCH 18/29] fix: velodrome v2 works --- src/config/tokenMappings/coingecko.json | 1 + src/config/tokenMappings/coinmarketcap.json | 1 + src/config/unifiedZapConfig.js | 118 ++++++++++---------- 3 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/config/tokenMappings/coingecko.json b/src/config/tokenMappings/coingecko.json index 19714f3..88c3fea 100644 --- a/src/config/tokenMappings/coingecko.json +++ b/src/config/tokenMappings/coingecko.json @@ -9,6 +9,7 @@ "sol": "solana", "xrp": "ripple", "dot": "polkadot", + "bold": "liquity-bold-2", "doge": "dogecoin", "avax": "avalanche-2", "shib": "shiba-inu", diff --git a/src/config/tokenMappings/coinmarketcap.json b/src/config/tokenMappings/coinmarketcap.json index f5cdfb7..e7fd10c 100644 --- a/src/config/tokenMappings/coinmarketcap.json +++ b/src/config/tokenMappings/coinmarketcap.json @@ -59,6 +59,7 @@ "gbyte": "1492", "rep": "1104", "fct": "1087", + "bold": "38407", "game": "1027", "bts": "463", "steem": "1230", diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index f95fb23..c4f77e4 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -72,65 +72,65 @@ const UNIFIED_ZAP_CONFIG = { }, }, // Velodrome BOLD/USDC LP on Base - // { - // id: 'velodrome-bold-usdc-base', - // name: 'Velodrome BOLD/USDC LP (Base)', - // implementation: 'VelodromeProtocol', - // chain: 'base', - // chainId: 8453, - // weight: 30, - // enabled: true, - // config: { - // mode: 'LP', - // protocolName: 'aerodrome', - // protocolVersion: '0', - // assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', - // assetDecimals: 18, - // routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', - // guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', - // lpTokens: [ - // ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], - // ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], - // ], - // rewards: [ - // { - // symbol: 'aero', - // address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', - // decimals: 18, - // }, - // ], - // }, - // }, - // // Velodrome USDC/sUSD LP on Optimism - // { - // id: 'velodrome-usdc-susd-optimism', - // name: 'Velodrome USDC/sUSD LP (Optimism)', - // implementation: 'VelodromeProtocol', - // chain: 'optimism', - // chainId: 10, - // weight: 25, - // enabled: true, - // config: { - // mode: 'LP', - // protocolName: 'velodrome', - // protocolVersion: 'v2', - // assetAddress: '0xbC26519f936A90E78fe2C9aA2A03CC208f041234', - // assetDecimals: 18, - // routerAddress: '0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858', - // guageAddress: '0x0E4c56B4a766968b12c286f67aE341b11eDD8b8d', - // lpTokens: [ - // ['usdc', '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', 6], - // ['susd', '0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9', 18], - // ], - // rewards: [ - // { - // symbol: 'velo', - // address: '0x9560e827af36c94d2ac33a39bce1fe78631088db', - // decimals: 18, - // }, - // ], - // }, - // }, + { + id: 'velodrome-bold-usdc-base', + name: 'Velodrome BOLD/USDC LP (Base)', + implementation: 'VelodromeProtocol', + chain: 'base', + chainId: 8453, + weight: 30, + enabled: true, + config: { + mode: 'LP', + protocolName: 'aerodrome', + protocolVersion: '0', + assetAddress: '0x2De3fE21d32319a1550264dA37846737885Ad7A1', + assetDecimals: 18, + routerAddress: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43', + guageAddress: '0x7fDCBc8C442C667D41a1041bdc6e588393cEb6fe', + lpTokens: [ + ['bold', '0x03569CC076654F82679C4BA2124D64774781B01D', 18], + ['usdc', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6], + ], + rewards: [ + { + symbol: 'aero', + address: '0x940181a94a35a4569e4529a3cdfb74e38fd98631', + decimals: 18, + }, + ], + }, + }, + // Velodrome USDC/sUSD LP on Optimism + { + id: 'velodrome-usdc-susd-optimism', + name: 'Velodrome USDC/sUSD LP (Optimism)', + implementation: 'VelodromeProtocol', + chain: 'optimism', + chainId: 10, + weight: 25, + enabled: true, + config: { + mode: 'LP', + protocolName: 'velodrome', + protocolVersion: 'v2', + assetAddress: '0xbC26519f936A90E78fe2C9aA2A03CC208f041234', + assetDecimals: 18, + routerAddress: '0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858', + guageAddress: '0x0E4c56B4a766968b12c286f67aE341b11eDD8b8d', + lpTokens: [ + ['usdc', '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', 6], + ['susd', '0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9', 18], + ], + rewards: [ + { + symbol: 'velo', + address: '0x9560e827af36c94d2ac33a39bce1fe78631088db', + decimals: 18, + }, + ], + }, + }, ], }, From a5708ef95f781c510b11c1791f44b1700c57b705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 13 Oct 2025 14:15:59 +0900 Subject: [PATCH 19/29] chores/feat: add error msg if token config is missing in config/tokenMappings --- src/config/unifiedZapConfig.js | 2 + src/executors/UnifiedZapExecutor.js | 73 ++++++++++++++++++-- src/intents/UnifiedZapIntentHandler.js | 34 +++++++++ src/protocols/PendlePTProtocol.js | 13 ++++ src/services/SSEEventFactory.js | 2 + src/services/TokenProcessor.js | 3 +- src/services/priceProviders/coingecko.js | 15 +++- src/services/priceProviders/coinmarketcap.js | 15 +++- src/utils/SwapErrorClassifier.js | 19 ++++- src/utils/errors.js | 41 +++++++++++ 10 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 src/utils/errors.js diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index c4f77e4..5ac5ae0 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -60,6 +60,8 @@ const UNIFIED_ZAP_CONFIG = { enabled: true, config: { mode: 'single', + symbolOfBestTokenToZapInOut: 'usdc', + zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', protocolAddress: '0x888888888889758F76e7103c6CbF23ABbF58F946', // Add this line - using marketAddress diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index 18dd922..f0ed44e 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -9,6 +9,7 @@ const { TokenConfigService, CHAIN_METADATA } = require('../config/tokenConfig'); const SSEEventFactory = require('../services/SSEEventFactory'); const TransactionBuilder = require('../transactions/TransactionBuilder'); const PhasedExecutionStore = require('./PhasedExecutionStore'); +const { MissingTokenMappingError } = require('../utils/errors'); class UnifiedZapExecutor { constructor( @@ -52,10 +53,8 @@ class UnifiedZapExecutor { ); // Phase 2: Get current prices for calculations - const tokenPrices = await this._getTokenPrices( - inputToken, - protocolAllocations - ); + const { prices: tokenPrices, errors: tokenPriceErrors } = + await this._getTokenPrices(inputToken, protocolAllocations); // Phase 3: Calculate token requirements for each protocol const protocolsWithRequirements = await this._analyzeTokenRequirements( @@ -75,6 +74,7 @@ class UnifiedZapExecutor { strategyAllocations, protocolAllocations: protocolsWithRequirements, tokenPrices, + tokenPriceErrors, timestamp: Date.now(), }; } @@ -506,18 +506,79 @@ class UnifiedZapExecutor { // Fetch prices for all tokens const prices = {}; + const errors = []; const pricePromises = Array.from(tokenSymbols).map(async symbol => { try { const priceObj = await this.priceService.getPrice(symbol); prices[symbol] = priceObj.price; } catch (error) { - console.warn(`Failed to get price for ${symbol}:`, error.message); + const formattedError = this._formatTokenPriceError(symbol, error); + errors.push(formattedError); + console.warn( + `Failed to get price for ${symbol}:`, + formattedError.developerMessage || formattedError.message + ); prices[symbol] = 0; } }); await Promise.all(pricePromises); - return prices; + return { + prices, + errors, + }; + } + + _formatTokenPriceError(symbol, error) { + const normalizedSymbol = symbol?.toUpperCase?.() || symbol || 'UNKNOWN'; + const message = + error && typeof error.message === 'string' + ? error.message + : 'Unknown token price error'; + + const errorInfo = { + symbol: normalizedSymbol, + code: 'PRICE_FETCH_FAILED', + message, + developerMessage: + error instanceof MissingTokenMappingError && error.developerMessage + ? error.developerMessage + : message, + provider: error?.provider || null, + severity: 'critical', + userMessage: `Unable to retrieve price for token '${normalizedSymbol}'.`, + }; + + if (error instanceof MissingTokenMappingError) { + errorInfo.code = 'CONFIG_MISSING_TOKEN_MAPPING'; + errorInfo.provider = error.provider; + errorInfo.userMessage = `Configuration for token '${normalizedSymbol}' is missing.`; + } + + const providerErrors = this._extractProviderErrors(error); + if (providerErrors.length > 0) { + errorInfo.providersAttempted = providerErrors; + } + + return errorInfo; + } + + _extractProviderErrors(error) { + if (!error || typeof error.message !== 'string') { + return []; + } + + const detailsMatch = error.message.match(/Failed to get price[^:]*:(.*)$/); + if (!detailsMatch || detailsMatch.length < 2) { + return []; + } + + try { + const parsed = JSON.parse(detailsMatch[1].trim()); + return Array.isArray(parsed) ? parsed : []; + } catch (_parseError) { + return []; + } } /** diff --git a/src/intents/UnifiedZapIntentHandler.js b/src/intents/UnifiedZapIntentHandler.js index 8b1c8af..4dfcfc0 100644 --- a/src/intents/UnifiedZapIntentHandler.js +++ b/src/intents/UnifiedZapIntentHandler.js @@ -105,6 +105,40 @@ class UnifiedZapIntentHandler extends BaseIntentHandler { ); }; + const priceErrors = Array.isArray(executionContext.tokenPriceErrors) + ? executionContext.tokenPriceErrors + : []; + + if (priceErrors.length > 0) { + emitPhaseProgress('price_fetch', 5, { + message: 'Unable to fetch required token prices', + errorCount: priceErrors.length, + }); + + const summaryMessage = priceErrors + .map( + errorInfo => + `${errorInfo.symbol}: ${errorInfo.userMessage || errorInfo.message}` + ) + .join('; '); + + streamWriter( + SSEEventFactory.createErrorEvent(summaryMessage, { + phase: 'price_fetch', + errorCode: 'TOKEN_PRICE_UNAVAILABLE', + processedTokens: 0, + totalTokens: executionContext.protocolAllocations?.length || 0, + errors: priceErrors, + }) + ); + + return { + success: false, + error: summaryMessage, + priceErrors, + }; + } + try { // Phase 1: Strategy Parsing emitPhaseProgress('strategy_parsing', 0, { diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 4ae648e..7111c14 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -192,9 +192,22 @@ class PendlePTProtocol extends BaseProtocolV2 { getTokenRequirements(inputToken) { const baseRequirements = super.getTokenRequirements(inputToken); + const normalizedInput = inputToken?.toLowerCase?.() || ''; + const normalizedUnderlying = + this.underlyingTokenAddress?.toLowerCase?.() || ''; + + const requiresSwap = + normalizedInput && + normalizedUnderlying && + normalizedInput !== normalizedUnderlying; + return { ...baseRequirements, + inputToken: this.underlyingTokenAddress || baseRequirements.inputToken, + outputToken: this.underlyingTokenAddress || baseRequirements.outputToken, + requiresSwap: requiresSwap || baseRequirements.requiresSwap, protocolSpecific: { + ...(baseRequirements.protocolSpecific || {}), marketAddress: this.marketAddress, ptTokenAddress: this.ptTokenAddress, ytTokenAddress: this.ytTokenAddress, diff --git a/src/services/SSEEventFactory.js b/src/services/SSEEventFactory.js index 0492cfc..80129b4 100644 --- a/src/services/SSEEventFactory.js +++ b/src/services/SSEEventFactory.js @@ -93,6 +93,7 @@ class SSEEventFactory { token, error, errorCategory, + developerMessage, userFriendlyMessage, provider = 'failed', tradingLoss = null, @@ -110,6 +111,7 @@ class SSEEventFactory { error: typeof error === 'string' ? error : error?.message || 'Unknown error', errorCategory, + developerMessage, userFriendlyMessage, // Fallback data for consistency diff --git a/src/services/TokenProcessor.js b/src/services/TokenProcessor.js index 0f32ecd..6062f3c 100644 --- a/src/services/TokenProcessor.js +++ b/src/services/TokenProcessor.js @@ -149,7 +149,7 @@ class TokenProcessor { // Return structured error result using value object return TokenProcessingResult.failure({ token, - error: error.message || 'Unknown swap error', + error: error, // Pass the full error object for better classification inputValueUSD: token.amount * token.price, }); } @@ -194,6 +194,7 @@ class TokenProcessor { token, error: errorClassification.errorMessage, errorCategory: errorClassification.category, + developerMessage: errorClassification.developerMessage, // Pass developer message userFriendlyMessage: errorClassification.userFriendlyMessage, provider: errorClassification.providerState, tradingLoss: fallbackData.tradingLoss, diff --git a/src/services/priceProviders/coingecko.js b/src/services/priceProviders/coingecko.js index 94b6422..18fce2f 100644 --- a/src/services/priceProviders/coingecko.js +++ b/src/services/priceProviders/coingecko.js @@ -1,5 +1,6 @@ const axios = require('axios'); const { getTokenId } = require('../../config/priceConfig'); +const { MissingTokenMappingError } = require('../../utils/errors'); /** * CoinGecko Price Provider @@ -21,7 +22,7 @@ class CoinGeckoProvider { async getPrice(symbol, options = {}) { const coinId = getTokenId(this.name, symbol); if (!coinId) { - throw new Error(`Token ${symbol} not supported by ${this.name}`); + throw new MissingTokenMappingError(this.name, symbol); } const config = { @@ -62,6 +63,9 @@ class CoinGeckoProvider { }, }; } catch (error) { + if (error instanceof MissingTokenMappingError) { + throw error; // Re-throw our custom error to be caught by the classifier + } if (error.response) { throw new Error( `CoinGecko API error: ${error.response.data?.error || error.message}` @@ -96,6 +100,10 @@ class CoinGeckoProvider { } if (coinIds.length === 0) { + // If all tokens are unsupported, we can throw a clear error. + if (unsupportedTokens.length > 0) { + throw new MissingTokenMappingError(this.name, unsupportedTokens[0]); + } throw new Error('No supported tokens found for CoinGecko'); } @@ -139,11 +147,12 @@ class CoinGeckoProvider { } } - // Add errors for unsupported tokens + // Add errors for unsupported tokens using the new error type for consistency for (const symbol of unsupportedTokens) { + const configError = new MissingTokenMappingError(this.name, symbol); errors.push({ symbol, - error: `Token ${symbol} not supported by ${this.name}`, + error: configError.developerMessage, // Use the detailed message for logs provider: this.name, }); } diff --git a/src/services/priceProviders/coinmarketcap.js b/src/services/priceProviders/coinmarketcap.js index 9f69511..f5d4528 100644 --- a/src/services/priceProviders/coinmarketcap.js +++ b/src/services/priceProviders/coinmarketcap.js @@ -1,5 +1,6 @@ const axios = require('axios'); const { getTokenId } = require('../../config/priceConfig'); +const { MissingTokenMappingError } = require('../../utils/errors'); /** * CoinMarketCap Price Provider @@ -51,7 +52,7 @@ class CoinMarketCapProvider { async getPrice(symbol, options = {}) { const tokenId = getTokenId(this.name, symbol); if (!tokenId) { - throw new Error(`Token ${symbol} not supported by ${this.name}`); + throw new MissingTokenMappingError(this.name, symbol); } const apiKey = this.getNextApiKey(); @@ -103,6 +104,9 @@ class CoinMarketCapProvider { }, }; } catch (error) { + if (error instanceof MissingTokenMappingError) { + throw error; // Re-throw our custom error to be caught by the classifier + } if (error.response) { // API returned an error response const errorMessage = @@ -141,6 +145,10 @@ class CoinMarketCapProvider { } if (tokenIds.length === 0) { + // If all tokens are unsupported, we can throw a clear error. + if (unsupportedTokens.length > 0) { + throw new MissingTokenMappingError(this.name, unsupportedTokens[0]); + } throw new Error('No supported tokens found for CoinMarketCap'); } @@ -197,11 +205,12 @@ class CoinMarketCapProvider { } } - // Add errors for unsupported tokens + // Add errors for unsupported tokens using the new error type for consistency for (const symbol of unsupportedTokens) { + const configError = new MissingTokenMappingError(this.name, symbol); errors.push({ symbol, - error: `Token ${symbol} not supported by ${this.name}`, + error: configError.developerMessage, // Use the detailed message for logs provider: this.name, }); } diff --git a/src/utils/SwapErrorClassifier.js b/src/utils/SwapErrorClassifier.js index 235e007..cdf372d 100644 --- a/src/utils/SwapErrorClassifier.js +++ b/src/utils/SwapErrorClassifier.js @@ -3,10 +3,13 @@ * Provides consistent error structures and classifications across all intent handlers */ +const { MissingTokenMappingError } = require('./errors'); + /** * Error categories for swap operations */ const ERROR_CATEGORIES = { + CONFIG_MISSING_TOKEN_MAPPING: 'CONFIG_MISSING_TOKEN_MAPPING', QUOTE_FAILED: 'QUOTE_FAILED', DATA_EXTRACTION_ERROR: 'DATA_EXTRACTION_ERROR', PROCESSING_ERROR: 'PROCESSING_ERROR', @@ -50,9 +53,22 @@ class SwapErrorClassifier { * @returns {Object} Standardized error classification */ static classifyError(error, context = {}) { + const { tokenSymbol = 'Unknown', swapQuote = null } = context; + + // Handle specific, structured errors first for clarity + if (error instanceof MissingTokenMappingError) { + return { + category: ERROR_CATEGORIES.CONFIG_MISSING_TOKEN_MAPPING, + providerState: PROVIDER_STATES.FAILED, + errorMessage: error.message, + userFriendlyMessage: `Configuration for token ${error.symbol} is missing.`, + developerMessage: error.developerMessage, + originalError: error, + }; + } + const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error'; - const { tokenSymbol = 'Unknown', swapQuote = null } = context; // Determine error category based on error message and context let category = ERROR_CATEGORIES.UNKNOWN_ERROR; @@ -91,6 +107,7 @@ class SwapErrorClassifier { providerState, errorMessage, userFriendlyMessage, + developerMessage: errorMessage, // Default developer message is the raw error originalError: error, }; } diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..a0bf8e9 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,41 @@ +/** + * Custom application errors for standardized error handling. + */ + +class BaseError extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Thrown when a token symbol cannot be found in a provider's mapping file. + * This is a configuration error that is actionable by developers. + */ +class MissingTokenMappingError extends BaseError { + /** + * @param {string} provider - The name of the price provider. + * @param {string} symbol - The token symbol that is missing. + */ + constructor(provider, symbol) { + const developerMessage = `Token '${symbol}' not found in mapping for provider '${provider}'. Please add it to the corresponding JSON configuration file.`; + const productionMessage = `Configuration for token '${symbol}' is missing.`; + + // Use the detailed message in development, otherwise use the generic one. + const message = + process.env.NODE_ENV !== 'production' + ? developerMessage + : productionMessage; + + super(message); + this.provider = provider; + this.symbol = symbol; + this.developerMessage = developerMessage; // Make the detailed message always available for logging. + } +} + +module.exports = { + MissingTokenMappingError, +}; From c3d836110a56c1718693923f7963ab6d5a4aef0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 13 Oct 2025 14:40:16 +0900 Subject: [PATCH 20/29] refactor: Cut out the retry backoff delays. Added a local mock for retryWithBackoff in test/balance.integration.test.js:13 so the integration suite still exercises the retry logic but loops synchronously without waiting between attempts, while keeping the rest of the helper exports intac --- test/balance.integration.test.js | 39 +++++++++++++++++++++-- test/priceProviders.coingecko.test.js | 11 +++++-- test/priceProviders.coinmarketcap.test.js | 11 +++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/test/balance.integration.test.js b/test/balance.integration.test.js index db7f27c..1d8f848 100644 --- a/test/balance.integration.test.js +++ b/test/balance.integration.test.js @@ -11,13 +11,46 @@ */ const request = require('supertest'); -const app = require('../src/app'); -const BalanceController = require('../src/controllers/balanceController'); -const axios = require('axios'); + +jest.mock('../src/utils/retry', () => { + const actual = jest.requireActual('../src/utils/retry'); + + return { + ...actual, + retryWithBackoff: async (fn, options = {}, shouldRetry) => { + const mergedOptions = { + retries: 3, + ...options, + }; + const retries = mergedOptions.retries ?? 3; + + for (let attempt = 1; attempt <= retries + 1; attempt += 1) { + try { + return await fn(); + } catch (error) { + const hasMoreAttempts = attempt <= retries; + const shouldAttemptRetry = shouldRetry + ? shouldRetry(error, attempt, mergedOptions) + : true; + + if (!hasMoreAttempts || !shouldAttemptRetry) { + throw error; + } + } + } + + throw new Error('retryWithBackoff: Exhausted attempts without result'); + }, + }; +}); // Mock axios for controlled Moralis API responses jest.mock('axios'); +const app = require('../src/app'); +const BalanceController = require('../src/controllers/balanceController'); +const axios = require('axios'); + const { TEST_ADDRESSES } = require('./utils/testHelpers'); function readParam(params, key) { diff --git a/test/priceProviders.coingecko.test.js b/test/priceProviders.coingecko.test.js index 2513f19..682aa79 100644 --- a/test/priceProviders.coingecko.test.js +++ b/test/priceProviders.coingecko.test.js @@ -3,6 +3,7 @@ const axios = require('axios'); // Use actual config for token id mapping const { getTokenId } = require('../src/config/priceConfig'); +const { MissingTokenMappingError } = require('../src/utils/errors'); describe('CoinGeckoProvider', () => { let CoinGeckoProvider; @@ -43,7 +44,10 @@ describe('CoinGeckoProvider', () => { it('getPrice throws when token unsupported', async () => { const provider = new CoinGeckoProvider(); await expect(provider.getPrice('notatoken')).rejects.toThrow( - 'Token notatoken not supported by coingecko' + MissingTokenMappingError + ); + await expect(provider.getPrice('notatoken')).rejects.toThrow( + "Token 'notatoken' not found in mapping for provider 'coingecko'" ); }); @@ -110,7 +114,10 @@ describe('CoinGeckoProvider', () => { it('getBulkPrices throws when no supported tokens', async () => { const provider = new CoinGeckoProvider(); await expect(provider.getBulkPrices(['foo', 'bar'])).rejects.toThrow( - 'No supported tokens found for CoinGecko' + MissingTokenMappingError + ); + await expect(provider.getBulkPrices(['foo', 'bar'])).rejects.toThrow( + "Token 'foo' not found in mapping for provider 'coingecko'" ); }); diff --git a/test/priceProviders.coinmarketcap.test.js b/test/priceProviders.coinmarketcap.test.js index a116638..9dfe9db 100644 --- a/test/priceProviders.coinmarketcap.test.js +++ b/test/priceProviders.coinmarketcap.test.js @@ -1,5 +1,6 @@ jest.mock('axios'); const axios = require('axios'); +const { MissingTokenMappingError } = require('../src/utils/errors'); describe('CoinMarketCapProvider', () => { let CoinMarketCapProvider; @@ -78,7 +79,10 @@ describe('CoinMarketCapProvider', () => { it('throws when token unsupported', async () => { const provider = new CoinMarketCapProvider(); await expect(provider.getPrice('nope')).rejects.toThrow( - 'Token nope not supported by coinmarketcap' + MissingTokenMappingError + ); + await expect(provider.getPrice('nope')).rejects.toThrow( + "Token 'nope' not found in mapping for provider 'coinmarketcap'" ); }); @@ -160,7 +164,10 @@ describe('CoinMarketCapProvider', () => { it('getBulkPrices throws when no supported tokens', async () => { const provider = new CoinMarketCapProvider(); await expect(provider.getBulkPrices(['foo'])).rejects.toThrow( - 'No supported tokens found for CoinMarketCap' + MissingTokenMappingError + ); + await expect(provider.getBulkPrices(['foo'])).rejects.toThrow( + "Token 'foo' not found in mapping for provider 'coinmarketcap'" ); }); }); From 6396cbf414e4679acf9110137a8694b6aa6b18ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 13 Oct 2025 21:37:57 +0900 Subject: [PATCH 21/29] wip: use config to support Pendle's any token can be zapped in --- src/config/unifiedZapConfig.js | 19 +- src/controllers/IntentController.js | 46 ++- src/executors/UnifiedZapExecutor.js | 71 +++- src/protocols/BaseProtocolV2.js | 25 +- src/protocols/PendlePTProtocol.js | 292 +++++++++++---- src/utils/zapTokenStrategy.js | 343 ++++++++++++++++++ src/validators/DustZapValidator.js | 24 +- test/dustZapIntentHandlerValidation.test.js | 6 +- .../integration/phasedZap.integration.test.js | 9 +- test/services/feeCalculator.test.js | 107 ++++++ test/services/feeInsertionStrategy.test.js | 302 +++++++++++++++ test/strategies-error-handling.test.js | 24 +- test/strategies.test.js | 117 ++++-- 13 files changed, 1256 insertions(+), 129 deletions(-) create mode 100644 src/utils/zapTokenStrategy.js create mode 100644 test/services/feeCalculator.test.js create mode 100644 test/services/feeInsertionStrategy.test.js diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index 5ac5ae0..59042ee 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -60,17 +60,24 @@ const UNIFIED_ZAP_CONFIG = { enabled: true, config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'usdc', - zapInOutTokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', marketAddress: '0x18ffb61c6d223bd91ec15acc248bb7e670abcc48', assetAddress: '0x247f150C90c9EEb7d733219bfA36D189C76D5Ec5', protocolAddress: '0x888888888889758F76e7103c6CbF23ABbF58F946', // Add this line - using marketAddress ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', assetDecimals: 6, - symbolOfBestTokenToZapOut: 'usdc', - bestTokenAddressToZapOut: - '0xaf88d065e77c8cc2239327c5edb3a432268e5831', - decimalOfBestTokenToZapOut: 6, + zapTokenStrategy: { + type: 'passthrough', + defaultInputToken: { + symbol: 'usdc', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + defaultOutputToken: { + symbol: 'usdc', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, }, }, // Velodrome BOLD/USDC LP on Base diff --git a/src/controllers/IntentController.js b/src/controllers/IntentController.js index 182b36a..9d3de2a 100644 --- a/src/controllers/IntentController.js +++ b/src/controllers/IntentController.js @@ -11,6 +11,11 @@ const { mapUnifiedZapError, } = require('../utils/errorHandlerUtils'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, +} = require('../utils/zapTokenStrategy'); // Initialize services (these should ideally be injected or managed by a DI container) const swapService = new SwapService(); @@ -66,6 +71,38 @@ class IntentController { } static _formatProtocolDetails(protocol) { + if (!protocol) { + throw new Error('Protocol details are required'); + } + + const config = protocol.config || {}; + + let zapStrategy = null; + try { + zapStrategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + } catch (error) { + console.warn( + `Failed to normalize zapTokenStrategy for protocol ${protocol.id}:`, + error.message + ); + } + + const lpTargetTokens = Array.isArray(config.lpTokens) + ? config.lpTokens + .map(entry => (Array.isArray(entry) ? entry[0] : null)) + .filter(Boolean) + : null; + + const strategyTargetTokens = getStrategyTokenSymbols(zapStrategy).filter( + Boolean + ); + + const targetTokens = + (lpTargetTokens?.length ? lpTargetTokens : strategyTargetTokens) || []; + return { id: protocol.id, protocol: IntentController._extractProtocolName(protocol), @@ -75,13 +112,8 @@ class IntentController { chainId: protocol.chainId, weight: protocol.weight, enabled: protocol.enabled !== false, - mode: protocol.config?.mode || 'single', - targetTokens: - protocol.config?.lpTokens?.map(([symbol]) => symbol) || - [ - protocol.config?.symbolOfBestTokenToZapInOut || - protocol.config?.symbolOfBestTokenToZapOut, - ].filter(Boolean), + mode: config?.mode || 'single', + targetTokens, }; } diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index f0ed44e..ecf1328 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -10,6 +10,13 @@ const SSEEventFactory = require('../services/SSEEventFactory'); const TransactionBuilder = require('../transactions/TransactionBuilder'); const PhasedExecutionStore = require('./PhasedExecutionStore'); const { MissingTokenMappingError } = require('../utils/errors'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, + getDefaultInputToken, + getDefaultOutputToken, +} = require('../utils/zapTokenStrategy'); class UnifiedZapExecutor { constructor( @@ -491,15 +498,34 @@ class UnifiedZapExecutor { // Add protocol tokens protocolAllocations.forEach(protocol => { - const config = protocol.instance.config; - if (config.symbolOfBestTokenToZapInOut) { - tokenSymbols.add(config.symbolOfBestTokenToZapInOut.toLowerCase()); + const config = protocol.instance.config || {}; + + try { + const strategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + + getStrategyTokenSymbols(strategy) + .filter( + symbol => + symbol && symbol.toLowerCase && symbol.toLowerCase() !== 'any' + ) + .forEach(symbol => tokenSymbols.add(symbol.toLowerCase())); + } catch (error) { + if (config.symbolOfBestTokenToZapInOut) { + tokenSymbols.add(config.symbolOfBestTokenToZapInOut.toLowerCase()); + } else if (config.symbolOfBestTokenToZapOut) { + tokenSymbols.add(config.symbolOfBestTokenToZapOut.toLowerCase()); + } } // Add LP tokens if applicable if (config.lpTokens) { config.lpTokens.forEach(([symbol]) => { - tokenSymbols.add(symbol.toLowerCase()); + if (symbol) { + tokenSymbols.add(symbol.toLowerCase()); + } }); } }); @@ -821,10 +847,15 @@ class UnifiedZapExecutor { targetTokenAddress ); + const fallbackSymbol = this._getStrategySymbol( + protocolAllocation.config?.config, + 'input' + ); + const symbolHint = this._resolveTokenSymbol( protocolAllocation.chainId, targetTokenAddress, - protocolAllocation.config?.config?.symbolOfBestTokenToZapInOut + fallbackSymbol ); const swapResult = await this._executeSwap({ @@ -1045,6 +1076,36 @@ class UnifiedZapExecutor { return address; } + _getStrategySymbol(protocolConfig, direction = 'input') { + if (!protocolConfig) { + return null; + } + + try { + const strategy = normalizeZapTokenStrategy( + protocolConfig.zapTokenStrategy, + deriveLegacyStrategyDefaults(protocolConfig) + ); + + const token = + direction === 'output' + ? getDefaultOutputToken(strategy) + : getDefaultInputToken(strategy); + + return token?.symbol || null; + } catch (error) { + if (direction === 'output') { + return protocolConfig.symbolOfBestTokenToZapOut || null; + } + + return ( + protocolConfig.symbolOfBestTokenToZapInOut || + protocolConfig.symbolOfBestTokenToZapOut || + null + ); + } + } + _resolveTokenSymbol(chainId, tokenAddress, fallbackSymbol) { if (!tokenAddress) { return fallbackSymbol || null; diff --git a/src/protocols/BaseProtocolV2.js b/src/protocols/BaseProtocolV2.js index cc3ddca..aaf4d6a 100644 --- a/src/protocols/BaseProtocolV2.js +++ b/src/protocols/BaseProtocolV2.js @@ -4,6 +4,11 @@ */ const { ethers } = require('ethers'); +const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultOutputToken, +} = require('../utils/zapTokenStrategy'); class BaseProtocolV2 { /** @@ -120,13 +125,31 @@ class BaseProtocolV2 { * @returns {Object} - Protocol info for UI display */ getProtocolInfo() { + let targetAsset = this.config.symbolOfBestTokenToZapInOut || null; + + try { + const strategy = normalizeZapTokenStrategy( + this.config.zapTokenStrategy, + deriveLegacyStrategyDefaults(this.config) + ); + const defaultOutput = getDefaultOutputToken(strategy); + if (defaultOutput?.symbol) { + targetAsset = defaultOutput.symbol; + } + } catch (error) { + // Fallback to legacy fields if strategy normalization fails + if (!targetAsset && this.config.symbolOfBestTokenToZapOut) { + targetAsset = this.config.symbolOfBestTokenToZapOut; + } + } + return { id: this.config.id || `${this.chain}-${this.constructor.name}`, name: this.config.name || this.constructor.name, chain: this.chain, chainId: this.chainId, mode: this.mode, - targetAsset: this.config.symbolOfBestTokenToZapInOut, + targetAsset, enabled: this.config.enabled !== false, }; } diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 7111c14..2d1a587 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -7,6 +7,17 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); const axios = require('axios'); const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const { + ZAP_TOKEN_STRATEGY_TYPES, + normalizeToken, + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultInputToken, + getDefaultOutputToken, + findStrategyToken, + requiresSwapForToken, + isAddressEqual, +} = require('../utils/zapTokenStrategy'); const PENDLE_CORE_API_BASE = 'https://api-v2.pendle.finance/core/v1'; const PENDLE_SDK_API_BASE = `${PENDLE_CORE_API_BASE}/sdk`; @@ -65,12 +76,31 @@ class PendlePTProtocol extends BaseProtocolV2 { this.marketAddress = config.marketAddress; this.ptTokenAddress = config.assetAddress; this.ytTokenAddress = config.ytAddress; - this.underlyingTokenAddress = config.bestTokenAddressToZapOut; this.tokenDecimals = config.assetDecimals; this.assetDecimals = config.assetDecimals; - this.bestTokenAddress = config.bestTokenAddressToZapOut; - this.bestTokenSymbol = config.symbolOfBestTokenToZapOut; - this.bestTokenDecimals = config.decimalOfBestTokenToZapOut; + + this.zapTokenStrategy = normalizeZapTokenStrategy( + config.zapTokenStrategy, + deriveLegacyStrategyDefaults(config) + ); + + this.defaultInputToken = getDefaultInputToken(this.zapTokenStrategy); + this.defaultOutputToken = getDefaultOutputToken(this.zapTokenStrategy); + + this.underlyingTokenMetadata = this.defaultInputToken; + this.outputTokenMetadata = this.defaultOutputToken; + + this.underlyingTokenAddress = + this.underlyingTokenMetadata?.address || null; + this.bestTokenAddress = this.outputTokenMetadata?.address || null; + this.bestTokenSymbol = + this.outputTokenMetadata?.symbol || + this.underlyingTokenMetadata?.symbol || + null; + this.bestTokenDecimals = + this.outputTokenMetadata?.decimals ?? + this.underlyingTokenMetadata?.decimals ?? + (Number.isInteger(config.assetDecimals) ? config.assetDecimals : null); // Pendle router addresses (chain-specific) this.routerAddresses = this._getPendleRouterAddresses(); @@ -91,7 +121,10 @@ class PendlePTProtocol extends BaseProtocolV2 { additionalParams = {} ) { this._validateAddress(userAddress); - this._validateAddress(inputToken); + + if (inputToken) { + this._validateAddress(inputToken); + } const depositAmount = typeof amount === 'bigint' ? amount : BigInt(amount.toString()); @@ -100,13 +133,7 @@ class PendlePTProtocol extends BaseProtocolV2 { throw new Error('Deposit amount cannot be zero'); } - if ( - inputToken.toLowerCase() !== this.underlyingTokenAddress.toLowerCase() - ) { - throw new Error( - `Input token ${inputToken} must match underlying token ${this.underlyingTokenAddress} for Pendle deposit` - ); - } + const depositToken = this._resolveDepositTokenMetadata(inputToken); const isExpired = await this._isMarketExpired(); if (isExpired) { @@ -121,7 +148,7 @@ class PendlePTProtocol extends BaseProtocolV2 { const swapQuote = await this._fetchPendleSwapQuote({ receiver: userAddress, - tokenIn: this.underlyingTokenAddress, + tokenIn: depositToken.address, tokenOut: this.ptTokenAddress, amountIn: depositAmount, slippage: normalizedSlippage, @@ -133,21 +160,26 @@ class PendlePTProtocol extends BaseProtocolV2 { defaultExtraGas: DEFAULT_SWAP_EXTRA_GAS, }); + const amountLabel = this._formatAmountForToken(depositAmount, depositToken); + const tokenLabel = this._formatTokenLabel(depositToken); + return { ...baseTx, - description: `Swap ${this._formatAmount( - depositAmount, - this.bestTokenDecimals - )} ${this.bestTokenSymbol.toUpperCase()} into Pendle PT`, + description: + amountLabel !== null + ? `Swap ${amountLabel} ${tokenLabel} into Pendle PT` + : `Swap ${tokenLabel} into Pendle PT`, meta: { ...baseTx.meta, quoteType: 'swapDeposit', - tokenIn: this.underlyingTokenAddress, + tokenIn: depositToken.address, tokenOut: this.ptTokenAddress, amountIn: depositAmount.toString(), estimatedAmountOut: swapQuote?.data?.amountOut ?? null, slippage: normalizedSlippage, latestPendleAssetPrice, + tokenInSymbol: depositToken.symbol || null, + zapTokenStrategy: this.zapTokenStrategy, }, }; } @@ -191,29 +223,30 @@ class PendlePTProtocol extends BaseProtocolV2 { */ getTokenRequirements(inputToken) { const baseRequirements = super.getTokenRequirements(inputToken); - - const normalizedInput = inputToken?.toLowerCase?.() || ''; - const normalizedUnderlying = - this.underlyingTokenAddress?.toLowerCase?.() || ''; - + const strategyRequiresSwap = requiresSwapForToken( + this.zapTokenStrategy, + inputToken + ); const requiresSwap = - normalizedInput && - normalizedUnderlying && - normalizedInput !== normalizedUnderlying; + strategyRequiresSwap || baseRequirements.requiresSwap || false; + + const underlyingTokenAddress = + this.underlyingTokenMetadata?.address || null; return { ...baseRequirements, - inputToken: this.underlyingTokenAddress || baseRequirements.inputToken, - outputToken: this.underlyingTokenAddress || baseRequirements.outputToken, - requiresSwap: requiresSwap || baseRequirements.requiresSwap, + inputToken: underlyingTokenAddress || baseRequirements.inputToken, + outputToken: underlyingTokenAddress || baseRequirements.outputToken, + requiresSwap, protocolSpecific: { ...(baseRequirements.protocolSpecific || {}), marketAddress: this.marketAddress, ptTokenAddress: this.ptTokenAddress, ytTokenAddress: this.ytTokenAddress, - underlyingToken: this.underlyingTokenAddress, + underlyingToken: underlyingTokenAddress, routerAddress: this.routerAddresses.router, requiresMarketInteraction: true, + zapTokenStrategy: this.zapTokenStrategy, }, }; } @@ -223,30 +256,22 @@ class PendlePTProtocol extends BaseProtocolV2 { * @private */ _validatePendleConfig() { - const required = [ - 'marketAddress', - 'assetAddress', - 'ytAddress', - 'bestTokenAddressToZapOut', - 'assetDecimals', - 'symbolOfBestTokenToZapOut', - 'decimalOfBestTokenToZapOut', - ]; - - const missing = required.filter(key => !this.config[key]); + const required = ['marketAddress', 'assetAddress', 'ytAddress', 'assetDecimals']; + + const missing = required.filter(key => this.config[key] === undefined); if (missing.length > 0) { throw new Error(`Missing required Pendle config: ${missing.join(', ')}`); } // Validate addresses - const addresses = [ - 'marketAddress', - 'assetAddress', - 'ytAddress', - 'bestTokenAddressToZapOut', - ]; - addresses.forEach(key => { - if (!ethers.isAddress(this.config[key])) { + const addressKeys = ['marketAddress', 'assetAddress', 'ytAddress']; + + if (this.config.bestTokenAddressToZapOut) { + addressKeys.push('bestTokenAddressToZapOut'); + } + + addressKeys.forEach(key => { + if (this.config[key] && !ethers.isAddress(this.config[key])) { throw new Error(`Invalid ${key}: ${this.config[key]}`); } }); @@ -258,12 +283,21 @@ class PendlePTProtocol extends BaseProtocolV2 { throw new Error(`Invalid assetDecimals: ${this.config.assetDecimals}`); } - if ( - !Number.isInteger(this.config.decimalOfBestTokenToZapOut) || - this.config.decimalOfBestTokenToZapOut < 0 - ) { - throw new Error( - `Invalid decimalOfBestTokenToZapOut: ${this.config.decimalOfBestTokenToZapOut}` + if (this.config.decimalOfBestTokenToZapOut !== undefined) { + if ( + !Number.isInteger(this.config.decimalOfBestTokenToZapOut) || + this.config.decimalOfBestTokenToZapOut < 0 + ) { + throw new Error( + `Invalid decimalOfBestTokenToZapOut: ${this.config.decimalOfBestTokenToZapOut}` + ); + } + } + + if (this.config.zapTokenStrategy) { + normalizeZapTokenStrategy( + this.config.zapTokenStrategy, + deriveLegacyStrategyDefaults(this.config) ); } } @@ -309,6 +343,129 @@ class PendlePTProtocol extends BaseProtocolV2 { return bounded / 100; } + _resolveDepositTokenMetadata(inputToken) { + if (!this.zapTokenStrategy) { + if (!inputToken) { + throw new Error('Input token is required for Pendle deposit'); + } + return normalizeToken({ address: inputToken }); + } + + if (this.zapTokenStrategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + if (inputToken) { + const match = findStrategyToken(this.zapTokenStrategy, inputToken); + if (match) { + return match; + } + + const defaultMeta = this.defaultInputToken; + return normalizeToken({ + address: inputToken, + symbol: defaultMeta?.symbol || null, + decimals: defaultMeta?.decimals ?? null, + }); + } + + if (this.defaultInputToken) { + return this.defaultInputToken; + } + + throw new Error( + 'Input token is required for passthrough zap strategy on Pendle' + ); + } + + const tokenAddress = inputToken || this.defaultInputToken?.address; + if (!tokenAddress) { + throw new Error('Input token is required for Pendle deposit'); + } + + const tokenMeta = findStrategyToken(this.zapTokenStrategy, tokenAddress); + if (!tokenMeta) { + throw new Error( + `Input token ${tokenAddress} is not supported by this Pendle strategy` + ); + } + + if ( + inputToken && + this.zapTokenStrategy.type === ZAP_TOKEN_STRATEGY_TYPES.FIXED && + !isAddressEqual(tokenMeta.address, inputToken) + ) { + throw new Error( + `Input token ${inputToken} must match required token ${tokenMeta.address}` + ); + } + + return tokenMeta; + } + + _resolveRedeemTokenMetadata(requestedToken) { + if (!this.zapTokenStrategy) { + if (!requestedToken && !this.bestTokenAddress) { + throw new Error('tokenOut is required for Pendle redemption'); + } + const fallback = requestedToken || this.bestTokenAddress; + return normalizeToken({ + address: fallback, + symbol: this.bestTokenSymbol || null, + decimals: this.bestTokenDecimals ?? null, + }); + } + + if (requestedToken) { + const match = findStrategyToken(this.zapTokenStrategy, requestedToken); + if (match) { + return match; + } + return normalizeToken({ address: requestedToken }); + } + + const defaultToken = getDefaultOutputToken(this.zapTokenStrategy); + if (defaultToken) { + return defaultToken; + } + + throw new Error( + 'tokenOut must be provided for passthrough zap strategy redemption' + ); + } + + _formatAmountForToken(amount, tokenMeta) { + if (!tokenMeta) { + return this._formatAmount(amount); + } + + if (tokenMeta.decimals === null || tokenMeta.decimals === undefined) { + if (this.bestTokenDecimals !== null && this.bestTokenDecimals !== undefined) { + return this._formatAmount(amount, this.bestTokenDecimals); + } + return this._formatAmount(amount); + } + + return this._formatAmount(amount, tokenMeta.decimals); + } + + _formatTokenLabel(tokenMeta) { + if (!tokenMeta) { + return 'token'; + } + + if (tokenMeta.symbol) { + return tokenMeta.symbol.toUpperCase(); + } + + return this._shortenAddress(tokenMeta.address); + } + + _shortenAddress(address) { + if (!address || address.length < 10) { + return address || 'token'; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } + async _fetchPendleSwapQuote({ receiver, tokenIn, @@ -587,6 +744,11 @@ class PendlePTProtocol extends BaseProtocolV2 { additionalParams.slippage ); const receiver = additionalParams.receiver || userAddress; + const requestedTokenOut = + additionalParams.tokenOut || additionalParams.tokenOutAddress || null; + const tokenOutMetadata = this._resolveRedeemTokenMetadata( + requestedTokenOut + ); const isExpired = typeof additionalParams.isExpired === 'boolean' ? additionalParams.isExpired @@ -595,7 +757,7 @@ class PendlePTProtocol extends BaseProtocolV2 { if (isExpired) { const redeemQuote = await this._fetchPendleRedeemQuote({ receiver, - tokenOut: this.bestTokenAddress, + tokenOut: tokenOutMetadata.address, amountIn: redeemAmount, slippage: normalizedSlippage, }); @@ -604,20 +766,24 @@ class PendlePTProtocol extends BaseProtocolV2 { defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, }); + const tokenLabel = this._formatTokenLabel(tokenOutMetadata); + return { ...baseTx, description: `Redeem ${this._formatAmount( redeemAmount, this.tokenDecimals - )} PT into ${this.bestTokenSymbol.toUpperCase()}`, + )} PT into ${tokenLabel}`, meta: { ...baseTx.meta, quoteType: 'redeem', tokenIn: this.ptTokenAddress, - tokenOut: this.bestTokenAddress, + tokenOut: tokenOutMetadata.address, + tokenOutSymbol: tokenOutMetadata.symbol || null, amountIn: redeemAmount.toString(), slippage: normalizedSlippage, isExpired: true, + zapTokenStrategy: this.zapTokenStrategy, }, }; } @@ -625,7 +791,7 @@ class PendlePTProtocol extends BaseProtocolV2 { const swapQuote = await this._fetchPendleSwapQuote({ receiver, tokenIn: this.ptTokenAddress, - tokenOut: this.bestTokenAddress, + tokenOut: tokenOutMetadata.address, amountIn: redeemAmount, slippage: normalizedSlippage, }); @@ -636,22 +802,26 @@ class PendlePTProtocol extends BaseProtocolV2 { defaultExtraGas: DEFAULT_REDEEM_EXTRA_GAS, }); + const tokenLabel = this._formatTokenLabel(tokenOutMetadata); + return { ...baseTx, description: `Swap ${this._formatAmount( redeemAmount, this.tokenDecimals - )} PT into ${this.bestTokenSymbol.toUpperCase()}`, + )} PT into ${tokenLabel}`, meta: { ...baseTx.meta, quoteType: 'swapRedeem', tokenIn: this.ptTokenAddress, - tokenOut: this.bestTokenAddress, + tokenOut: tokenOutMetadata.address, amountIn: redeemAmount.toString(), estimatedAmountOut: swapQuote?.data?.amountOut ?? null, slippage: normalizedSlippage, latestPendleAssetPrice, isExpired: false, + tokenOutSymbol: tokenOutMetadata.symbol || null, + zapTokenStrategy: this.zapTokenStrategy, }, }; } diff --git a/src/utils/zapTokenStrategy.js b/src/utils/zapTokenStrategy.js new file mode 100644 index 0000000..dc74b2f --- /dev/null +++ b/src/utils/zapTokenStrategy.js @@ -0,0 +1,343 @@ +const { ethers } = require('ethers'); + +const ZAP_TOKEN_STRATEGY_TYPES = Object.freeze({ + FIXED: 'fixed', + PASSTHROUGH: 'passthrough', + OPTIONS: 'options', +}); + +function isAddressEqual(a, b) { + if (!a || !b) { + return false; + } + return a.toLowerCase() === b.toLowerCase(); +} + +function normalizeToken(token) { + if (!token) { + return null; + } + + const { address, symbol = null, decimals = null } = token; + + if (!address || typeof address !== 'string') { + throw new Error('Zap token metadata is missing a valid address'); + } + + if (!ethers.isAddress(address)) { + throw new Error(`Invalid zap token address: ${address}`); + } + + if (decimals !== null && decimals !== undefined) { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error( + `Invalid decimals for zap token ${address}: ${decimals}` + ); + } + } + + return { + symbol, + address, + addressLower: address.toLowerCase(), + decimals: decimals === undefined ? null : decimals, + }; +} + +function deriveLegacyStrategyDefaults(config = {}) { + const defaults = {}; + + if ( + config.symbolOfBestTokenToZapInOut && + config.zapInOutTokenAddress && + ethers.isAddress(config.zapInOutTokenAddress) + ) { + defaults.defaultInputToken = { + symbol: config.symbolOfBestTokenToZapInOut, + address: config.zapInOutTokenAddress, + decimals: + Number.isInteger(config.assetDecimals) && config.assetDecimals >= 0 + ? config.assetDecimals + : null, + }; + } + + if ( + config.symbolOfBestTokenToZapOut && + config.bestTokenAddressToZapOut && + ethers.isAddress(config.bestTokenAddressToZapOut) + ) { + defaults.defaultOutputToken = { + symbol: config.symbolOfBestTokenToZapOut, + address: config.bestTokenAddressToZapOut, + decimals: + Number.isInteger(config.decimalOfBestTokenToZapOut) && + config.decimalOfBestTokenToZapOut >= 0 + ? config.decimalOfBestTokenToZapOut + : null, + }; + } + + if (!defaults.defaultInputToken && defaults.defaultOutputToken) { + defaults.defaultInputToken = defaults.defaultOutputToken; + } + + if (!defaults.defaultOutputToken && defaults.defaultInputToken) { + defaults.defaultOutputToken = defaults.defaultInputToken; + } + + return defaults; +} + +function normalizeZapTokenStrategy(rawStrategy, legacyDefaults = {}) { + const { defaultInputToken, defaultOutputToken } = legacyDefaults || {}; + + const fallbackToken = + defaultInputToken || defaultOutputToken || legacyDefaults?.token || null; + + if (!rawStrategy) { + if (fallbackToken) { + const normalizedToken = normalizeToken(fallbackToken); + const normalizedOutput = normalizeToken( + defaultOutputToken || fallbackToken + ); + + return { + type: ZAP_TOKEN_STRATEGY_TYPES.FIXED, + tokens: [normalizedToken], + defaultInputToken: normalizeToken(defaultInputToken || fallbackToken), + defaultOutputToken: normalizedOutput, + }; + } + + return { + type: ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH, + tokens: [], + defaultInputToken: defaultInputToken + ? normalizeToken(defaultInputToken) + : null, + defaultOutputToken: defaultOutputToken + ? normalizeToken(defaultOutputToken) + : null, + }; + } + + const type = (rawStrategy.type || '').toLowerCase(); + + if (!Object.values(ZAP_TOKEN_STRATEGY_TYPES).includes(type)) { + throw new Error(`Invalid zapTokenStrategy type: ${rawStrategy.type}`); + } + + if (type === ZAP_TOKEN_STRATEGY_TYPES.FIXED) { + const token = + rawStrategy.token || + rawStrategy.defaultToken || + rawStrategy.defaultInputToken || + fallbackToken; + + if (!token) { + throw new Error('Fixed zap token strategy requires a token'); + } + + const normalizedToken = normalizeToken(token); + const normalizedOutput = normalizeToken( + rawStrategy.defaultOutputToken || token + ); + + return { + type, + tokens: [normalizedToken], + defaultInputToken: normalizedToken, + defaultOutputToken: normalizedOutput, + }; + } + + if (type === ZAP_TOKEN_STRATEGY_TYPES.OPTIONS) { + if (!Array.isArray(rawStrategy.tokens) || rawStrategy.tokens.length === 0) { + throw new Error('Options zap token strategy requires at least one token'); + } + + const normalizedTokens = rawStrategy.tokens.map(normalizeToken); + const normalizedInput = normalizeToken( + rawStrategy.defaultInputToken || normalizedTokens[0] + ); + const normalizedOutput = normalizeToken( + rawStrategy.defaultOutputToken || normalizedInput + ); + + return { + type, + tokens: normalizedTokens, + defaultInputToken: normalizedInput, + defaultOutputToken: normalizedOutput, + }; + } + + // Passthrough strategy + const normalizedTokens = Array.isArray(rawStrategy.tokens) + ? rawStrategy.tokens.map(normalizeToken) + : []; + + const normalizedInput = rawStrategy.defaultInputToken + ? normalizeToken(rawStrategy.defaultInputToken) + : defaultInputToken + ? normalizeToken(defaultInputToken) + : null; + + const normalizedOutput = rawStrategy.defaultOutputToken + ? normalizeToken(rawStrategy.defaultOutputToken) + : defaultOutputToken + ? normalizeToken(defaultOutputToken) + : null; + + return { + type, + tokens: normalizedTokens, + defaultInputToken: normalizedInput, + defaultOutputToken: normalizedOutput, + }; +} + +function findStrategyToken(strategy, address) { + if (!strategy || !address) { + return null; + } + + const normalized = address.toLowerCase(); + + if (strategy.tokens && strategy.tokens.length > 0) { + const match = strategy.tokens.find( + token => token.addressLower === normalized + ); + if (match) { + return match; + } + } + + if ( + strategy.defaultInputToken && + strategy.defaultInputToken.addressLower === normalized + ) { + return strategy.defaultInputToken; + } + + if ( + strategy.defaultOutputToken && + strategy.defaultOutputToken.addressLower === normalized + ) { + return strategy.defaultOutputToken; + } + + return null; +} + +function getDefaultInputToken(strategy) { + if (!strategy) { + return null; + } + + if (strategy.defaultInputToken) { + return strategy.defaultInputToken; + } + + if (strategy.tokens && strategy.tokens.length > 0) { + return strategy.tokens[0]; + } + + return null; +} + +function getDefaultOutputToken(strategy) { + if (!strategy) { + return null; + } + + if (strategy.defaultOutputToken) { + return strategy.defaultOutputToken; + } + + if (strategy.defaultInputToken) { + return strategy.defaultInputToken; + } + + if (strategy.tokens && strategy.tokens.length > 0) { + return strategy.tokens[0]; + } + + return null; +} + +function requiresSwapForToken(strategy, inputToken) { + if (!strategy || !inputToken) { + return false; + } + + const normalized = inputToken.toLowerCase(); + + if (strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + if (!strategy.tokens || strategy.tokens.length === 0) { + return false; + } + return !strategy.tokens.some(token => token.addressLower === normalized); + } + + if (strategy.type === ZAP_TOKEN_STRATEGY_TYPES.OPTIONS) { + return !strategy.tokens.some(token => token.addressLower === normalized); + } + + // Fixed strategy + const defaultToken = getDefaultInputToken(strategy); + if (!defaultToken) { + return false; + } + return defaultToken.addressLower !== normalized; +} + +function getStrategyTokenSymbols(strategy) { + if (!strategy) { + return []; + } + + const symbols = new Set(); + + const candidateTokens = []; + + if (strategy.tokens && strategy.tokens.length > 0) { + candidateTokens.push(...strategy.tokens); + } + + if (strategy.defaultInputToken) { + candidateTokens.push(strategy.defaultInputToken); + } + + if (strategy.defaultOutputToken) { + candidateTokens.push(strategy.defaultOutputToken); + } + + candidateTokens + .filter(Boolean) + .forEach(token => { + if (token.symbol) { + symbols.add(token.symbol); + } + }); + + if (symbols.size === 0 && strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + return ['ANY']; + } + + return Array.from(symbols); +} + +module.exports = { + ZAP_TOKEN_STRATEGY_TYPES, + normalizeToken, + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getDefaultInputToken, + getDefaultOutputToken, + getStrategyTokenSymbols, + findStrategyToken, + requiresSwapForToken, + isAddressEqual, +}; diff --git a/src/validators/DustZapValidator.js b/src/validators/DustZapValidator.js index aa9a0e0..d28fe73 100644 --- a/src/validators/DustZapValidator.js +++ b/src/validators/DustZapValidator.js @@ -49,8 +49,28 @@ class DustZapValidator { } } - if (targetToken && !config.SUPPORTED_TARGET_TOKENS.includes(targetToken)) { - throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); + const { SUPPORTED_TARGET_TOKENS } = config; + const allowAny = + SUPPORTED_TARGET_TOKENS === '*' || + SUPPORTED_TARGET_TOKENS === 'ANY' || + SUPPORTED_TARGET_TOKENS === 'any'; + + if (targetToken && !allowAny) { + if (Array.isArray(SUPPORTED_TARGET_TOKENS)) { + const normalizedTarget = targetToken.toLowerCase(); + const supportedMatch = SUPPORTED_TARGET_TOKENS.some(token => { + if (!token) { + return false; + } + return token.toLowerCase() === normalizedTarget; + }); + + if (!supportedMatch) { + throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); + } + } else { + throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); + } } if ( diff --git a/test/dustZapIntentHandlerValidation.test.js b/test/dustZapIntentHandlerValidation.test.js index 80bc004..9873e40 100644 --- a/test/dustZapIntentHandlerValidation.test.js +++ b/test/dustZapIntentHandlerValidation.test.js @@ -149,7 +149,7 @@ describe('DustZapIntentHandler Validation', () => { ); }); - it('should throw error for non-ETH target token', () => { + it('should accept non-ETH target token when wildcard enabled', () => { const invalidRequest = { userAddress: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', chainId: 1, @@ -161,9 +161,7 @@ describe('DustZapIntentHandler Validation', () => { }, }; - expect(() => handler.validate(invalidRequest)).toThrow( - 'Only ETH target token is currently supported' - ); + expect(() => handler.validate(invalidRequest)).not.toThrow(); }); it('should accept ETH target token', () => { diff --git a/test/integration/phasedZap.integration.test.js b/test/integration/phasedZap.integration.test.js index b3bcb96..021ee70 100644 --- a/test/integration/phasedZap.integration.test.js +++ b/test/integration/phasedZap.integration.test.js @@ -23,7 +23,14 @@ const mockProtocolInstance = { getTokenRequirements: jest.fn(), config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'USDC', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, }, }; diff --git a/test/services/feeCalculator.test.js b/test/services/feeCalculator.test.js new file mode 100644 index 0000000..b8ad539 --- /dev/null +++ b/test/services/feeCalculator.test.js @@ -0,0 +1,107 @@ +const crypto = require('crypto'); + +const FeeCalculator = require('../../src/services/fee/FeeCalculator'); + +describe('FeeCalculator', () => { + let calculator; + let randomQueue; + let randomSpy; + + beforeEach(() => { + calculator = new FeeCalculator(); + randomQueue = []; + randomSpy = jest + .spyOn(crypto, 'randomInt') + .mockImplementation((minOrMax, maybeMax) => { + const hasExplicitMin = typeof maybeMax === 'number'; + const min = hasExplicitMin ? minOrMax : 0; + const max = hasExplicitMin ? maybeMax : minOrMax; + + if (randomQueue.length === 0) { + return min; // deterministic fallback when queue empty + } + + const next = randomQueue.shift(); + // clamp into expected range to imitate crypto.randomInt behaviour + return Math.min(Math.max(next, min), Math.max(min, max - 1)); + }); + }); + + afterEach(() => { + randomSpy.mockRestore(); + }); + + const setRandomSequence = values => { + randomQueue = [...values]; + }; + + describe('calculateMinimumThreshold', () => { + it('uses percentage and safety buffer to compute minimum swaps', () => { + const batches = [['a', 'b'], ['c']]; + const result = calculator.calculateMinimumThreshold(batches, 0.5, { + minimumThresholdPercentage: 0.4, + safetyBuffer: 0.1, + }); + + // totalTokens=3, transactions per token=2 -> 6 total + // threshold percentage = 0.5 => ceil(6*0.5)=3 + expect(result).toBe(3); + }); + + it('honours absolute minimum even when threshold percentage is tiny', () => { + const batches = [['only']]; + const result = calculator.calculateMinimumThreshold(batches, 0.2, { + minimumThresholdPercentage: 0, + safetyBuffer: 0, + }); + + // minimum threshold percentage gives 0, but absolute minimum ~20% of tokens -> 1 + expect(result).toBe(1); + }); + }); + + describe('generateRandomInsertionPoints', () => { + it('distributes points across available range', () => { + setRandomSequence([0, 1, 2]); + const points = calculator.generateRandomInsertionPoints(2, 12, 3, { + spreadFactor: 0.5, + }); + + expect(points).toEqual([2, 6, 10]); + }); + + it('falls back to end of range when minimum index exceeds max', () => { + setRandomSequence([0, 1, 2]); + const points = calculator.generateRandomInsertionPoints(10, 5, 2); + // fallback range is last 3 positions -> indices 2,3,4 + expect(points).toEqual([2, 3]); + }); + + it('uses sequential with offsets when space is tight', () => { + setRandomSequence([]); + const points = calculator.generateRandomInsertionPoints(8, 10, 3); + + expect(points).toEqual([8, 9, 9]); + }); + }); + + describe('generateRandomInsertionPointsInRange', () => { + it('returns unique sorted insertion points', () => { + setRandomSequence([0, 0, 1, 2]); + const points = calculator.generateRandomInsertionPointsInRange(5, 8, 3); + + expect(points).toEqual([5, 6, 7]); + }); + }); + + describe('generateSequentialWithRandomOffset', () => { + it('creates sequential points with small random offsets', () => { + setRandomSequence([0, 1, 0]); + const points = calculator.generateSequentialWithRandomOffset(4, 10, 3); + + expect(points.every(p => p >= 4 && p < 10)).toBe(true); + expect(points.length).toBe(3); + expect(points).toEqual([4, 7, 8]); + }); + }); +}); diff --git a/test/services/feeInsertionStrategy.test.js b/test/services/feeInsertionStrategy.test.js new file mode 100644 index 0000000..5ab959c --- /dev/null +++ b/test/services/feeInsertionStrategy.test.js @@ -0,0 +1,302 @@ +const FeeInsertionStrategy = require('../../src/services/fee/FeeInsertionStrategy'); +const InsertionStrategyParams = require('../../src/valueObjects/InsertionStrategyParams'); + +describe('FeeInsertionStrategy', () => { + let strategy; + + const sampleBatches = [[{ symbol: 'A' }], [{ symbol: 'B' }]]; + + beforeEach(() => { + strategy = new FeeInsertionStrategy(); + }); + + describe('calculateInsertionStrategyWithParams', () => { + it('delegates to calculateInsertionStrategy when provided params object', () => { + const params = InsertionStrategyParams.forBalancedStrategy({ + batches: sampleBatches, + totalFeeETH: 0.2, + totalTransactionCount: 8, + feeTransactionCount: 2, + }); + + const spy = jest + .spyOn(strategy, 'calculateInsertionStrategy') + .mockReturnValue({ strategy: 'random', insertionPoints: [3, 6] }); + + const result = strategy.calculateInsertionStrategyWithParams(params); + + expect(spy).toHaveBeenCalledWith( + params.batches, + params.totalFeeETH, + params.totalTransactionCount, + params.feeTransactionCount, + expect.objectContaining({ minimumThresholdPercentage: 0.4 }) + ); + expect(result).toEqual({ strategy: 'random', insertionPoints: [3, 6] }); + }); + + it('throws when provided params are invalid', () => { + expect(() => strategy.calculateInsertionStrategyWithParams({})).toThrow( + 'Expected InsertionStrategyParams instance' + ); + }); + }); + + describe('calculateInsertionStrategy', () => { + it('returns random strategy metadata when threshold below total transactions', () => { + jest + .spyOn(strategy.calculator, 'calculateMinimumThreshold') + .mockReturnValue(3); + jest + .spyOn(strategy.calculator, 'generateRandomInsertionPoints') + .mockReturnValue([4, 6]); + + const result = strategy.calculateInsertionStrategy( + sampleBatches, + 0.3, + 10, + 2, + { spreadFactor: 0.5 } + ); + + expect(result).toMatchObject({ + strategy: 'random', + minimumThreshold: 3, + insertionPoints: [4, 6], + metadata: expect.objectContaining({ + totalTokens: 2, + totalTransactions: 10, + feeTransactionCount: 2, + }), + }); + }); + + it('returns fallback strategy when threshold exceeds total transactions', () => { + jest + .spyOn(strategy.calculator, 'calculateMinimumThreshold') + .mockReturnValue(12); + jest + .spyOn(strategy.calculator, 'generateRandomInsertionPoints') + .mockReturnValue([9, 10]); + + const result = strategy.calculateInsertionStrategy( + sampleBatches, + 0.3, + 10, + 2 + ); + + expect(result.strategy).toBe('fallback'); + expect(result.insertionPoints).toEqual([9, 10]); + }); + }); + + describe('validateInsertionStrategy', () => { + it('validates points within range and after minimum', () => { + const isValid = strategy.validateInsertionStrategy( + { + insertionPoints: [3, 5], + minimumThreshold: 3, + strategy: 'random', + }, + 8 + ); + expect(isValid).toBe(true); + }); + + it('returns false when points violate constraints', () => { + const isValid = strategy.validateInsertionStrategy( + { + insertionPoints: [1, 9], + minimumThreshold: 3, + strategy: 'random', + }, + 5 + ); + expect(isValid).toBe(false); + }); + }); + + describe('shouldInsertFeeBlock', () => { + it('inserts immediately when fallback strategy reaches 80% progress', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 10, + { + strategy: 'fallback', + insertionPoints: [], + minimumThreshold: 12, + }, + 8, + 10 + ); + + expect(shouldInsert).toBe(true); + }); + + it('uses first insertion point for random strategy', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 5, + { + strategy: 'random', + insertionPoints: [4, 7], + minimumThreshold: 2, + }, + 1, + 5 + ); + + expect(shouldInsert).toBe(true); + }); + + it('falls back to minimum threshold when no insertion points exist', () => { + const shouldInsert = strategy.shouldInsertFeeBlock( + 3, + { + strategy: 'random', + insertionPoints: [], + minimumThreshold: 3, + }, + 1, + 4 + ); + + expect(shouldInsert).toBe(true); + }); + }); + + describe('executeFeeBlockInsertion', () => { + it('inserts fee block when conditions satisfied', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const transactions = [{ id: 't1' }]; + + jest.spyOn(strategy, 'shouldInsertFeeBlock').mockReturnValue(true); + + const result = strategy.executeFeeBlockInsertion({ + feeTransactions, + insertionStrategy: { strategy: 'random' }, + transactions, + currentTransactionCount: transactions.length, + processedTokenCount: 1, + totalTokenCount: 2, + }); + + expect(result).toMatchObject({ inserted: true, feeTransactionCount: 2 }); + expect(transactions.slice(-2)).toEqual(feeTransactions); + }); + + it('skips insertion when conditions fail', () => { + const result = strategy.executeFeeBlockInsertion({ + feeTransactions: [{ id: 'fee1' }], + insertionStrategy: { strategy: 'random', insertionPoints: [10] }, + transactions: [], + currentTransactionCount: 0, + processedTokenCount: 0, + totalTokenCount: 2, + }); + + expect(result.inserted).toBe(false); + expect(result.reason).toMatch(/Conditions not met/); + }); + }); + + describe('executeFallbackFeeInsertion', () => { + it('appends fee transactions at the end', () => { + const feeTransactions = [{ id: 'fee1' }]; + const transactions = [{ id: 'existing' }]; + + const result = strategy.executeFallbackFeeInsertion({ + feeTransactions, + transactions, + }); + + expect(result).toMatchObject({ inserted: true, position: 1 }); + expect(transactions).toHaveLength(2); + }); + + it('reports no-op when nothing to insert', () => { + const result = strategy.executeFallbackFeeInsertion({ + feeTransactions: [], + transactions: [], + }); + + expect(result).toEqual({ + inserted: false, + reason: 'No fee transactions to insert', + }); + }); + }); + + describe('processFeeInsertion', () => { + it('injects fees when insertion point reached', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const results = { transactions: [] }; + + const state = strategy.processFeeInsertion({ + shouldInsertFees: true, + insertionPoints: [0, 1], + currentTransactionIndex: 0, + feesInserted: 0, + feeTransactions, + results, + }); + + expect(state).toEqual({ + insertionPoints: [], + feesInserted: 2, + currentTransactionIndex: 2, + }); + expect(results.transactions).toEqual(feeTransactions); + }); + + it('makes no changes when insertion conditions are not met', () => { + const feeTransactions = [{ id: 'fee1' }]; + const results = { transactions: [] }; + + const state = strategy.processFeeInsertion({ + shouldInsertFees: true, + insertionPoints: [2], + currentTransactionIndex: 0, + feesInserted: 0, + feeTransactions, + results, + }); + + expect(state).toEqual({ + insertionPoints: [2], + feesInserted: 0, + currentTransactionIndex: 0, + }); + expect(results.transactions).toHaveLength(0); + }); + }); + + describe('insertRemainingFees', () => { + it('pushes remaining fees when enabled', () => { + const feeTransactions = [{ id: 'fee1' }, { id: 'fee2' }]; + const results = { transactions: [{ id: 'existing' }] }; + + strategy.insertRemainingFees({ + shouldInsertFees: true, + feesInserted: 1, + feeTransactions, + results, + }); + + expect(results.transactions).toHaveLength(2); + expect(results.transactions[1]).toEqual(feeTransactions[1]); + }); + + it('does nothing when all fees inserted already', () => { + const results = { transactions: [] }; + + strategy.insertRemainingFees({ + shouldInsertFees: true, + feesInserted: 2, + feeTransactions: [{ id: 'fee1' }, { id: 'fee2' }], + results, + }); + + expect(results.transactions).toHaveLength(0); + }); + }); +}); diff --git a/test/strategies-error-handling.test.js b/test/strategies-error-handling.test.js index cfe6447..3833641 100644 --- a/test/strategies-error-handling.test.js +++ b/test/strategies-error-handling.test.js @@ -36,7 +36,17 @@ const mockUnifiedZapConfig = { chainId: 42161, weight: 50, enabled: true, - config: { mode: 'single', symbolOfBestTokenToZapInOut: 'USDC' }, + config: { + mode: 'single', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, + }, }, { id: 'test-protocol-2', @@ -313,7 +323,17 @@ describe('Strategies Error Handling and Edge Cases', () => { chainId: 42161, weight: 1, enabled: true, - config: { mode: 'single', symbolOfBestTokenToZapInOut: 'USDC' }, + config: { + mode: 'single', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, + }, })); originalConfig.STRATEGY_CATEGORIES = { diff --git a/test/strategies.test.js b/test/strategies.test.js index a49ddb4..cb5c486 100644 --- a/test/strategies.test.js +++ b/test/strategies.test.js @@ -4,7 +4,7 @@ * This test file comprehensively validates the IntentController.getStrategies method * which returns strategy data with protocol details including: * - Protocol name cleaning (removing chain suffixes like "(Arbitrum)") - * - Target token extraction from both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut + * - Target token extraction from zap token strategy definitions and legacy fields * - LP token handling from lpTokens array * - Proper formatting of protocol details using the _formatProtocolDetails private method * @@ -239,44 +239,45 @@ describe('GET /api/v1/strategies', () => { const singleProtocols = protocols.filter(p => p.mode === 'single'); singleProtocols.forEach(protocol => { - expect(protocol.targetTokens.length).toBe(1); - expect(typeof protocol.targetTokens[0]).toBe('string'); + expect(protocol.targetTokens.length).toBeGreaterThan(0); + protocol.targetTokens.forEach(token => { + expect(typeof token).toBe('string'); + }); }); }); - it('should support both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut', () => { + it('should include zap token strategy symbols when configured', () => { const UNIFIED_ZAP_CONFIG = require('../src/config/unifiedZapConfig'); + const { + normalizeZapTokenStrategy, + deriveLegacyStrategyDefaults, + getStrategyTokenSymbols, + } = require('../src/utils/zapTokenStrategy'); + const allProtocols = Object.values( UNIFIED_ZAP_CONFIG.STRATEGY_CATEGORIES ).flatMap(strategy => strategy.protocols); - // Find protocols with either field to ensure the extraction logic works - const protocolsWithInOut = allProtocols.filter( - p => p.config?.symbolOfBestTokenToZapInOut - ); - const protocolsWithOut = allProtocols.filter( - p => p.config?.symbolOfBestTokenToZapOut - ); + allProtocols.forEach(protocol => { + const strategy = normalizeZapTokenStrategy( + protocol.config?.zapTokenStrategy, + deriveLegacyStrategyDefaults(protocol.config || {}) + ); - if (protocolsWithInOut.length > 0) { - protocolsWithInOut.forEach(protocol => { - const responseProtocol = protocols.find(p => p.id === protocol.id); - expect(responseProtocol).toBeDefined(); - expect(responseProtocol.targetTokens).toContain( - protocol.config.symbolOfBestTokenToZapInOut - ); - }); - } + const expectedSymbols = getStrategyTokenSymbols(strategy).filter( + symbol => symbol && symbol.toLowerCase() !== 'any' + ); + + if (expectedSymbols.length === 0) { + return; + } - if (protocolsWithOut.length > 0) { - protocolsWithOut.forEach(protocol => { - const responseProtocol = protocols.find(p => p.id === protocol.id); - expect(responseProtocol).toBeDefined(); - expect(responseProtocol.targetTokens).toContain( - protocol.config.symbolOfBestTokenToZapOut - ); + const responseProtocol = protocols.find(p => p.id === protocol.id); + expect(responseProtocol).toBeDefined(); + expectedSymbols.forEach(symbol => { + expect(responseProtocol.targetTokens).toContain(symbol); }); - } + }); }); it('should filter out empty/undefined tokens', () => { @@ -306,7 +307,14 @@ describe('GET /api/v1/strategies', () => { enabled: true, config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'USDC', + zapTokenStrategy: { + type: 'fixed', + token: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + }, }, }; @@ -320,7 +328,7 @@ describe('GET /api/v1/strategies', () => { expect(formatted.mode).toBe('single'); }); - it('should handle protocol with symbolOfBestTokenToZapOut', () => { + it('should handle protocol with default output token strategy', () => { const IntentController = require('../src/controllers/IntentController'); const mockProtocol = { @@ -333,7 +341,14 @@ describe('GET /api/v1/strategies', () => { enabled: false, config: { mode: 'single', - symbolOfBestTokenToZapOut: 'WETH', + zapTokenStrategy: { + type: 'passthrough', + defaultOutputToken: { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + }, }, }; @@ -386,7 +401,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockMinimalProtocol); - expect(formatted.targetTokens).toEqual([]); // Empty when no tokens specified + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); // Default enabled expect(formatted.mode).toBe('single'); // Default mode }); @@ -425,7 +440,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('single'); // Default mode - expect(formatted.targetTokens).toEqual([]); // Empty for null config + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); }); @@ -445,7 +460,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('single'); - expect(formatted.targetTokens).toEqual([]); + expect(formatted.targetTokens).toEqual(['ANY']); expect(formatted.enabled).toBe(true); // Default when not specified }); @@ -468,7 +483,7 @@ describe('GET /api/v1/strategies', () => { const formatted = IntentController._formatProtocolDetails(mockProtocol); expect(formatted.mode).toBe('LP'); - expect(formatted.targetTokens).toEqual([]); // Empty for empty lpTokens + expect(formatted.targetTokens).toEqual(['ANY']); }); it('should handle protocol with malformed lpTokens', () => { @@ -498,7 +513,7 @@ describe('GET /api/v1/strategies', () => { expect(Array.isArray(formatted.targetTokens)).toBe(true); }); - it('should handle protocol with both symbolOfBestTokenToZapInOut and symbolOfBestTokenToZapOut', () => { + it('should handle protocol with multiple zap token options', () => { const IntentController = require('../src/controllers/IntentController'); const mockProtocol = { @@ -510,15 +525,37 @@ describe('GET /api/v1/strategies', () => { weight: 50, config: { mode: 'single', - symbolOfBestTokenToZapInOut: 'USDC', - symbolOfBestTokenToZapOut: 'WETH', // Both present + zapTokenStrategy: { + type: 'options', + tokens: [ + { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + ], + defaultInputToken: { + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + }, + defaultOutputToken: { + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + decimals: 18, + }, + }, }, }; const formatted = IntentController._formatProtocolDetails(mockProtocol); - // Should prioritize symbolOfBestTokenToZapInOut - expect(formatted.targetTokens).toEqual(['USDC']); + expect(formatted.targetTokens).toEqual(['USDC', 'WETH']); }); it('should handle protocol with complex chain suffix patterns', () => { From c03a607f7ac15f3870513a8eac69e73cbaebb4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 13 Oct 2025 21:45:57 +0900 Subject: [PATCH 22/29] fix: pendle can send txns but still failed with slippage issue --- src/protocols/PendlePTProtocol.js | 34 +++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 2d1a587..10c466f 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -223,27 +223,41 @@ class PendlePTProtocol extends BaseProtocolV2 { */ getTokenRequirements(inputToken) { const baseRequirements = super.getTokenRequirements(inputToken); + let resolvedDepositToken = null; + + try { + resolvedDepositToken = this._resolveDepositTokenMetadata(inputToken); + } catch (error) { + resolvedDepositToken = this.defaultInputToken || null; + } + + const depositTokenAddress = resolvedDepositToken?.address || null; + const strategyRequiresSwap = requiresSwapForToken( this.zapTokenStrategy, inputToken ); - const requiresSwap = - strategyRequiresSwap || baseRequirements.requiresSwap || false; - const underlyingTokenAddress = - this.underlyingTokenMetadata?.address || null; + const requiresSwap = + strategyRequiresSwap || + baseRequirements.requiresSwap || + (inputToken && + depositTokenAddress && + inputToken.toLowerCase() !== depositTokenAddress.toLowerCase()) || + false; return { ...baseRequirements, - inputToken: underlyingTokenAddress || baseRequirements.inputToken, - outputToken: underlyingTokenAddress || baseRequirements.outputToken, + inputToken: depositTokenAddress || baseRequirements.inputToken, + outputToken: depositTokenAddress || baseRequirements.outputToken, requiresSwap, protocolSpecific: { ...(baseRequirements.protocolSpecific || {}), marketAddress: this.marketAddress, ptTokenAddress: this.ptTokenAddress, ytTokenAddress: this.ytTokenAddress, - underlyingToken: underlyingTokenAddress, + underlyingToken: depositTokenAddress, + depositToken: resolvedDepositToken, routerAddress: this.routerAddresses.router, requiresMarketInteraction: true, zapTokenStrategy: this.zapTokenStrategy, @@ -359,10 +373,12 @@ class PendlePTProtocol extends BaseProtocolV2 { } const defaultMeta = this.defaultInputToken; + if (defaultMeta && isAddressEqual(defaultMeta.address, inputToken)) { + return defaultMeta; + } + return normalizeToken({ address: inputToken, - symbol: defaultMeta?.symbol || null, - decimals: defaultMeta?.decimals ?? null, }); } From 1c965839d60fb4780abb64337f7b5a26cc2d5265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Mon, 13 Oct 2025 22:14:16 +0900 Subject: [PATCH 23/29] fix(pendle): now we can use weth to zapin/out --- src/config/unifiedZapConfig.js | 4 ++ src/protocols/PendlePTProtocol.js | 55 +++++++++++++++++---- src/validators/DustZapValidator.js | 24 +-------- test/dustZapIntentHandlerValidation.test.js | 6 ++- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index 59042ee..3358195 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -65,6 +65,10 @@ const UNIFIED_ZAP_CONFIG = { protocolAddress: '0x888888888889758F76e7103c6CbF23ABbF58F946', // Add this line - using marketAddress ytAddress: '0x59e4e0FE7981E31Eb1283ff9aDc5F851FE9A216D', assetDecimals: 6, + symbolOfBestTokenToZapOut: 'usdc', + bestTokenAddressToZapOut: + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimalOfBestTokenToZapOut: 6, zapTokenStrategy: { type: 'passthrough', defaultInputToken: { diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 10c466f..b5af9fd 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -18,6 +18,7 @@ const { requiresSwapForToken, isAddressEqual, } = require('../utils/zapTokenStrategy'); +const { TokenConfigService } = require('../config/tokenConfig'); const PENDLE_CORE_API_BASE = 'https://api-v2.pendle.finance/core/v1'; const PENDLE_SDK_API_BASE = `${PENDLE_CORE_API_BASE}/sdk`; @@ -104,6 +105,8 @@ class PendlePTProtocol extends BaseProtocolV2 { // Pendle router addresses (chain-specific) this.routerAddresses = this._getPendleRouterAddresses(); + + this.currentDepositTokenMetadata = null; } /** @@ -231,20 +234,31 @@ class PendlePTProtocol extends BaseProtocolV2 { resolvedDepositToken = this.defaultInputToken || null; } - const depositTokenAddress = resolvedDepositToken?.address || null; + const depositTokenAddress = + resolvedDepositToken?.address || + baseRequirements.inputToken || + inputToken || + null; const strategyRequiresSwap = requiresSwapForToken( this.zapTokenStrategy, inputToken ); - const requiresSwap = - strategyRequiresSwap || - baseRequirements.requiresSwap || - (inputToken && - depositTokenAddress && - inputToken.toLowerCase() !== depositTokenAddress.toLowerCase()) || - false; + let requiresSwap = baseRequirements.requiresSwap; + + if (this.zapTokenStrategy?.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + requiresSwap = false; + } else { + requiresSwap = + strategyRequiresSwap || + requiresSwap || + (inputToken && + depositTokenAddress && + !isAddressEqual(inputToken, depositTokenAddress)); + } + + this.currentDepositTokenMetadata = resolvedDepositToken; return { ...baseRequirements, @@ -357,12 +371,33 @@ class PendlePTProtocol extends BaseProtocolV2 { return bounded / 100; } + _normalizeTokenWithRegistry(token) { + if (!token || !token.address) { + throw new Error('Token address is required'); + } + + const registryMeta = TokenConfigService.getTokenByAddress( + this.chainId, + token.address + ); + + return normalizeToken({ + address: token.address, + symbol: token.symbol ?? registryMeta?.symbol ?? null, + decimals: + token.decimals ?? + registryMeta?.decimals ?? + this.defaultInputToken?.decimals ?? + null, + }); + } + _resolveDepositTokenMetadata(inputToken) { if (!this.zapTokenStrategy) { if (!inputToken) { throw new Error('Input token is required for Pendle deposit'); } - return normalizeToken({ address: inputToken }); + return this._normalizeTokenWithRegistry({ address: inputToken }); } if (this.zapTokenStrategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { @@ -377,7 +412,7 @@ class PendlePTProtocol extends BaseProtocolV2 { return defaultMeta; } - return normalizeToken({ + return this._normalizeTokenWithRegistry({ address: inputToken, }); } diff --git a/src/validators/DustZapValidator.js b/src/validators/DustZapValidator.js index d28fe73..aa9a0e0 100644 --- a/src/validators/DustZapValidator.js +++ b/src/validators/DustZapValidator.js @@ -49,28 +49,8 @@ class DustZapValidator { } } - const { SUPPORTED_TARGET_TOKENS } = config; - const allowAny = - SUPPORTED_TARGET_TOKENS === '*' || - SUPPORTED_TARGET_TOKENS === 'ANY' || - SUPPORTED_TARGET_TOKENS === 'any'; - - if (targetToken && !allowAny) { - if (Array.isArray(SUPPORTED_TARGET_TOKENS)) { - const normalizedTarget = targetToken.toLowerCase(); - const supportedMatch = SUPPORTED_TARGET_TOKENS.some(token => { - if (!token) { - return false; - } - return token.toLowerCase() === normalizedTarget; - }); - - if (!supportedMatch) { - throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); - } - } else { - throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); - } + if (targetToken && !config.SUPPORTED_TARGET_TOKENS.includes(targetToken)) { + throw new Error(config.ERRORS.UNSUPPORTED_TARGET_TOKEN); } if ( diff --git a/test/dustZapIntentHandlerValidation.test.js b/test/dustZapIntentHandlerValidation.test.js index 9873e40..80bc004 100644 --- a/test/dustZapIntentHandlerValidation.test.js +++ b/test/dustZapIntentHandlerValidation.test.js @@ -149,7 +149,7 @@ describe('DustZapIntentHandler Validation', () => { ); }); - it('should accept non-ETH target token when wildcard enabled', () => { + it('should throw error for non-ETH target token', () => { const invalidRequest = { userAddress: '0x2eCBC6f229feD06044CDb0dD772437a30190CD50', chainId: 1, @@ -161,7 +161,9 @@ describe('DustZapIntentHandler Validation', () => { }, }; - expect(() => handler.validate(invalidRequest)).not.toThrow(); + expect(() => handler.validate(invalidRequest)).toThrow( + 'Only ETH target token is currently supported' + ); }); it('should accept ETH target token', () => { From 550860efe87fc266aa3697ce5b2706421cc36dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 14:49:27 +0900 Subject: [PATCH 24/29] fixCI: lint --- src/executors/UnifiedZapExecutor.js | 4 ++-- src/protocols/BaseProtocolV2.js | 2 +- src/protocols/PendlePTProtocol.js | 22 ++++++++++++++-------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/executors/UnifiedZapExecutor.js b/src/executors/UnifiedZapExecutor.js index ecf1328..b041bc7 100644 --- a/src/executors/UnifiedZapExecutor.js +++ b/src/executors/UnifiedZapExecutor.js @@ -512,7 +512,7 @@ class UnifiedZapExecutor { symbol && symbol.toLowerCase && symbol.toLowerCase() !== 'any' ) .forEach(symbol => tokenSymbols.add(symbol.toLowerCase())); - } catch (error) { + } catch (_error) { if (config.symbolOfBestTokenToZapInOut) { tokenSymbols.add(config.symbolOfBestTokenToZapInOut.toLowerCase()); } else if (config.symbolOfBestTokenToZapOut) { @@ -1093,7 +1093,7 @@ class UnifiedZapExecutor { : getDefaultInputToken(strategy); return token?.symbol || null; - } catch (error) { + } catch (_error) { if (direction === 'output') { return protocolConfig.symbolOfBestTokenToZapOut || null; } diff --git a/src/protocols/BaseProtocolV2.js b/src/protocols/BaseProtocolV2.js index aaf4d6a..16da598 100644 --- a/src/protocols/BaseProtocolV2.js +++ b/src/protocols/BaseProtocolV2.js @@ -136,7 +136,7 @@ class BaseProtocolV2 { if (defaultOutput?.symbol) { targetAsset = defaultOutput.symbol; } - } catch (error) { + } catch (_error) { // Fallback to legacy fields if strategy normalization fails if (!targetAsset && this.config.symbolOfBestTokenToZapOut) { targetAsset = this.config.symbolOfBestTokenToZapOut; diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index b5af9fd..e76d179 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -91,8 +91,7 @@ class PendlePTProtocol extends BaseProtocolV2 { this.underlyingTokenMetadata = this.defaultInputToken; this.outputTokenMetadata = this.defaultOutputToken; - this.underlyingTokenAddress = - this.underlyingTokenMetadata?.address || null; + this.underlyingTokenAddress = this.underlyingTokenMetadata?.address || null; this.bestTokenAddress = this.outputTokenMetadata?.address || null; this.bestTokenSymbol = this.outputTokenMetadata?.symbol || @@ -230,7 +229,7 @@ class PendlePTProtocol extends BaseProtocolV2 { try { resolvedDepositToken = this._resolveDepositTokenMetadata(inputToken); - } catch (error) { + } catch (_error) { resolvedDepositToken = this.defaultInputToken || null; } @@ -284,7 +283,12 @@ class PendlePTProtocol extends BaseProtocolV2 { * @private */ _validatePendleConfig() { - const required = ['marketAddress', 'assetAddress', 'ytAddress', 'assetDecimals']; + const required = [ + 'marketAddress', + 'assetAddress', + 'ytAddress', + 'assetDecimals', + ]; const missing = required.filter(key => this.config[key] === undefined); if (missing.length > 0) { @@ -488,7 +492,10 @@ class PendlePTProtocol extends BaseProtocolV2 { } if (tokenMeta.decimals === null || tokenMeta.decimals === undefined) { - if (this.bestTokenDecimals !== null && this.bestTokenDecimals !== undefined) { + if ( + this.bestTokenDecimals !== null && + this.bestTokenDecimals !== undefined + ) { return this._formatAmount(amount, this.bestTokenDecimals); } return this._formatAmount(amount); @@ -797,9 +804,8 @@ class PendlePTProtocol extends BaseProtocolV2 { const receiver = additionalParams.receiver || userAddress; const requestedTokenOut = additionalParams.tokenOut || additionalParams.tokenOutAddress || null; - const tokenOutMetadata = this._resolveRedeemTokenMetadata( - requestedTokenOut - ); + const tokenOutMetadata = + this._resolveRedeemTokenMetadata(requestedTokenOut); const isExpired = typeof additionalParams.isExpired === 'boolean' ? additionalParams.isExpired From becc9931bf6315d82ad4c1ed513ffaa719c001f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 15:14:34 +0900 Subject: [PATCH 25/29] chainId is the canonical identifier - it's what the Pendle API expects (you can see it used directly in lines 536, 564, 592) The chain string was just a lookup key that ultimately resolved to the same chainId Simpler caching with consistent keys Less cognitive overhead when reading the code --- src/protocols/PendlePTProtocol.js | 37 +++++++++++++------------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index e76d179..1148b85 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -28,40 +28,33 @@ const PENDLE_MARKET_ABI = ['function isExpired() view returns (bool)']; const PROVIDER_CACHE = new Map(); -function resolveRpcUrl(chain, chainId) { - if (chain && UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS?.[chain]?.rpcUrl) { - return UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS[chain].rpcUrl; +function resolveRpcUrl(chainId) { + if (!chainId) { + return null; } - if (chainId) { - const match = Object.values(UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {}).find( - config => config.chainId === chainId - ); - if (match?.rpcUrl) { - return match.rpcUrl; - } - } - - return null; + const match = Object.values(UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {}).find( + config => config.chainId === chainId + ); + return match?.rpcUrl || null; } -function getRpcProvider(chain, chainId) { - const cacheKey = chainId || chain; - if (cacheKey && PROVIDER_CACHE.has(cacheKey)) { - return PROVIDER_CACHE.get(cacheKey); +function getRpcProvider(chainId) { + if (chainId && PROVIDER_CACHE.has(chainId)) { + return PROVIDER_CACHE.get(chainId); } - const rpcUrl = resolveRpcUrl(chain, chainId); + const rpcUrl = resolveRpcUrl(chainId); if (!rpcUrl) { throw new Error( - `No RPC URL configured for Pendle chain ${chain ?? 'unknown'} (${chainId ?? 'n/a'})` + `No RPC URL configured for Pendle chainId ${chainId ?? 'n/a'}` ); } const provider = new ethers.JsonRpcProvider(rpcUrl); - if (cacheKey) { - PROVIDER_CACHE.set(cacheKey, provider); + if (chainId) { + PROVIDER_CACHE.set(chainId, provider); } return provider; } @@ -336,7 +329,7 @@ class PendlePTProtocol extends BaseProtocolV2 { _getRpcProviderInstance() { if (!this._rpcProvider) { - this._rpcProvider = getRpcProvider(this.chain, this.chainId); + this._rpcProvider = getRpcProvider(this.chainId); } return this._rpcProvider; } From 713612fbfe1185c7df09ad73a2906811ad61ea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 16:22:54 +0900 Subject: [PATCH 26/29] Removed the chain parameter from both resolveRpcUrl() and getRpcProvider() Simplified the lookup logic - now it just searches by chainId directly Cleaned up the cache key - now just uses chainId (which was already the preferred key) Updated the call site in _getRpcProviderInstance() to only pass chainId The benefits: Less redundancy: No need to pass two parameters that represent the same thing Clearer intent: chainId is the canonical identifier (it's what Pendle API uses throughout) Simpler code: No fallback logic needed since we always have chainId Better caching: Cache key is now consistent (always chainId) --- README.md | 6 ++ src/config/tokenMappings/coingecko.json | 2 +- src/config/tokenMappings/coinmarketcap.json | 2 +- src/config/unifiedZapConfig.js | 18 +++- src/protocols/PendlePTProtocol.js | 35 +----- src/utils/rpcProvider.js | 112 ++++++++++++++++++++ 6 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 src/utils/rpcProvider.js diff --git a/README.md b/README.md index 5ec56a1..1a6e96c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ ZEROX_API_KEY=your_0x_api_key_here # Price API Keys COINMARKETCAP_API_KEY=your_coinmarketcap_api_key_here,your_second_key_here +# RPC Provider (optional - falls back to public RPCs if not provided) +ALCHEMY_API_KEY=your_alchemy_api_key_here + # Server Configuration PORT=3002 NODE_ENV=development @@ -318,6 +321,9 @@ ZEROX_API_KEY=your_0x_api_key_here # Price API Keys COINMARKETCAP_API_KEY=your_coinmarketcap_api_key_here +# RPC Provider (optional - falls back to public RPCs if not provided) +ALCHEMY_API_KEY=your_alchemy_api_key_here + # Server Configuration PORT=3002 NODE_ENV=production diff --git a/src/config/tokenMappings/coingecko.json b/src/config/tokenMappings/coingecko.json index 88c3fea..b359e75 100644 --- a/src/config/tokenMappings/coingecko.json +++ b/src/config/tokenMappings/coingecko.json @@ -1,7 +1,7 @@ { "btc": "bitcoin", "eth": "ethereum", - "weth": "weth", + "weth": "ethereum", "usdc": "usd-coin", "usdt": "tether", "bnb": "binancecoin", diff --git a/src/config/tokenMappings/coinmarketcap.json b/src/config/tokenMappings/coinmarketcap.json index e7fd10c..b13fcbe 100644 --- a/src/config/tokenMappings/coinmarketcap.json +++ b/src/config/tokenMappings/coinmarketcap.json @@ -1,7 +1,7 @@ { "btc": "1", "eth": "1027", - "weth": "2396", + "weth": "1027", "usdc": "3408", "usdt": "825", "bnb": "1839", diff --git a/src/config/unifiedZapConfig.js b/src/config/unifiedZapConfig.js index 3358195..6001b13 100644 --- a/src/config/unifiedZapConfig.js +++ b/src/config/unifiedZapConfig.js @@ -280,28 +280,38 @@ const UNIFIED_ZAP_CONFIG = { chainId: 1, name: 'Ethereum', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.infura.io/v3/', + alchemyPrefix: 'eth', + publicRpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth'], blockExplorerUrl: 'https://etherscan.io', }, arbitrum: { chainId: 42161, name: 'Arbitrum One', nativeCurrency: 'ETH', - rpcUrl: 'https://arb1.arbitrum.io/rpc', + alchemyPrefix: 'arb', + publicRpcUrls: [ + 'https://arb1.arbitrum.io/rpc', + 'https://arbitrum.llamarpc.com', + ], blockExplorerUrl: 'https://arbiscan.io', }, base: { chainId: 8453, name: 'Base', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.base.org', + alchemyPrefix: 'base', + publicRpcUrls: ['https://mainnet.base.org', 'https://base.llamarpc.com'], blockExplorerUrl: 'https://basescan.org', }, optimism: { chainId: 10, name: 'Optimism', nativeCurrency: 'ETH', - rpcUrl: 'https://mainnet.optimism.io', + alchemyPrefix: 'opt', + publicRpcUrls: [ + 'https://mainnet.optimism.io', + 'https://optimism.llamarpc.com', + ], blockExplorerUrl: 'https://optimistic.etherscan.io', }, }, diff --git a/src/protocols/PendlePTProtocol.js b/src/protocols/PendlePTProtocol.js index 1148b85..e4a63e1 100644 --- a/src/protocols/PendlePTProtocol.js +++ b/src/protocols/PendlePTProtocol.js @@ -6,7 +6,7 @@ const BaseProtocolV2 = require('./BaseProtocolV2'); const { ethers } = require('ethers'); const axios = require('axios'); -const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); +const { getRpcProvider } = require('../utils/rpcProvider'); const { ZAP_TOKEN_STRATEGY_TYPES, normalizeToken, @@ -26,39 +26,6 @@ const DEFAULT_SWAP_EXTRA_GAS = 600000n; const DEFAULT_REDEEM_EXTRA_GAS = 750000n; const PENDLE_MARKET_ABI = ['function isExpired() view returns (bool)']; -const PROVIDER_CACHE = new Map(); - -function resolveRpcUrl(chainId) { - if (!chainId) { - return null; - } - - const match = Object.values(UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {}).find( - config => config.chainId === chainId - ); - return match?.rpcUrl || null; -} - -function getRpcProvider(chainId) { - if (chainId && PROVIDER_CACHE.has(chainId)) { - return PROVIDER_CACHE.get(chainId); - } - - const rpcUrl = resolveRpcUrl(chainId); - - if (!rpcUrl) { - throw new Error( - `No RPC URL configured for Pendle chainId ${chainId ?? 'n/a'}` - ); - } - - const provider = new ethers.JsonRpcProvider(rpcUrl); - if (chainId) { - PROVIDER_CACHE.set(chainId, provider); - } - return provider; -} - class PendlePTProtocol extends BaseProtocolV2 { constructor(config, chain, chainId) { super(config, chain, chainId); diff --git a/src/utils/rpcProvider.js b/src/utils/rpcProvider.js new file mode 100644 index 0000000..7d8cdad --- /dev/null +++ b/src/utils/rpcProvider.js @@ -0,0 +1,112 @@ +/** + * RPC Provider Utility + * Centralized RPC provider management with Alchemy primary and public fallbacks + */ + +const { ethers } = require('ethers'); +const UNIFIED_ZAP_CONFIG = require('../config/unifiedZapConfig'); + +// Provider cache keyed by chainId +const PROVIDER_CACHE = new Map(); + +/** + * Build Alchemy RPC URL for a given chain + * @param {number} chainId - Chain ID + * @returns {string|null} - Alchemy URL or null if not configured + */ +function getAlchemyUrl(chainId) { + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + return null; + } + + const chainConfig = Object.values( + UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {} + ).find(config => config.chainId === chainId); + + if (!chainConfig?.alchemyPrefix) { + return null; + } + + return `https://${chainConfig.alchemyPrefix}-mainnet.g.alchemy.com/v2/${apiKey}`; +} + +/** + * Get public fallback RPC URLs for a chain + * @param {number} chainId - Chain ID + * @returns {string[]} - Array of public RPC URLs + */ +function getPublicRpcUrls(chainId) { + const chainConfig = Object.values( + UNIFIED_ZAP_CONFIG.SUPPORTED_CHAINS || {} + ).find(config => config.chainId === chainId); + + return chainConfig?.publicRpcUrls || []; +} + +/** + * Resolve RPC URL for a chain (Alchemy first, then fallbacks) + * @param {number} chainId - Chain ID + * @returns {string|null} - RPC URL or null if none available + */ +function resolveRpcUrl(chainId) { + if (!chainId) { + return null; + } + + // Try Alchemy first + const alchemyUrl = getAlchemyUrl(chainId); + if (alchemyUrl) { + return alchemyUrl; + } + + // Fall back to public RPCs + const publicUrls = getPublicRpcUrls(chainId); + if (publicUrls.length > 0) { + return publicUrls[0]; // Use first public RPC as fallback + } + + return null; +} + +/** + * Get or create an RPC provider for a given chain + * @param {number} chainId - Chain ID + * @returns {ethers.JsonRpcProvider} - Ethers JSON RPC provider + * @throws {Error} - If no RPC URL is configured for the chain + */ +function getRpcProvider(chainId) { + // Return cached provider if exists + if (chainId && PROVIDER_CACHE.has(chainId)) { + return PROVIDER_CACHE.get(chainId); + } + + const rpcUrl = resolveRpcUrl(chainId); + + if (!rpcUrl) { + throw new Error(`No RPC URL configured for chainId ${chainId ?? 'n/a'}`); + } + + const provider = new ethers.JsonRpcProvider(rpcUrl); + + if (chainId) { + PROVIDER_CACHE.set(chainId, provider); + } + + return provider; +} + +/** + * Clear provider cache (useful for testing or refreshing connections) + */ +function clearProviderCache() { + PROVIDER_CACHE.clear(); +} + +module.exports = { + getRpcProvider, + getAlchemyUrl, + getPublicRpcUrls, + resolveRpcUrl, + clearProviderCache, +}; From 2db67046e95bcdf173c5b73c8b97614276ce7986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 16:25:37 +0900 Subject: [PATCH 27/29] fixCI --- package-lock.json | 208 +++++++------------ package.json | 2 +- src/controllers/IntentController.js | 5 +- src/utils/zapTokenStrategy.js | 29 ++- test/intents/UnifiedZapIntentHandler.test.js | 12 +- test/routes/tokens.test.js | 4 +- 6 files changed, 109 insertions(+), 151 deletions(-) diff --git a/package-lock.json b/package-lock.json index a62a4c2..2a8002a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "express": "^4.18.2", "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^6.2.8", + "swagger-jsdoc": "^3.7.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -57,68 +57,6 @@ "node": ">=6.0.0" } }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1194,12 +1132,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -1401,12 +1333,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.0.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", @@ -1567,7 +1493,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -2945,7 +2870,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -4666,7 +4590,6 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -4703,6 +4626,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.4.tgz", + "integrity": "sha512-AD7bvav0vak1/63w3jH8F7eHId/4E4EPdMAEZhGxtjktteUv9dnNB/cJy6nVnMyoTPBJnLwFK6tiQPSTeleCtQ==", + "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", + "license": "MIT", + "dependencies": { + "call-me-maybe": "^1.0.1", + "js-yaml": "^3.13.1", + "ono": "^6.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5174,12 +5109,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -5722,12 +5651,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "node_modules/ono": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", + "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==", + "license": "MIT" + }, + "node_modules/openapi-schemas": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/openapi-schemas/-/openapi-schemas-1.0.3.tgz", + "integrity": "sha512-KtMWcK2VtOS+nD8RKSIyScJsj8JrmVWcIX7Kjx4xEHijFYuvMTDON8WfeKOgeSb4uNG6UsqLj5Na7nKbSav9RQ==", "license": "MIT", - "peer": true + "engines": { + "node": ">=8" + } + }, + "node_modules/openapi-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", + "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==", + "license": "MIT" }, "node_modules/optionator": { "version": "0.9.4", @@ -6698,7 +6641,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/stack-utils": { @@ -6935,29 +6877,28 @@ } }, "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-3.7.0.tgz", + "integrity": "sha512-K3R1NaP1CoWXeBp2F8Oh1vrtHRaDA2+pN17Ls/U1lHOtRlKtbtICwPKLRNOA2kDY0x2SXsCZisKiJlBStnv3yg==", "license": "MIT", "dependencies": { - "commander": "6.2.0", + "commander": "4.0.1", "doctrine": "3.0.0", "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" + "js-yaml": "3.13.1", + "swagger-parser": "8.0.4" }, "bin": { "swagger-jsdoc": "bin/swagger-jsdoc.js" }, "engines": { - "node": ">=12.0.0" + "node": ">=8.0.0" } }, "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.0.1.tgz", + "integrity": "sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA==", "license": "MIT", "engines": { "node": ">= 6" @@ -6984,25 +6925,39 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" + "node_modules/swagger-jsdoc/node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, + "node_modules/swagger-methods": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/swagger-methods/-/swagger-methods-2.0.2.tgz", + "integrity": "sha512-/RNqvBZkH8+3S/FqBPejHxJxZenaYq3MrpeXnzi06aDIS39Mqf5YCUNb/ZBjsvFFt8h9FxfKs8EXPtcYdfLiRg==", + "deprecated": "This package is no longer being maintained.", + "license": "MIT" + }, "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-8.0.4.tgz", + "integrity": "sha512-KGRdAaMJogSEB7sPKI31ptKIWX8lydEDAwWgB4pBMU7zys5cd54XNhoPSVlTxG/A3LphjX47EBn9j0dOGyzWbA==", "license": "MIT", "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" + "call-me-maybe": "^1.0.1", + "json-schema-ref-parser": "^7.1.3", + "ono": "^6.0.0", + "openapi-schemas": "^1.0.2", + "openapi-types": "^1.3.5", + "swagger-methods": "^2.0.1", + "z-schema": "^4.2.2" } }, "node_modules/swagger-ui-dist": { @@ -7426,34 +7381,31 @@ } }, "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.4.tgz", + "integrity": "sha512-YvBeW5RGNeNzKOUJs3rTL4+9rpcvHXt5I051FJbOcitV8bl40pEfcG0Q+dWSwS0/BIYrMZ/9HHoqLllMkFhD0w==", "license": "MIT", "dependencies": { "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" + "validator": "^13.6.0" }, "bin": { "z-schema": "bin/z-schema" }, "engines": { - "node": ">=8.0.0" + "node": ">=6.0.0" }, "optionalDependencies": { - "commander": "^9.4.1" + "commander": "^2.7.1" } }, "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } + "optional": true } } } diff --git a/package.json b/package.json index 7c72919..2830059 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "express": "^4.18.2", "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^6.2.8", + "swagger-jsdoc": "^3.7.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { diff --git a/src/controllers/IntentController.js b/src/controllers/IntentController.js index 9d3de2a..ef08ccb 100644 --- a/src/controllers/IntentController.js +++ b/src/controllers/IntentController.js @@ -96,9 +96,8 @@ class IntentController { .filter(Boolean) : null; - const strategyTargetTokens = getStrategyTokenSymbols(zapStrategy).filter( - Boolean - ); + const strategyTargetTokens = + getStrategyTokenSymbols(zapStrategy).filter(Boolean); const targetTokens = (lpTargetTokens?.length ? lpTargetTokens : strategyTargetTokens) || []; diff --git a/src/utils/zapTokenStrategy.js b/src/utils/zapTokenStrategy.js index dc74b2f..8948afd 100644 --- a/src/utils/zapTokenStrategy.js +++ b/src/utils/zapTokenStrategy.js @@ -30,9 +30,7 @@ function normalizeToken(token) { if (decimals !== null && decimals !== undefined) { if (!Number.isInteger(decimals) || decimals < 0) { - throw new Error( - `Invalid decimals for zap token ${address}: ${decimals}` - ); + throw new Error(`Invalid decimals for zap token ${address}: ${decimals}`); } } @@ -181,14 +179,14 @@ function normalizeZapTokenStrategy(rawStrategy, legacyDefaults = {}) { const normalizedInput = rawStrategy.defaultInputToken ? normalizeToken(rawStrategy.defaultInputToken) : defaultInputToken - ? normalizeToken(defaultInputToken) - : null; + ? normalizeToken(defaultInputToken) + : null; const normalizedOutput = rawStrategy.defaultOutputToken ? normalizeToken(rawStrategy.defaultOutputToken) : defaultOutputToken - ? normalizeToken(defaultOutputToken) - : null; + ? normalizeToken(defaultOutputToken) + : null; return { type, @@ -314,15 +312,16 @@ function getStrategyTokenSymbols(strategy) { candidateTokens.push(strategy.defaultOutputToken); } - candidateTokens - .filter(Boolean) - .forEach(token => { - if (token.symbol) { - symbols.add(token.symbol); - } - }); + candidateTokens.filter(Boolean).forEach(token => { + if (token.symbol) { + symbols.add(token.symbol); + } + }); - if (symbols.size === 0 && strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH) { + if ( + symbols.size === 0 && + strategy.type === ZAP_TOKEN_STRATEGY_TYPES.PASSTHROUGH + ) { return ['ANY']; } diff --git a/test/intents/UnifiedZapIntentHandler.test.js b/test/intents/UnifiedZapIntentHandler.test.js index e441edb..6687b9f 100644 --- a/test/intents/UnifiedZapIntentHandler.test.js +++ b/test/intents/UnifiedZapIntentHandler.test.js @@ -82,7 +82,9 @@ describe('UnifiedZapIntentHandler', () => { const result = await handler.execute(request); - expect(mockExecutor.prepareExecutionContext).toHaveBeenCalledWith(request); + expect(mockExecutor.prepareExecutionContext).toHaveBeenCalledWith( + request + ); expect(result).toMatchObject({ success: true, intentType: 'unifiedZap', @@ -181,7 +183,9 @@ describe('UnifiedZapIntentHandler', () => { }; const streamWriter = jest.fn(); - mockExecutor.generateTransactions.mockRejectedValue(new Error('TX error')); + mockExecutor.generateTransactions.mockRejectedValue( + new Error('TX error') + ); await expect( handler.processWithSSEStreaming(executionContext, streamWriter) @@ -255,7 +259,9 @@ describe('UnifiedZapIntentHandler', () => { describe('getStatus', () => { test('should return handler status', () => { - jest.spyOn(handler.contextManager, 'getStatus').mockReturnValue({ active: 1 }); + jest + .spyOn(handler.contextManager, 'getStatus') + .mockReturnValue({ active: 1 }); handler.contextManager.executionContexts = new Map(); const status = handler.getStatus(); diff --git a/test/routes/tokens.test.js b/test/routes/tokens.test.js index 9028255..e8f26a0 100644 --- a/test/routes/tokens.test.js +++ b/test/routes/tokens.test.js @@ -20,7 +20,9 @@ describe('Token Routes', () => { // Mock TokenConfigService TokenConfigService.getZapTokens = jest.fn(); - TokenConfigService.getSupportedChains = jest.fn().mockReturnValue([1, 137, 42161, 8453]); + TokenConfigService.getSupportedChains = jest + .fn() + .mockReturnValue([1, 137, 42161, 8453]); }); describe('GET /tokens/zap/:chainId', () => { From f3ff00060b6e41e5e74fdbccefa111f0883c692f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 19:27:03 +0900 Subject: [PATCH 28/29] Swapped out the old express-validator dependency for fully in-house middleware: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrote the request guards in src/middleware/requestValidator.js:1-166 so both intent payloads and balance routes use local helpers, lowercase addresses, and return the same error payloads as before. - Replaced the validator arrays in src/utils/validation.js:1-220 with bespoke middleware for swap quotes, provider-specific data, and bulk price queries; routes (src/routes/swap.js:1-215) now call these functions directly and reuse the sanitized values. - Rebuilt the balance validator module as a pure utility (src/validators/balanceValidator.js:1-186) and updated its test suite accordingly (test/validators/balanceValidator.test.js:1-194) to exercise the new helpers and middleware integration. Swagger is now served from the prebuilt spec: - src/config/swaggerConfig.js:1-58 loads docs/swagger.json when present and falls back to the base definition without pulling in swagger-jsdoc. - Added scripts/ensureSwaggerSpec.js:1-17, and npm run docs:generate now just makes sure the spec file exists. Generating OpenAPI definitions from comments is manual going forward—edit docs/swagger.json or bolt in another generator if you need automation later. Housekeeping and verifications: - Dropped express-validator (and the validator override) from package.json:6-45, refreshed the lockfile, and tweaked the README to mention the custom middleware. - npm run lint, npm run test:coverage, and npm audit --audit-level=moderate all pass locally. --- README.md | 2 +- package-lock.json | 209 +----------- package.json | 4 +- scripts/ensureSwaggerSpec.js | 19 ++ src/config/swaggerConfig.js | 43 ++- src/middleware/requestValidator.js | 231 +++++++++----- src/routes/swap.js | 36 +-- src/utils/validation.js | 385 +++++++++++++++-------- src/validators/balanceValidator.js | 172 +++++----- test/validators/balanceValidator.test.js | 210 ++++--------- 10 files changed, 649 insertions(+), 662 deletions(-) create mode 100644 scripts/ensureSwaggerSpec.js diff --git a/README.md b/README.md index 1a6e96c..49e42bc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Node.js Express API server for intent-based DeFi operations, providing optimal - **Comprehensive Caching**: In-memory caching with configurable TTL for price data - **Multiple Price Sources**: CoinMarketCap, CoinGecko with extensible architecture - **Retry Logic**: Automatic retry with exponential backoff for failed requests -- **Input Validation**: Comprehensive parameter validation using express-validator +- **Input Validation**: Comprehensive parameter validation via custom middleware - **Error Handling**: Robust error handling with meaningful error messages - **CORS Support**: Configured for cross-origin requests - **Testing**: Comprehensive test suite with Jest and Supertest diff --git a/package-lock.json b/package-lock.json index 2a8002a..df99841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,7 @@ "dotenv": "^16.3.1", "ethers": "^6.15.0", "express": "^4.18.2", - "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^3.7.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -1493,6 +1491,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -1697,6 +1696,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { @@ -1740,6 +1740,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1847,12 +1848,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2152,6 +2147,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -2358,6 +2354,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -2870,6 +2867,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -2919,6 +2917,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -3107,19 +3106,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-validator": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", - "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "validator": "~13.12.0" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3329,6 +3315,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -3727,6 +3714,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4590,6 +4578,7 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -4626,18 +4615,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-ref-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.4.tgz", - "integrity": "sha512-AD7bvav0vak1/63w3jH8F7eHId/4E4EPdMAEZhGxtjktteUv9dnNB/cJy6nVnMyoTPBJnLwFK6tiQPSTeleCtQ==", - "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", - "license": "MIT", - "dependencies": { - "call-me-maybe": "^1.0.1", - "js-yaml": "^3.13.1", - "ono": "^6.0.0" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5082,26 +5059,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5436,6 +5393,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5630,6 +5588,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5651,27 +5610,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ono": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", - "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==", - "license": "MIT" - }, - "node_modules/openapi-schemas": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/openapi-schemas/-/openapi-schemas-1.0.3.tgz", - "integrity": "sha512-KtMWcK2VtOS+nD8RKSIyScJsj8JrmVWcIX7Kjx4xEHijFYuvMTDON8WfeKOgeSb4uNG6UsqLj5Na7nKbSav9RQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/openapi-types": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", - "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==", - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5800,6 +5738,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6641,6 +6580,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/stack-utils": { @@ -6876,90 +6816,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swagger-jsdoc": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-3.7.0.tgz", - "integrity": "sha512-K3R1NaP1CoWXeBp2F8Oh1vrtHRaDA2+pN17Ls/U1lHOtRlKtbtICwPKLRNOA2kDY0x2SXsCZisKiJlBStnv3yg==", - "license": "MIT", - "dependencies": { - "commander": "4.0.1", - "doctrine": "3.0.0", - "glob": "7.1.6", - "js-yaml": "3.13.1", - "swagger-parser": "8.0.4" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.0.1.tgz", - "integrity": "sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/swagger-methods": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/swagger-methods/-/swagger-methods-2.0.2.tgz", - "integrity": "sha512-/RNqvBZkH8+3S/FqBPejHxJxZenaYq3MrpeXnzi06aDIS39Mqf5YCUNb/ZBjsvFFt8h9FxfKs8EXPtcYdfLiRg==", - "deprecated": "This package is no longer being maintained.", - "license": "MIT" - }, - "node_modules/swagger-parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-8.0.4.tgz", - "integrity": "sha512-KGRdAaMJogSEB7sPKI31ptKIWX8lydEDAwWgB4pBMU7zys5cd54XNhoPSVlTxG/A3LphjX47EBn9j0dOGyzWbA==", - "license": "MIT", - "dependencies": { - "call-me-maybe": "^1.0.1", - "json-schema-ref-parser": "^7.1.3", - "ono": "^6.0.0", - "openapi-schemas": "^1.0.2", - "openapi-types": "^1.3.5", - "swagger-methods": "^2.0.1", - "z-schema": "^4.2.2" - } - }, "node_modules/swagger-ui-dist": { "version": "5.27.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.0.tgz", @@ -7195,15 +7051,6 @@ "node": ">=10.12.0" } }, - "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7271,6 +7118,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -7379,33 +7227,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/z-schema": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.4.tgz", - "integrity": "sha512-YvBeW5RGNeNzKOUJs3rTL4+9rpcvHXt5I051FJbOcitV8bl40pEfcG0Q+dWSwS0/BIYrMZ/9HHoqLllMkFhD0w==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.6.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=6.0.0" - }, - "optionalDependencies": { - "commander": "^2.7.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "optional": true } } } diff --git a/package.json b/package.json index 2830059..e28b42c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format:check": "prettier --check src/ test/ *.js *.json *.md", "quality": "npm run lint && npm run format:check && npm run test", "quality:fix": "npm run lint:fix && npm run format && npm run test", - "docs:generate": "node -e \"const { swaggerSpec } = require('./src/config/swaggerConfig'); const fs = require('fs'); if (!fs.existsSync('docs')) fs.mkdirSync('docs', { recursive: true }); fs.writeFileSync('docs/swagger.json', JSON.stringify(swaggerSpec, null, 2)); console.log('✅ Generated docs/swagger.json');\"", + "docs:generate": "node scripts/ensureSwaggerSpec.js", "docs:serve": "npm run docs:generate && npx swagger-ui-serve docs/swagger.json", "prepare": "husky" }, @@ -28,9 +28,7 @@ "dotenv": "^16.3.1", "ethers": "^6.15.0", "express": "^4.18.2", - "express-validator": "^7.0.1", "retry": "^0.13.1", - "swagger-jsdoc": "^3.7.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { diff --git a/scripts/ensureSwaggerSpec.js b/scripts/ensureSwaggerSpec.js new file mode 100644 index 0000000..c94a118 --- /dev/null +++ b/scripts/ensureSwaggerSpec.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); +const { swaggerOptions } = require('../src/config/swaggerConfig'); + +const outputPath = path.resolve(__dirname, '..', 'docs', 'swagger.json'); + +if (!fs.existsSync(outputPath)) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const baseSpec = { + ...swaggerOptions.definition, + paths: {}, + }; + fs.writeFileSync(outputPath, JSON.stringify(baseSpec, null, 2)); + console.log('✅ Initialized docs/swagger.json with base Swagger definition.'); +} else { + console.log( + 'ℹ️ docs/swagger.json already exists. Update manually as needed.' + ); +} diff --git a/src/config/swaggerConfig.js b/src/config/swaggerConfig.js index 586dde8..d59c74d 100644 --- a/src/config/swaggerConfig.js +++ b/src/config/swaggerConfig.js @@ -1,10 +1,7 @@ -const swaggerJsdoc = require('swagger-jsdoc'); +const fs = require('fs'); +const path = require('path'); const swaggerDefinitions = require('./swagger'); -/** - * Swagger configuration for Intent Engine API - * Refactored to use modular domain-specific definitions - */ const swaggerOptions = { definition: { openapi: '3.0.0', @@ -34,14 +31,38 @@ const swaggerOptions = { }, tags: swaggerDefinitions.tags, }, - apis: [ - './src/routes/*.js', // Path to the API docs - './src/app.js', // Main app file - ], + apis: ['./src/routes/*.js', './src/app.js'], }; -// Generate Swagger specification -const swaggerSpec = swaggerJsdoc(swaggerOptions); +const resolveSpecPath = () => + path.resolve(__dirname, '..', '..', 'docs', 'swagger.json'); + +const loadSwaggerSpec = () => { + const specPath = resolveSpecPath(); + + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (fs.existsSync(specPath)) { + // The spec path is project-internal and derived from __dirname + // eslint-disable-next-line security/detect-non-literal-fs-filename + const content = fs.readFileSync(specPath, 'utf8'); + return JSON.parse(content); + } + } catch (error) { + console.warn( + `Failed to load Swagger spec from ${specPath}:`, + error.message + ); + } + + // Fallback: return base definition without generated paths + return { + ...swaggerOptions.definition, + paths: {}, + }; +}; + +const swaggerSpec = loadSwaggerSpec(); module.exports = { swaggerOptions, diff --git a/src/middleware/requestValidator.js b/src/middleware/requestValidator.js index 367a62a..179215b 100644 --- a/src/middleware/requestValidator.js +++ b/src/middleware/requestValidator.js @@ -1,88 +1,163 @@ -const { body, param, query, validationResult } = require('express-validator'); - -const validateIntentRequest = [ - body('userAddress') - .isEthereumAddress() - .withMessage('Invalid userAddress: must be a valid Ethereum address'), - body('chainId') - .isInt({ gt: 0 }) - .withMessage('Invalid chainId: must be a positive integer'), - body('params').isObject().withMessage('params object is required'), - (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - error: { - code: 'INVALID_INPUT', - message: errors.array()[0].msg, - details: { - field: errors.array()[0].param, - value: errors.array()[0].value, - }, - }, - }); - } - next(); +const { isAddress } = require('ethers'); + +const buildErrorResponse = (message, field, value) => ({ + success: false, + error: { + code: 'INVALID_INPUT', + message, + details: { + field, + value, + }, }, -]; - -/** - * Validate balance request parameters - * Route: GET /api/v1/balances/:chainId/:address - */ -const validateBalanceRequest = [ - param('chainId') - .isInt({ gt: 0 }) - .withMessage('Invalid chainId: must be a positive integer'), - param('address') - .exists() - .withMessage('Invalid address: must be a valid Ethereum address') - .bail() - .isString() - .withMessage('Invalid address: must be a valid Ethereum address') - .matches(/^0[xX][a-fA-F0-9]{40}$/) - .withMessage('Invalid address: must be a valid Ethereum address') - .customSanitizer(value => value.toLowerCase()), - query('tokens') - .optional() - .isString() - .custom(value => { - // Validate comma-separated Ethereum addresses - const addresses = value +}); + +const parsePositiveInteger = value => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizeAddress = value => + typeof value === 'string' ? value.trim() : ''; + +const isEthereumAddress = value => { + if (!value || typeof value !== 'string') { + return false; + } + + try { + if (isAddress(value)) { + return true; + } + } catch { + // ignore and fall back to lowercase normalization + } + + try { + return isAddress(value.toLowerCase()); + } catch { + return false; + } +}; + +const validateIntentRequest = (req, res, next) => { + const { userAddress, chainId, params } = req.body || {}; + + const normalizedAddress = normalizeAddress(userAddress); + if (!normalizedAddress || !isAddress(normalizedAddress)) { + return res + .status(400) + .json( + buildErrorResponse( + 'Invalid userAddress: must be a valid Ethereum address', + 'userAddress', + userAddress + ) + ); + } + + const parsedChainId = parsePositiveInteger(chainId); + if (parsedChainId === null) { + return res + .status(400) + .json( + buildErrorResponse( + 'Invalid chainId: must be a positive integer', + 'chainId', + chainId + ) + ); + } + + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return res + .status(400) + .json(buildErrorResponse('params object is required', 'params', params)); + } + + req.body.userAddress = normalizedAddress; + req.body.chainId = parsedChainId; + + return next(); +}; + +const validateBalanceRequest = (req, res, next) => { + const errors = []; + + const chainId = parsePositiveInteger(req.params?.chainId); + if (chainId === null) { + errors.push({ + message: 'Invalid chainId: must be a positive integer', + field: 'chainId', + value: req.params?.chainId, + }); + } + + const address = normalizeAddress(req.params?.address); + if (!address || !isEthereumAddress(address)) { + errors.push({ + message: 'Invalid address: must be a valid Ethereum address', + field: 'address', + value: req.params?.address, + }); + } + + const tokensParam = req.query?.tokens; + if (tokensParam !== undefined) { + if (typeof tokensParam !== 'string') { + errors.push({ + message: 'tokens must be a comma-separated list of addresses', + field: 'tokens', + value: tokensParam, + }); + } else { + const addresses = tokensParam .split(',') - .map(addr => addr.trim()) + .map(token => token.trim()) .filter(Boolean); - const addressRegex = /^0x[a-fA-F0-9]{40}$/; - for (const addr of addresses) { - if (addr.toLowerCase() === 'native') { - continue; - } - if (!addressRegex.test(addr)) { - throw new Error(`Invalid token address: ${addr}`); + const invalid = addresses.find(token => { + if (token.toLowerCase() === 'native') { + return false; } - } - return true; - }), - (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - error: { - code: 'INVALID_INPUT', - message: errors.array()[0].msg, - details: { - field: errors.array()[0].param, - value: errors.array()[0].value, - }, - }, + return !/^0x[a-fA-F0-9]{40}$/.test(token); }); + + if (invalid) { + errors.push({ + message: `Invalid token address: ${invalid}`, + field: 'tokens', + value: tokensParam, + }); + } } - next(); - }, -]; + } + + if (errors.length > 0) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: errors[0].message, + details: errors[0], + }, + }); + } + + if (chainId !== null) { + req.params.chainId = chainId; + } + req.params.address = address.toLowerCase(); + + if (tokensParam !== undefined && typeof tokensParam === 'string') { + req.query.tokens = tokensParam; + } + + return next(); +}; module.exports = { validateIntentRequest, diff --git a/src/routes/swap.js b/src/routes/swap.js index c794537..a6e2fc5 100644 --- a/src/routes/swap.js +++ b/src/routes/swap.js @@ -4,7 +4,6 @@ const PriceService = require('../services/priceService'); const { swapQuoteValidation, bulkPricesValidation, - handleValidationErrors, validateTokenAddresses, } = require('../utils/validation'); @@ -134,7 +133,6 @@ const priceService = new PriceService(); router.get( '/swap/quote', swapQuoteValidation, - handleValidationErrors, validateTokenAddresses, async (req, res, next) => { try { @@ -280,31 +278,21 @@ router.get('/swap/providers', (req, res) => { * 500: * $ref: '#/components/responses/InternalServerError' */ -router.get( - '/tokens/prices', - bulkPricesValidation, - handleValidationErrors, - async (req, res, next) => { - try { - const { tokens, useCache = 'true', timeout = '5000' } = req.query; +router.get('/tokens/prices', bulkPricesValidation, async (req, res, next) => { + try { + const { tokens, useCache = true, timeout = 5000 } = req.query; - // Parse comma-separated tokens and clean up whitespace - const tokenSymbols = tokens - .split(',') - .map(token => token.trim()) - .filter(token => token); - const options = { - useCache: useCache === 'true', - timeout: parseInt(timeout), - }; + const options = { + useCache: Boolean(useCache), + timeout: Number.parseInt(timeout, 10), + }; - const result = await priceService.getBulkPrices(tokenSymbols, options); - res.json(result); - } catch (error) { - next(error); - } + const result = await priceService.getBulkPrices(tokens, options); + res.json(result); + } catch (error) { + next(error); } -); +}); /** * @swagger diff --git a/src/utils/validation.js b/src/utils/validation.js index 85344f3..593470e 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -1,150 +1,276 @@ -const { query, validationResult } = require('express-validator'); +const respondWithErrors = (res, errors) => + res.status(400).json({ + error: 'Validation failed', + details: errors, + }); -/** - * Validation rules for swap quote endpoint (aggregates all providers) - */ -const swapQuoteValidation = [ - query('chainId') - .notEmpty() - .withMessage('chainId is required') - .isString() - .withMessage('chainId must be a string'), - - query('fromTokenAddress') - .notEmpty() - .withMessage('fromTokenAddress is required') - .isString() - .withMessage('fromTokenAddress must be a string'), - - query('fromTokenDecimals') - .notEmpty() - .withMessage('fromTokenDecimals is required') - .isInt({ min: 0, max: 18 }) - .withMessage('fromTokenDecimals must be an integer between 0 and 18'), - - query('toTokenAddress') - .notEmpty() - .withMessage('toTokenAddress is required') - .isString() - .withMessage('toTokenAddress must be a string'), - - query('toTokenDecimals') - .notEmpty() - .withMessage('toTokenDecimals is required') - .isInt({ min: 0, max: 18 }) - .withMessage('toTokenDecimals must be an integer between 0 and 18'), - - query('amount') - .notEmpty() - .withMessage('amount is required') - .isString() - .withMessage('amount must be a string'), - - query('fromAddress') - .notEmpty() - .withMessage('fromAddress is required') - .isString() - .withMessage('fromAddress must be a string'), - - query('slippage') - .notEmpty() - .withMessage('slippage is required') - .isFloat({ min: 0, max: 100 }) - .withMessage('slippage must be a number between 0 and 100'), - - query('eth_price') - .optional() - .isFloat({ min: 0 }) - .withMessage('eth_price must be a positive number'), - - query('to_token_price') - .notEmpty() - .withMessage('to_token_price is required') - .isFloat({ min: 0 }) - .withMessage('to_token_price must be a positive number'), -]; +const isNonEmptyString = value => + typeof value === 'string' && value.trim() !== ''; -/** - * Validation rules for swap data endpoint with specific provider - */ -const swapDataValidation = [ - ...swapQuoteValidation, - query('provider') - .notEmpty() - .withMessage('provider is required') - .isIn(['1inch', 'paraswap', '0x']) - .withMessage('provider must be one of: 1inch, paraswap, 0x'), -]; +const ensureFloatInRange = (value, { min, max, message }) => { + if (!isNonEmptyString(value) && typeof value !== 'number') { + return { valid: false, parsed: null, error: message }; + } -/** - * Middleware to handle validation errors - */ -const handleValidationErrors = (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - error: 'Validation failed', - details: errors.array(), + const parsed = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(parsed)) { + return { valid: false, parsed: null, error: message }; + } + + if (min !== undefined && parsed < min) { + return { valid: false, parsed: null, error: message }; + } + if (max !== undefined && parsed > max) { + return { valid: false, parsed: null, error: message }; + } + + return { valid: true, parsed }; +}; + +const ensureIntInRange = (value, { min, max, message }) => { + if (!isNonEmptyString(value) && typeof value !== 'number') { + return { valid: false, parsed: null, error: message }; + } + + const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) { + return { valid: false, parsed: null, error: message }; + } + + if (min !== undefined && parsed < min) { + return { valid: false, parsed: null, error: message }; + } + + if (max !== undefined && parsed > max) { + return { valid: false, parsed: null, error: message }; + } + + return { valid: true, parsed }; +}; + +const sanitizeString = value => + typeof value === 'string' ? value.trim() : value; + +const swapQuoteValidation = (req, res, next) => { + const { query } = req; + const errors = []; + + const requiredStringFields = [ + ['chainId', 'chainId is required'], + ['fromTokenAddress', 'fromTokenAddress is required'], + ['toTokenAddress', 'toTokenAddress is required'], + ['amount', 'amount is required'], + ['fromAddress', 'fromAddress is required'], + ['to_token_price', 'to_token_price is required'], + ]; + + requiredStringFields.forEach(([field, message]) => { + if (!isNonEmptyString(query[field])) { + errors.push({ field, msg: message, value: query[field] }); + } else { + query[field] = sanitizeString(query[field]); + } + }); + + const decimalChecks = [ + [ + 'fromTokenDecimals', + 'fromTokenDecimals must be an integer between 0 and 18', + ], + ['toTokenDecimals', 'toTokenDecimals must be an integer between 0 and 18'], + ]; + + decimalChecks.forEach(([field, message]) => { + if (!isNonEmptyString(query[field])) { + errors.push({ field, msg: `${field} is required`, value: query[field] }); + return; + } + + const result = ensureIntInRange(query[field], { min: 0, max: 18, message }); + if (!result.valid) { + errors.push({ field, msg: message, value: query[field] }); + } + }); + + const slippageResult = ensureFloatInRange(query.slippage, { + min: 0, + max: 100, + field: 'slippage', + message: 'slippage must be a number between 0 and 100', + }); + if (!slippageResult.valid) { + errors.push({ + field: 'slippage', + msg: 'slippage is required', + value: query.slippage, + }); + } else if (Number.isNaN(slippageResult.parsed)) { + errors.push({ + field: 'slippage', + msg: 'slippage must be a number between 0 and 100', + value: query.slippage, }); } - next(); + + if ( + query.eth_price !== undefined && + query.eth_price !== null && + query.eth_price !== '' + ) { + const ethPriceResult = ensureFloatInRange(query.eth_price, { + min: 0, + message: 'eth_price must be a positive number', + }); + if (!ethPriceResult.valid) { + errors.push({ + field: 'eth_price', + msg: 'eth_price must be a positive number', + value: query.eth_price, + }); + } + } + + const toTokenPriceResult = ensureFloatInRange(query.to_token_price, { + min: 0, + message: 'to_token_price must be a positive number', + }); + if (!toTokenPriceResult.valid) { + errors.push({ + field: 'to_token_price', + msg: 'to_token_price must be a positive number', + value: query.to_token_price, + }); + } + + if (errors.length > 0) { + return respondWithErrors(res, errors); + } + + return next(); }; -/** - * Validation rules for bulk token prices endpoint - */ -const bulkPricesValidation = [ - query('tokens') - .notEmpty() - .withMessage('tokens parameter is required') - .custom(value => { - // Split by comma and clean up whitespace - const tokens = value - .split(',') - .map(token => token.trim()) - .filter(token => token); - - if (tokens.length === 0) { - throw new Error('tokens cannot be empty'); - } +const swapDataValidation = (req, res, next) => { + const providerAllowed = ['1inch', 'paraswap', '0x']; + const { provider } = req.query; + const errors = []; - if (tokens.length > 100) { - throw new Error('tokens cannot exceed 100 items'); - } + if (!isNonEmptyString(provider)) { + errors.push({ + field: 'provider', + msg: 'provider is required', + value: provider, + }); + } else if (!providerAllowed.includes(provider.trim())) { + errors.push({ + field: 'provider', + msg: 'provider must be one of: 1inch, paraswap, 0x', + value: provider, + }); + } else { + req.query.provider = provider.trim(); + } - // Validate each token symbol - for (const token of tokens) { - if (typeof token !== 'string' || token === '') { - throw new Error('all tokens must be non-empty strings'); - } + if (errors.length > 0) { + return respondWithErrors(res, errors); + } - // Basic token symbol validation (alphanumeric, dashes, underscores) + return swapQuoteValidation(req, res, next); +}; + +const bulkPricesValidation = (req, res, next) => { + const { tokens, useCache, timeout } = req.query; + const errors = []; + + if (!isNonEmptyString(tokens)) { + errors.push({ + field: 'tokens', + msg: 'tokens parameter is required', + value: tokens, + }); + } else { + const splitTokens = tokens + .split(',') + .map(token => token.trim()) + .filter(token => token); + + if (splitTokens.length === 0) { + errors.push({ + field: 'tokens', + msg: 'tokens cannot be empty', + value: tokens, + }); + } else if (splitTokens.length > 100) { + errors.push({ + field: 'tokens', + msg: 'tokens cannot exceed 100 items', + value: tokens, + }); + } else { + for (const token of splitTokens) { if (!/^[a-zA-Z0-9_-]+$/.test(token)) { - throw new Error( - `invalid token symbol: ${token}. Only alphanumeric characters, dashes, and underscores allowed` - ); + errors.push({ + field: 'tokens', + msg: `invalid token symbol: ${token}. Only alphanumeric characters, dashes, and underscores allowed`, + value: tokens, + }); + break; } - if (token.length > 20) { - throw new Error( - `token symbol too long: ${token}. Maximum 20 characters allowed` - ); + errors.push({ + field: 'tokens', + msg: `token symbol too long: ${token}. Maximum 20 characters allowed`, + value: tokens, + }); + break; } } + } + } - return true; - }), + if (useCache !== undefined) { + if (!['true', 'false', true, false].includes(useCache)) { + errors.push({ + field: 'useCache', + msg: 'useCache must be a boolean', + value: useCache, + }); + } + } - query('useCache') - .optional() - .isBoolean() - .withMessage('useCache must be a boolean'), + if (timeout !== undefined && timeout !== '') { + const timeoutResult = ensureIntInRange(timeout, { + min: 1000, + max: 30000, + message: 'timeout must be between 1000 and 30000 milliseconds', + }); + if (!timeoutResult.valid) { + errors.push({ + field: 'timeout', + msg: 'timeout must be between 1000 and 30000 milliseconds', + value: timeout, + }); + } + } - query('timeout') - .optional() - .isInt({ min: 1000, max: 30000 }) - .withMessage('timeout must be between 1000 and 30000 milliseconds'), -]; + if (errors.length > 0) { + return respondWithErrors(res, errors); + } + + req.query.tokens = tokens + .split(',') + .map(token => token.trim()) + .filter(token => token); + + if (typeof useCache === 'string') { + req.query.useCache = useCache === 'true'; + } + + if (timeout !== undefined && timeout !== '') { + req.query.timeout = Number.parseInt(timeout, 10); + } + + return next(); +}; /** * Validate that fromTokenAddress and toTokenAddress are different @@ -165,6 +291,5 @@ module.exports = { swapQuoteValidation, swapDataValidation, bulkPricesValidation, - handleValidationErrors, validateTokenAddresses, }; diff --git a/src/validators/balanceValidator.js b/src/validators/balanceValidator.js index cf5b773..9c5a7e7 100644 --- a/src/validators/balanceValidator.js +++ b/src/validators/balanceValidator.js @@ -4,12 +4,9 @@ * Validates: * - chainId: Must be one of the supported chains (1, 137, 42161, 8453, 10) * - wallet: Must be a valid Ethereum address - * - tokens: Optional array of valid token addresses - * - * Uses express-validator for validation rules. + * - tokens: Optional comma-separated list of valid token addresses */ -const { query, validationResult } = require('express-validator'); const { isAddress } = require('ethers'); /** @@ -25,6 +22,8 @@ const SUPPORTED_CHAINS = { const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS).map(Number); +const buildError = (field, message, value) => ({ field, message, value }); + /** * Custom validator: Check if value is a valid Ethereum address */ @@ -38,7 +37,7 @@ const isValidAddress = value => { return true; } } catch { - // no-op, fall back to lowercase normalization + // Ignore and fall back to lowercase check } try { @@ -71,71 +70,90 @@ const areValidAddresses = value => { return addresses.every(isValidAddress); }; +const normalizeTokens = value => + value + .split(',') + .map(addr => addr.trim().toLowerCase()) + .filter(Boolean); + /** - * Validation rules for balance endpoint + * Validate balance query parameters and return collected errors + normalized values. + * @param {Object} query + * @returns {{errors: Array, normalized: Object}} */ -const balanceValidationRules = [ - query('chainId') - .exists() - .withMessage('chainId is required') - .isInt() - .withMessage('chainId must be an integer') - .toInt() - .custom(value => SUPPORTED_CHAIN_IDS.includes(value)) - .withMessage( - `chainId must be one of: ${SUPPORTED_CHAIN_IDS.join(', ')} (${Object.entries( - SUPPORTED_CHAINS +const validateBalanceQuery = query => { + const errors = []; + const normalized = {}; + + const rawChainId = query?.chainId; + const parsedChainId = Number(rawChainId); + + if (!Number.isInteger(parsedChainId)) { + errors.push( + buildError('chainId', 'chainId must be an integer', rawChainId) + ); + } else if (!SUPPORTED_CHAIN_IDS.includes(parsedChainId)) { + const chainList = SUPPORTED_CHAIN_IDS.join(', '); + const chainDescriptions = Object.entries(SUPPORTED_CHAINS) + .map(([id, name]) => `${id}=${name}`) + .join(', '); + errors.push( + buildError( + 'chainId', + `chainId must be one of: ${chainList} (${chainDescriptions})`, + rawChainId ) - .map(([id, name]) => `${id}=${name}`) - .join(', ')})` - ), - - query('wallet') - .exists() - .withMessage('wallet address is required') - .trim() - .notEmpty() - .withMessage('wallet address cannot be empty') - .custom(isValidAddress) - .withMessage('wallet must be a valid Ethereum address (0x... format)') - .customSanitizer(value => value.toLowerCase()), - - query('tokens') - .optional() - .trim() - .custom(areValidAddresses) - .withMessage( - 'tokens must be a comma-separated list of valid Ethereum addresses (max 50 tokens)' - ) - .customSanitizer(value => { - if (!value) { - return undefined; - } - // Normalize to lowercase array - return value - .split(',') - .map(addr => addr.trim().toLowerCase()) - .filter(Boolean); - }), -]; + ); + } else { + normalized.chainId = parsedChainId; + } -/** - * Middleware to handle validation errors - */ -const handleValidationErrors = (req, res, next) => { - const errors = validationResult(req); + const rawWallet = + typeof query?.wallet === 'string' ? query.wallet.trim() : ''; + if (!rawWallet) { + errors.push( + buildError('wallet', 'wallet address is required', query?.wallet) + ); + } else if (!isValidAddress(rawWallet)) { + errors.push( + buildError( + 'wallet', + 'wallet must be a valid Ethereum address (0x... format)', + query?.wallet + ) + ); + } else { + normalized.wallet = rawWallet.toLowerCase(); + } - if (!errors.isEmpty()) { - const formattedErrors = errors.array().map(err => ({ - field: err.path || err.param, - message: err.msg, - value: err.value, - })); + if (query?.tokens !== undefined) { + const tokensValue = + typeof query.tokens === 'string' ? query.tokens.trim() : ''; + + if (tokensValue && !areValidAddresses(tokensValue)) { + errors.push( + buildError( + 'tokens', + 'tokens must be a comma-separated list of valid Ethereum addresses (max 50 tokens)', + query.tokens + ) + ); + } else if (tokensValue) { + normalized.tokens = normalizeTokens(tokensValue); + } + } + + return { errors, normalized }; +}; +const balanceValidator = (req, res, next) => { + const { errors, normalized } = validateBalanceQuery(req.query || {}); + + if (errors.length > 0) { return res.status(400).json({ error: 'Validation Error', message: 'Invalid request parameters', - errors: formattedErrors, + errors, details: { supportedChains: SUPPORTED_CHAINS, example: { @@ -148,30 +166,26 @@ const handleValidationErrors = (req, res, next) => { }); } + if (normalized.chainId !== undefined) { + req.query.chainId = normalized.chainId; + } + + if (normalized.wallet) { + req.query.wallet = normalized.wallet; + } + + if (Array.isArray(normalized.tokens)) { + req.query.tokens = normalized.tokens.join(','); + req.query.tokensList = normalized.tokens; + } + next(); }; -/** - * Combined validation middleware for balance endpoint - * - * Usage: - * router.get('/balance', balanceRateLimit, balanceValidator, getBalance); - * - * Query Parameters: - * - chainId: number (required) - One of 1, 10, 137, 8453, 42161 - * - wallet: string (required) - Valid Ethereum address - * - tokens: string (optional) - Comma-separated list of token addresses - * - * Example: - * GET /balance?chainId=1&wallet=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb&tokens=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 - */ -const balanceValidator = [...balanceValidationRules, handleValidationErrors]; - module.exports = balanceValidator; // Export for testing and reuse -module.exports.balanceValidationRules = balanceValidationRules; -module.exports.handleValidationErrors = handleValidationErrors; +module.exports.validateBalanceQuery = validateBalanceQuery; module.exports.SUPPORTED_CHAINS = SUPPORTED_CHAINS; module.exports.SUPPORTED_CHAIN_IDS = SUPPORTED_CHAIN_IDS; module.exports.isValidAddress = isValidAddress; diff --git a/test/validators/balanceValidator.test.js b/test/validators/balanceValidator.test.js index 1666795..d1a0f66 100644 --- a/test/validators/balanceValidator.test.js +++ b/test/validators/balanceValidator.test.js @@ -2,8 +2,9 @@ * Tests for Balance Validator Middleware */ +const balanceValidator = require('../../src/validators/balanceValidator'); const { - balanceValidationRules, + validateBalanceQuery, SUPPORTED_CHAIN_IDS, SUPPORTED_CHAINS, isValidAddress, @@ -91,178 +92,103 @@ describe('Supported Chains', () => { }); describe('Balance Validator Integration', () => { - // Mock express-validator for integration tests - const mockValidate = (rules, req) => { - // Simplified mock - in real tests, use express-validator's test utilities - const errors = []; + const runValidation = query => validateBalanceQuery(query).errors; - // Manually check chainId - if (!req.query.chainId) { - errors.push({ path: 'chainId', msg: 'chainId is required' }); - } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { - errors.push({ - path: 'chainId', - msg: 'chainId must be one of supported chains', - }); - } - - // Manually check wallet - if (!req.query.wallet) { - errors.push({ path: 'wallet', msg: 'wallet address is required' }); - } else if (!isValidAddress(req.query.wallet)) { - errors.push({ - path: 'wallet', - msg: 'wallet must be a valid Ethereum address', - }); - } - - // Manually check tokens (optional) - if (req.query.tokens && !areValidAddresses(req.query.tokens)) { - errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); - } - - return errors; - }; - - it('should validate correct request', async () => { - const req = { - query: { - chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); + it('should validate correct request', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); expect(errors).toHaveLength(0); }); - it('should require chainId', async () => { - const req = { - query: { - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'chainId')).toBe(true); + it('should require chainId', () => { + const errors = runValidation({ + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); + expect(errors.some(e => e.field === 'chainId')).toBe(true); }); - it('should require wallet', async () => { - const req = { - query: { - chainId: '1', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'wallet')).toBe(true); + it('should require wallet', () => { + const errors = runValidation({ + chainId: '1', + }); + expect(errors.some(e => e.field === 'wallet')).toBe(true); }); - it('should reject unsupported chainId', async () => { - const req = { - query: { - chainId: '999', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', - }, - }; + it('should reject unsupported chainId', () => { + const errors = runValidation({ + chainId: '999', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }); + expect(errors.some(e => e.field === 'chainId')).toBe(true); + }); - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'chainId')).toBe(true); + it('should reject invalid wallet address', () => { + const errors = runValidation({ + chainId: '1', + wallet: 'not-a-valid-address', + }); + expect(errors.some(e => e.field === 'wallet')).toBe(true); }); - it('should reject invalid wallet address', async () => { - const req = { - query: { - chainId: '1', - wallet: 'not-a-valid-address', - }, - }; + it('should validate optional tokens parameter', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + tokens: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', + }); + expect(errors).toHaveLength(0); + }); - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'wallet')).toBe(true); + it('should reject invalid token addresses', () => { + const errors = runValidation({ + chainId: '1', + wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + expect(errors.some(e => e.field === 'tokens')).toBe(true); }); - it('should validate optional tokens parameter', async () => { + it('should integrate with middleware and sanitize values', () => { const req = { query: { - chainId: '1', + chainId: '42161', wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', tokens: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xdAC17F958D2ee523a2206206994597C13D831ec7', }, }; - const errors = await mockValidate(balanceValidationRules, req); - expect(errors).toHaveLength(0); - }); - - it('should reject invalid token addresses', async () => { - const req = { - query: { - chainId: '1', - wallet: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', - tokens: 'invalid-token,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - }, + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), }; - const errors = await mockValidate(balanceValidationRules, req); - expect(errors.some(e => e.path === 'tokens')).toBe(true); - }); + const next = jest.fn(); - it('should validate all supported chains', async () => { - const wallet = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; + balanceValidator(req, res, next); - for (const chainId of SUPPORTED_CHAIN_IDS) { - const req = { query: { chainId: chainId.toString(), wallet } }; - const errors = await mockValidate(balanceValidationRules, req); - expect(errors).toHaveLength(0); - } + expect(next).toHaveBeenCalled(); + expect(req.query.chainId).toBe(42161); + expect(req.query.wallet).toBe('0x742d35cc6634c0532925a3b844bc9e7595f0beb0'); + expect(req.query.tokensList).toEqual([ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ]); }); }); describe('Error Message Quality', () => { - it('should provide helpful error messages', async () => { - const req = { - query: { - chainId: '999', - wallet: 'invalid', - }, - }; - - const errors = await mockValidate(balanceValidationRules, req); + it('should provide helpful error messages', () => { + const { errors } = validateBalanceQuery({ + chainId: '999', + wallet: 'invalid', + }); - // Errors should be descriptive expect(errors.length).toBeGreaterThan(0); - expect(errors.some(e => e.msg.includes('chainId'))).toBe(true); - expect(errors.some(e => e.msg.includes('wallet'))).toBe(true); + expect(errors.some(e => e.message.includes('chainId'))).toBe(true); + expect(errors.some(e => e.message.includes('wallet'))).toBe(true); }); }); - -// Helper function for mock validation -function mockValidate(rules, req) { - const errors = []; - - if (!req.query.chainId) { - errors.push({ path: 'chainId', msg: 'chainId is required' }); - } else if (!SUPPORTED_CHAIN_IDS.includes(parseInt(req.query.chainId))) { - errors.push({ - path: 'chainId', - msg: 'chainId must be one of supported chains', - }); - } - - if (!req.query.wallet) { - errors.push({ path: 'wallet', msg: 'wallet address is required' }); - } else if (!isValidAddress(req.query.wallet)) { - errors.push({ - path: 'wallet', - msg: 'wallet must be a valid Ethereum address', - }); - } - - if (req.query.tokens && !areValidAddresses(req.query.tokens)) { - errors.push({ path: 'tokens', msg: 'tokens must be valid addresses' }); - } - - return errors; -} From 021eb12ac59a2a41d137a6f4a6a992fddd0e9700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=B5=E6=B3=B0=E7=91=8B=28Chang=20Tai=20Wei=29?= Date: Tue, 14 Oct 2025 22:40:14 +0900 Subject: [PATCH 29/29] fixCI: add env --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6dd8b5..882b7f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,9 @@ on: branches: [main, develop] pull_request: branches: [main, develop] - +env: + COINMARKETCAP_API_KEY: ${{ secrets.COINMARKETCAP_API_KEY }} + MORALIS_API_KEY: ${{ secrets.MORALIS_API_KEY }} jobs: quality: runs-on: ubuntu-latest