diff --git a/script/tasks/LTM_DelegateNodes.s.sol b/script/tasks/LTM_DelegateNodes.s.sol index 07374ad9..a9666f02 100644 --- a/script/tasks/LTM_DelegateNodes.s.sol +++ b/script/tasks/LTM_DelegateNodes.s.sol @@ -29,11 +29,9 @@ contract DelegateNodes is Script, Test { string memory configPath = string(bytes(string.concat("script/outputs", configFileName))); string memory config = vm.readFile(configPath); - address liquidTokenManageraddress = stdJson.readAddress( - config, - ".contractDeployments.proxy.liquidTokenManager.address" + LiquidTokenManager liquidTokenManager = LiquidTokenManager( + payable(stdJson.readAddress(config, ".contractDeployments.proxy.liquidTokenManager.address")) ); - LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); // Create default signatures and salts if empty arrays are provided ISignatureUtilsMixinTypes.SignatureWithExpiry[] memory signatures; @@ -58,4 +56,4 @@ contract DelegateNodes is Script, Test { liquidTokenManager.delegateNodes(nodeIds, operators, signatures, salts); vm.stopBroadcast(); } -} +} \ No newline at end of file diff --git a/script/tasks/LTM_StakeAssetsToNode.s.sol b/script/tasks/LTM_StakeAssetsToNode.s.sol index 46ff56ec..ff120922 100644 --- a/script/tasks/LTM_StakeAssetsToNode.s.sol +++ b/script/tasks/LTM_StakeAssetsToNode.s.sol @@ -28,14 +28,14 @@ contract StakeAssetsToNode is Script, Test { string memory configPath = string(bytes(string.concat("script/outputs", configFileName))); string memory config = vm.readFile(configPath); - address liquidTokenManageraddress = stdJson.readAddress( - config, - ".contractDeployments.proxy.liquidTokenManager.address" + address payable liquidTokenManageraddress = payable( + stdJson.readAddress(config, ".contractDeployments.proxy.liquidTokenManager.address") ); + LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); vm.startBroadcast(); liquidTokenManager.stakeAssetsToNode(nodeId, assets, amounts); vm.stopBroadcast(); } -} +} \ No newline at end of file diff --git a/script/tasks/LTM_StakeAssetsToNodes.s.sol b/script/tasks/LTM_StakeAssetsToNodes.s.sol index 00c6adb3..c7e16000 100644 --- a/script/tasks/LTM_StakeAssetsToNodes.s.sol +++ b/script/tasks/LTM_StakeAssetsToNodes.s.sol @@ -21,9 +21,8 @@ contract StakeAssetsToNodes is Script, Test { string memory configPath = string(bytes(string.concat("script/outputs", configFileName))); string memory config = vm.readFile(configPath); - address liquidTokenManageraddress = stdJson.readAddress( - config, - ".contractDeployments.proxy.liquidTokenManager.address" + address payable liquidTokenManageraddress = payable( + stdJson.readAddress(config, ".contractDeployments.proxy.liquidTokenManager.address") ); LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); @@ -31,4 +30,4 @@ contract StakeAssetsToNodes is Script, Test { liquidTokenManager.stakeAssetsToNodes(allocations); vm.stopBroadcast(); } -} +} \ No newline at end of file diff --git a/script/tasks/LTM_SwapAndStakeAssetsToNode.s.sol b/script/tasks/LTM_SwapAndStakeAssetsToNode.s.sol new file mode 100644 index 00000000..e69de29b diff --git a/src/FinalAutoRouting.sol b/src/FinalAutoRouting.sol new file mode 100644 index 00000000..d6414294 --- /dev/null +++ b/src/FinalAutoRouting.sol @@ -0,0 +1,3115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol";//below 5 path (FAR PRODUCT is 5) +import "@openzeppelin/contracts/security/Pausable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Interfaces +interface IWETH { + function deposit() external payable; + function withdraw(uint256) external; + function balanceOf(address) external view returns (uint256); + function approve(address, uint256) external returns (bool); +} + +interface IUniswapV3Router { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +interface IUniswapV3Quoter { + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountOut); +} + +interface ICurvePool { + function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256); + function get_dy_underlying(int128 i, int128 j, uint256 dx) external view returns (uint256); +} + +interface IFrxETHMinter { + function submitAndDeposit(address recipient) external payable returns (uint256); +} + +/** + * @title FinalAutoRouting + * @notice Intelligent routing system that provides execution data without holding assets + * @dev FAR acts as a guide for LTM, never touching tokens directly + */ +contract FinalAutoRouting is AccessControl, ReentrancyGuard, Pausable { + using SafeERC20 for IERC20; + + // ============================================================================ + // CONSTANTS & IMMUTABLES + // ============================================================================ + + IWETH public immutable WETH; + IUniswapV3Router public immutable uniswapRouter; + IUniswapV3Quoter public immutable uniswapQuoter; + IFrxETHMinter public immutable frxETHMinter; + bytes32 private immutable ROUTE_PASSWORD_HASH; + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 private constant POOL_INIT_CODE_HASH = 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54; + + uint256 public constant MAX_SLIPPAGE = 2000; + address public constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address public constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + address public constant SFRXETH = 0xac3E018457B222d93114458476f3E3416Abbe38F; + address public constant FRXETH = 0x5E8422345238F34275888049021821E8E08CAa1f; + address public constant RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + address public constant OSETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + address private constant UNISWAP_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + + uint256 public constant TIGHT_BUFFER_BPS = 20; + uint256 public constant QUOTE_MAX_AGE = 30; + uint256 public constant MAX_CURVE_TOKENS = 8; + uint256 public constant EXTERNAL_CALL_GAS_LIMIT = 300000; + uint256 public constant MAX_DEX_GAS_LIMIT = 500000; + uint256 public constant MAX_MULTI_STEP_OPERATIONS = 5; + uint256 public constant DEX_TIMELOCK = 24 hours; + + // ============================================================================ + // STATE VARIABLES + // ============================================================================ + + bool private initialized; + address public routeManager; + + // Mappings + mapping(address => mapping(address => uint256)) public slippageTolerance; + mapping(address => AssetType) public assetTypes; + mapping(address => bool) public poolWhitelist; + mapping(address => bool) public farSupportedTokens; + mapping(address => bool) public poolPaused; + mapping(Protocol => bool) public protocolPaused; + mapping(address => uint8) public tokenDecimals; + mapping(address => uint256) public curvePoolTokenCounts; + mapping(address => CurveInterface) public curvePoolInterfaces; + mapping(bytes32 => RouteConfig) public routes; + mapping(address => bool) public registeredDEXes; + mapping(address => string) public dexNames; + mapping(bytes4 => bool) public dangerousSelectors; + mapping(bytes4 => bool) public whitelistedSelectors; + mapping(bytes4 => string) public selectorDescriptions; + mapping(address => uint256) public dexRegistrationTime; + mapping(address => address) public dexRegisteredBy; + + address[] public allRegisteredDEXes; + bytes4[] public allDangerousSelectors; + bytes4[] public allWhitelistedSelectors; + + // ============================================================================ + // ENUMS & STRUCTS + // ============================================================================ + + enum Protocol { + UniswapV3, + Curve, + DirectMint, + MultiHop, + MultiStep + } + + enum AssetType { + STABLE, + ETH_LST, + BTC_WRAPPED, + VOLATILE + } + + enum CurveInterface { + None, + Exchange, + ExchangeUnderlying, + Both + } + + enum RouteType { + Direct, + Reverse, + Bridge + } + + enum SlippageType { + QUOTE, + FALLBACK + } + + struct QuoteData { + uint256 expectedOutput; + uint256 timestamp; + bool valid; + } + + struct SwapParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + Protocol protocol; + bytes routeData; + } + + struct UniswapV3Route { + address pool; + uint24 fee; + bool isMultiHop; + bytes path; + } + + struct CurveRoute { + address pool; + int128 indexIn; + int128 indexOut; + bool useUnderlying; + } + + struct RouteConfig { + Protocol protocol; + address pool; + uint24 fee; + bool directSwap; + bytes path; + int128 tokenIndexIn; + int128 tokenIndexOut; + bool useUnderlying; + address specialContract; + bool isConfigured; + bytes routeData; + } + + struct ExecutionStrategy { + RouteType routeType; + Protocol protocol; + address bridgeAsset; + bytes primaryRouteData; + bytes secondaryRouteData; + uint256 expectedGas; + } + + struct ExecutionStep { + address target; + uint256 value; + bytes data; + address tokenIn; + address tokenOut; + bool requiresApproval; + bool isCurvePool; + } + + struct SlippageConfig { + address tokenIn; + address tokenOut; + uint256 slippageBps; + } + + struct SwapStep { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + address target; + bytes data; + uint256 value; + Protocol protocol; + } + + struct MultiStepExecutionPlan { + SwapStep[] steps; + uint256 expectedFinalAmount; + } + + // ============================================================================ + // EVENTS + // ============================================================================ + + event ExecutionDataGenerated( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + Protocol protocol, + uint256 timestamp + ); + event RouteConfigured(address indexed tokenIn, address indexed tokenOut, Protocol protocol, address pool); + event SlippageConfigured( + address indexed tokenIn, + address indexed tokenOut, + uint256 slippageBps, + address indexed configuredBy, + uint256 timestamp + ); + event PoolWhitelisted( + address indexed pool, + bool status, + CurveInterface curveInterface, + address indexed updatedBy, + uint256 timestamp + ); + event TokenSupported(address indexed token, bool status, AssetType assetType, uint8 decimals, uint256 timestamp); + event DexRegistered(address indexed dex, string name, address indexed registeredBy, uint256 timestamp); + event DexUnregistered(address indexed dex, address indexed unregisteredBy, uint256 timestamp); + event SelectorWhitelisted(bytes4 indexed selector, string description, uint256 timestamp); + event SelectorBlacklisted(bytes4 indexed selector, string reason, uint256 timestamp); + event MultiStepPlanGenerated( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 stepCount + ); + // ============================================================================ + // ERRORS + // ============================================================================ + + error UnauthorizedCaller(); + error InvalidProtocol(); + error InsufficientOutput(); + error SwapFailed(string reason); + error InvalidSlippage(); + error ZeroAmount(); + error PoolNotWhitelisted(); + error TokenNotSupported(); + error PoolIsPaused(); + error ProtocolIsPaused(); + error InvalidParameter(string parameter); + error AlreadyInitialized(); + error NotInitialized(); + error InvalidAddress(); + error InvalidRoutePassword(); + error NoRouteFound(); + error InvalidDecimals(); + error TransferFailed(); + error SameTokenSwap(); + error QuoteTooOld(); + error NoConfigSlippage(); + error UnsupportedRoute(); + + // ============================================================================ + // MODIFIERS + // ============================================================================ + + modifier onlyAuthorizedCaller() { + require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender) || hasRole(OPERATOR_ROLE, msg.sender), "Unauthorized"); + _; + } + + modifier onlyRouteManager() { + require(msg.sender == routeManager || hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "Unauthorized route manager"); + _; + } + + // ============================================================================ + // CONSTRUCTOR + // ============================================================================ + + constructor( + address _weth, + address _uniswapRouter, + address _uniswapQuoter, + address _frxETHMinter, + address _routeManager, + bytes32 _routePasswordHash, + address _liquidTokenManager, + bool _initializeProduction + ) { + if (_weth == address(0)) revert InvalidAddress(); + if (_uniswapRouter == address(0)) revert InvalidAddress(); + if (_uniswapQuoter == address(0)) revert InvalidAddress(); + if (_frxETHMinter == address(0)) revert InvalidAddress(); + if (_routeManager == address(0)) revert InvalidAddress(); + if (_liquidTokenManager == address(0)) revert InvalidAddress(); + if (_routePasswordHash == bytes32(0)) revert InvalidParameter("routePasswordHash"); + + WETH = IWETH(_weth); + uniswapRouter = IUniswapV3Router(_uniswapRouter); + uniswapQuoter = IUniswapV3Quoter(_uniswapQuoter); + frxETHMinter = IFrxETHMinter(_frxETHMinter); + routeManager = _routeManager; + ROUTE_PASSWORD_HASH = _routePasswordHash; + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(OPERATOR_ROLE, _liquidTokenManager); + + if (_initializeProduction) { + _applyProductionSlippageConfig(); + } + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + function initialize( + address[] calldata tokenAddresses, + AssetType[] calldata tokenTypes, + uint8[] calldata decimals, + address[] calldata poolAddresses, + uint256[] calldata poolTokenCounts, + CurveInterface[] calldata curveInterfaces, + SlippageConfig[] calldata slippageConfigs + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (initialized) revert AlreadyInitialized(); + + // Validate arrays + if (tokenAddresses.length != tokenTypes.length) revert InvalidParameter("tokenArrays"); + if (tokenAddresses.length != decimals.length) revert InvalidParameter("decimalsArray"); + if (poolAddresses.length != poolTokenCounts.length) revert InvalidParameter("poolArrays"); + if (poolAddresses.length != curveInterfaces.length) revert InvalidParameter("interfaceArrays"); + + // Configure tokens + for (uint256 i = 0; i < tokenAddresses.length; ++i) { + address token = tokenAddresses[i]; + if (token == address(0)) revert InvalidAddress(); + + farSupportedTokens[token] = true; + assetTypes[token] = tokenTypes[i]; + tokenDecimals[token] = decimals[i]; + + emit TokenSupported(token, true, tokenTypes[i], decimals[i], block.timestamp); + } + + // Configure pools + for (uint256 i = 0; i < poolAddresses.length; ++i) { + address pool = poolAddresses[i]; + if (pool == address(0)) revert InvalidAddress(); + + poolWhitelist[pool] = true; + curvePoolTokenCounts[pool] = poolTokenCounts[i]; + curvePoolInterfaces[pool] = curveInterfaces[i]; + + emit PoolWhitelisted(pool, true, curveInterfaces[i], msg.sender, block.timestamp); + } + + // Configure slippage + for (uint256 i = 0; i < slippageConfigs.length; ++i) { + SlippageConfig memory config = slippageConfigs[i]; + slippageTolerance[config.tokenIn][config.tokenOut] = config.slippageBps; + + emit SlippageConfigured(config.tokenIn, config.tokenOut, config.slippageBps, msg.sender, block.timestamp); + } + + initialized = true; + } + + // ============================================================================ + // MAIN FUNCTIONS FOR LTM INTEGRATION + // ============================================================================ + /** + * @notice Get accurate quote and execution data for LTM - THIS IS THE MAIN FUNCTION + * @dev Returns executable calldata that LTM can use directly without FAR touching assets + * @param tokenIn Input token address + * @param tokenOut Output token address + * @param amountIn Input amount + * @param recipient The final recipient of tokens (usually LTM) + * @return quotedAmount The accurate quoted output amount + * @return executionData The calldata for LTM to execute directly on DEX + * @return protocol The protocol to use + * @return targetContract The DEX contract LTM should call + * @return value ETH value to send (if ETH swap) + */ + function getQuoteAndExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedAmount, + bytes memory executionData, + Protocol protocol, + address targetContract, + uint256 value + ) + { + if (amountIn == 0) revert ZeroAmount(); + if (tokenIn == tokenOut) revert SameTokenSwap(); + if (!farSupportedTokens[tokenIn] && tokenIn != ETH_ADDRESS) revert TokenNotSupported(); + if (!farSupportedTokens[tokenOut] && tokenOut != ETH_ADDRESS) revert TokenNotSupported(); + if (recipient == address(0)) revert InvalidAddress(); + + // Validate cross-category early + if (_isCrossCategory(tokenIn, tokenOut)) { + revert NoRouteFound(); + } + + // Find optimal strategy + ExecutionStrategy memory strategy = _findOptimalExecutionStrategy(tokenIn, tokenOut, amountIn, 0); + + // Get quote with proper fallback mechanism + uint256 minAmountOut; + + if (strategy.protocol == Protocol.MultiStep) { + // Special handling for multi-step + (address[] memory tokens, Protocol[] memory protocols, bytes[] memory routeDatas, ) = abi.decode( + strategy.primaryRouteData, + (address[], Protocol[], bytes[], uint256[]) + ); + + // Calculate dynamic min amounts for all steps + uint256[] memory calculatedMinAmounts; + (calculatedMinAmounts, quotedAmount) = _calculateMultiStepMinAmounts( + tokens, + amountIn, + protocols, + routeDatas + ); + + // Use the final step's minimum as overall minimum + minAmountOut = calculatedMinAmounts[calculatedMinAmounts.length - 1]; + + // Update strategy with calculated amounts + strategy.primaryRouteData = abi.encode(tokens, protocols, routeDatas, calculatedMinAmounts); + } else { + // Standard quote for single/bridge routes + (quotedAmount, minAmountOut) = _getQuoteWithFallback(tokenIn, tokenOut, amountIn, strategy); + } + + // Generate execution data based on route type + if (strategy.routeType == RouteType.Bridge) { + // For bridge routes, calculate first leg minimum + (uint256 firstLegQuote, uint256 firstLegMin) = _getQuoteWithFallback( + tokenIn, + strategy.bridgeAsset, + amountIn, + ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: strategy.protocol, + bridgeAsset: address(0), + primaryRouteData: strategy.primaryRouteData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(strategy.protocol) + }) + ); + + // Get first step execution data + (executionData, targetContract) = _generateDirectExecutionData( + ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: strategy.protocol, + bridgeAsset: address(0), + primaryRouteData: strategy.primaryRouteData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(strategy.protocol) + }), + tokenIn, + strategy.bridgeAsset, + amountIn, + firstLegMin, // Use calculated minimum for first leg + recipient + ); + + // Wrap with bridge metadata for LTM + executionData = abi.encode( + uint8(2), // Bridge flag + targetContract, + executionData, + strategy.bridgeAsset, + tokenOut, + minAmountOut // This is the overall minimum for the entire route + ); + + protocol = Protocol.MultiStep; // LTM treats bridge as MultiStep + } else if (strategy.protocol == Protocol.MultiStep) { + // Multi-step: get first execution with calculated minimums + ( + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas, + uint256[] memory minAmounts + ) = abi.decode(strategy.primaryRouteData, (address[], Protocol[], bytes[], uint256[])); + + ExecutionStrategy memory firstStep = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[0], + bridgeAsset: address(0), + primaryRouteData: routeDatas[0], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[0]) + }); + + (bytes memory firstExecution, address firstTarget) = _generateDirectExecutionData( + firstStep, + tokens[0], + tokens[1], + amountIn, + minAmounts[0], // Use calculated minimum + recipient + ); + + targetContract = firstTarget; + + // Wrap with multi-step metadata + executionData = abi.encode( + uint8(3), // Multi-step flag + firstTarget, + firstExecution, + tokens, + protocols, + routeDatas, + minAmounts // Pass all calculated minimums + ); + + protocol = Protocol.MultiStep; + } else { + // Single step execution - direct DEX call + (executionData, targetContract) = _generateDirectExecutionData( + strategy, + tokenIn, + tokenOut, + amountIn, + minAmountOut, + recipient + ); + protocol = strategy.protocol; + } + + // Set ETH value if needed + value = (tokenIn == ETH_ADDRESS) ? amountIn : 0; + + emit ExecutionDataGenerated(tokenIn, tokenOut, amountIn, protocol, block.timestamp); + } + /** + * @notice Get complete swap execution plan for LTM + * @dev Returns all necessary data for LTM to execute swap(s) blindly + */ + function getCompleteExecutionPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedOutput, + uint256 minAmountOut, + ExecutionStep[] memory steps, + uint256 totalGas, + uint256 ethValue + ) + { + // Find strategy + ExecutionStrategy memory strategy = _findOptimalExecutionStrategy(tokenIn, tokenOut, amountIn, 0); + + // Get quote with fallback + (quotedOutput, minAmountOut) = _getQuoteWithFallback(tokenIn, tokenOut, amountIn, strategy); + + // Build execution steps + if (strategy.routeType == RouteType.Bridge) { + steps = new ExecutionStep[](2); + + // First step: tokenIn -> bridgeAsset + (bytes memory data1, address target1) = _generateDirectExecutionData( + ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: strategy.protocol, + bridgeAsset: address(0), + primaryRouteData: strategy.primaryRouteData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(strategy.protocol) + }), + tokenIn, + strategy.bridgeAsset, + amountIn, + 0, // No min for intermediate + recipient + ); + + steps[0] = ExecutionStep({ + target: target1, + value: tokenIn == ETH_ADDRESS ? amountIn : 0, + data: data1, + tokenIn: tokenIn, + tokenOut: strategy.bridgeAsset, + requiresApproval: tokenIn != ETH_ADDRESS, + isCurvePool: strategy.protocol == Protocol.Curve + }); + + // Second step will be determined after first completes + steps[1] = ExecutionStep({ + target: address(0), // To be filled by LTM + value: 0, + data: "", + tokenIn: strategy.bridgeAsset, + tokenOut: tokenOut, + requiresApproval: true, + isCurvePool: false + }); + } else if (strategy.protocol == Protocol.MultiStep) { + // Decode multi-step + ( + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas, + uint256[] memory minAmounts + ) = abi.decode(strategy.primaryRouteData, (address[], Protocol[], bytes[], uint256[])); + + steps = new ExecutionStep[](protocols.length); + + // Build each step + for (uint256 i = 0; i < protocols.length; i++) { + ExecutionStrategy memory stepStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[i], + bridgeAsset: address(0), + primaryRouteData: routeDatas[i], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[i]) + }); + + (bytes memory data, address target) = _generateDirectExecutionData( + stepStrategy, + tokens[i], + tokens[i + 1], + i == 0 ? amountIn : 0, // Only first step has known input + minAmounts[i], + recipient + ); + + steps[i] = ExecutionStep({ + target: target, + value: (i == 0 && tokens[i] == ETH_ADDRESS) ? amountIn : 0, + data: data, + tokenIn: tokens[i], + tokenOut: tokens[i + 1], + requiresApproval: tokens[i] != ETH_ADDRESS, + isCurvePool: protocols[i] == Protocol.Curve + }); + } + } else { + // Single step + steps = new ExecutionStep[](1); + + (bytes memory data, address target) = _generateDirectExecutionData( + strategy, + tokenIn, + tokenOut, + amountIn, + minAmountOut, + recipient + ); + + steps[0] = ExecutionStep({ + target: target, + value: tokenIn == ETH_ADDRESS ? amountIn : 0, + data: data, + tokenIn: tokenIn, + tokenOut: tokenOut, + requiresApproval: tokenIn != ETH_ADDRESS, + isCurvePool: strategy.protocol == Protocol.Curve + }); + } + + // Calculate total gas + totalGas = strategy.expectedGas; + ethValue = tokenIn == ETH_ADDRESS ? amountIn : 0; + } + + /** + * @notice Get bridge route second leg execution data + * @dev Called by LTM after first swap completes - properly calculates second leg minimum + */ + function getBridgeSecondLegData( + address bridgeAsset, + address finalToken, + uint256 bridgeAmount, + uint256 originalMinOut, + address recipient + ) external returns (bytes memory executionData, address targetContract, bool requiresApproval) { + // Get the bridge->final route + bytes32 routeKey = keccak256(abi.encodePacked(bridgeAsset, finalToken)); + RouteConfig memory config = routes[routeKey]; + + require(config.isConfigured, "Bridge route not found"); + + // Generate execution data + ExecutionStrategy memory strategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: config.protocol, + bridgeAsset: address(0), + primaryRouteData: _encodeRouteData(config, bridgeAsset, finalToken), + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(config.protocol) + }); + + // Calculate proper minAmountOut for second leg based on actual bridge amount + (uint256 quotedAmount, uint256 secondLegMinOut) = _getQuoteWithFallback( + bridgeAsset, + finalToken, + bridgeAmount, + strategy + ); + + // Use the calculated min for second leg, but ensure it meets original requirement + uint256 effectiveMinOut = secondLegMinOut; + + // If the calculated second leg output is less than original, we need to ensure + // we still meet the original minimum requirement + if (secondLegMinOut < originalMinOut) { + effectiveMinOut = originalMinOut; + } + + (executionData, targetContract) = _generateDirectExecutionData( + strategy, + bridgeAsset, + finalToken, + bridgeAmount, + effectiveMinOut, + recipient + ); + + requiresApproval = bridgeAsset != ETH_ADDRESS; + } + /** + * @notice Get next step execution data for multi-step swaps + * @dev Called by LTM after completing previous step + */ + function getNextStepExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes calldata fullRouteData, + uint256 stepIndex, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool isFinalStep) { + // Decode the full route data + ( + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas, + uint256[] memory minAmounts + ) = abi.decode(fullRouteData, (address[], Protocol[], bytes[], uint256[])); + + require(stepIndex < protocols.length, "Invalid step index"); + + // Check if this is the final step + isFinalStep = (stepIndex == protocols.length - 1); + + // Generate execution data for this step + ExecutionStrategy memory stepStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[stepIndex], + bridgeAsset: address(0), + primaryRouteData: routeDatas[stepIndex], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[stepIndex]) + }); + + // Use actual recipient for final step, LTM address for intermediate steps + address stepRecipient = isFinalStep ? recipient : msg.sender; + + (executionData, targetContract) = _generateDirectExecutionData( + stepStrategy, + tokens[stepIndex], + tokens[stepIndex + 1], + amountIn, + minAmounts[stepIndex], + stepRecipient + ); + } + /** + * @notice Validate swap execution (view function for validation) + * @dev This can be called with staticcall for validation only + */ + function validateSwapExecution( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address executor + ) external view returns (bool isValid, string memory reason, uint256 estimatedOutput) { + // Token validation + if (!farSupportedTokens[tokenIn] && tokenIn != ETH_ADDRESS) { + return (false, "Input token not supported", 0); + } + if (!farSupportedTokens[tokenOut] && tokenOut != ETH_ADDRESS) { + return (false, "Output token not supported", 0); + } + + // Basic validation + if (amountIn == 0) { + return (false, "Zero amount", 0); + } + if (tokenIn == tokenOut) { + return (false, "Same token swap", 0); + } + // Check cross-category + if (_isCrossCategory(tokenIn, tokenOut)) { + return (false, "Cross-category swap forbidden", 0); + } + + // Find route + try this._findOptimalExecutionStrategyView(tokenIn, tokenOut, amountIn, minAmountOut) returns ( + ExecutionStrategy memory strategy + ) { + // Validate pools + if (strategy.protocol == Protocol.UniswapV3) { + UniswapV3Route memory route = abi.decode(strategy.primaryRouteData, (UniswapV3Route)); + if (!route.isMultiHop && route.pool != address(0) && !poolWhitelist[route.pool]) { + return (false, "Uniswap pool not whitelisted", 0); + } + } else if (strategy.protocol == Protocol.Curve) { + CurveRoute memory route = abi.decode(strategy.primaryRouteData, (CurveRoute)); + if (!poolWhitelist[route.pool]) { + return (false, "Curve pool not whitelisted", 0); + } + } + + // Estimate output using view-safe method + estimatedOutput = _estimateSwapOutputView(tokenIn, tokenOut, amountIn, strategy); + + if (estimatedOutput < minAmountOut) { + return (false, "Output below minimum", estimatedOutput); + } + + return (true, "Valid", estimatedOutput); + } catch { + return (false, "No route found", 0); + } + } + + /** + * @notice Generate swap execution data (view function) + * @dev Returns execution bytecode for LTM - can be called with staticcall + */ + function generateSwapExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) external view returns (bytes memory executionData, Protocol protocol, uint256 expectedGas) { + ExecutionStrategy memory strategy = _findOptimalExecutionStrategy(tokenIn, tokenOut, amountIn, minAmountOut); + + protocol = strategy.protocol; + expectedGas = strategy.expectedGas; + + // If minAmountOut is 0, calculate it from estimate + if (minAmountOut == 0) { + uint256 estimate = _estimateSwapOutputView(tokenIn, tokenOut, amountIn, strategy); + uint256 slippage = slippageTolerance[tokenIn][tokenOut]; + if (slippage == 0) revert NoConfigSlippage(); + minAmountOut = (estimate * (10000 - slippage)) / 10000; + } + + if (strategy.routeType == RouteType.Bridge) { + executionData = _generateComplexRouteData(strategy, tokenIn, tokenOut, amountIn, minAmountOut, recipient); + } else { + (executionData, ) = _generateDirectExecutionData( + strategy, + tokenIn, + tokenOut, + amountIn, + minAmountOut, + recipient + ); + } + } + + /** + * @notice Get WETH conversion instructions for LTM + * @dev Tells LTM when to wrap/unwrap ETH + */ + function getETHConversionData( + address tokenIn, + address tokenOut, + uint256 amount, + bool isInput + ) external view returns (bool needsConversion, bytes memory conversionData, address conversionTarget) { + if (isInput && tokenIn == ETH_ADDRESS) { + // Need to wrap ETH to WETH + needsConversion = true; + conversionTarget = address(WETH); + conversionData = abi.encodeWithSelector(IWETH.deposit.selector); + } else if (!isInput && tokenOut == ETH_ADDRESS) { + // Need to unwrap WETH to ETH + needsConversion = true; + conversionTarget = address(WETH); + conversionData = abi.encodeWithSelector(IWETH.withdraw.selector, amount); + } else { + needsConversion = false; + } + } + + /** + * @notice Check if swap needs WETH wrapping/unwrapping + */ + function getWETHRequirements( + address tokenIn, + address tokenOut, + Protocol protocol + ) external view returns (bool needsWrap, bool needsUnwrap, address wethAddress) { + wethAddress = address(WETH); + + // Check if we need to wrap ETH + if (tokenIn == ETH_ADDRESS && protocol != Protocol.Curve && protocol != Protocol.DirectMint) { + needsWrap = true; + } + + // Check if we need to unwrap to ETH + if (tokenOut == ETH_ADDRESS && protocol != Protocol.Curve && protocol != Protocol.DirectMint) { + needsUnwrap = true; + } + } + + /** + * @notice Get all possible routes for a token pair + */ + function getAllPossibleRoutes( + address tokenIn, + address tokenOut + ) + external + view + returns ( + bool hasDirect, + bool hasReverse, + bool hasBridge, + address bridgeAsset, + uint256 estimatedDirectGas, + uint256 estimatedBridgeGas + ) + { + bytes32 directKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + bytes32 reverseKey = keccak256(abi.encodePacked(tokenOut, tokenIn)); + + RouteConfig memory directRoute = routes[directKey]; + RouteConfig memory reverseRoute = routes[reverseKey]; + + hasDirect = directRoute.isConfigured; + hasReverse = reverseRoute.isConfigured; + + // Check bridge + bridgeAsset = _getBridgeAsset(tokenIn, tokenOut); + if (bridgeAsset != address(0)) { + bytes32 firstKey = keccak256(abi.encodePacked(tokenIn, bridgeAsset)); + bytes32 secondKey = keccak256(abi.encodePacked(bridgeAsset, tokenOut)); + hasBridge = routes[firstKey].isConfigured && routes[secondKey].isConfigured; + + if (hasBridge) { + estimatedBridgeGas = + _estimateGasForProtocol(routes[firstKey].protocol) + + _estimateGasForProtocol(routes[secondKey].protocol); + } + } + + if (hasDirect) { + estimatedDirectGas = _estimateGasForProtocol(directRoute.protocol); + } else if (hasReverse) { + estimatedDirectGas = _estimateGasForProtocol(reverseRoute.protocol); + } + } + + /** + * @notice Validate route configuration before execution + */ + function validateRouteConfiguration( + address tokenIn, + address tokenOut + ) external view returns (bool isValid, string memory error, uint256 configuredSlippage) { + // Check token support + if (!farSupportedTokens[tokenIn] && tokenIn != ETH_ADDRESS) { + return (false, "Input token not supported", 0); + } + + if (!farSupportedTokens[tokenOut] && tokenOut != ETH_ADDRESS) { + return (false, "Output token not supported", 0); + } + + // Check route exists + try this._findOptimalExecutionStrategyView(tokenIn, tokenOut, 1e18, 0) returns (ExecutionStrategy memory) { + isValid = true; + error = ""; + } catch { + return (false, "No route configured", 0); + } + + // Get slippage + configuredSlippage = slippageTolerance[tokenIn][tokenOut]; + if (configuredSlippage == 0) revert NoConfigSlippage(); + } + + /** + * @notice Get custom DEX execution data + * @dev Returns execution info without executing + */ + function getCustomDEXExecutionData( + address targetDEX, + bytes calldata proposedCalldata, + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + view + returns (bool isValid, string memory validationError, bytes memory approvalData, uint256 estimatedGas) + { + // Validate DEX + if (!registeredDEXes[targetDEX]) { + return (false, "DEX not registered", "", 0); + } + + if (block.timestamp < dexRegistrationTime[targetDEX] + DEX_TIMELOCK) { + return (false, "DEX timelock not expired", "", 0); + } + + // Validate selector + if (proposedCalldata.length < 4) { + return (false, "Invalid calldata", "", 0); + } + + bytes4 selector = bytes4(proposedCalldata[:4]); + + if (dangerousSelectors[selector]) { + return (false, "Dangerous selector", "", 0); + } + + if (!whitelistedSelectors[selector]) { + return (false, "Selector not whitelisted", "", 0); + } + + // Generate approval data if needed + if (tokenIn != ETH_ADDRESS) { + approvalData = abi.encodeWithSelector(IERC20.approve.selector, targetDEX, amountIn); + } + + isValid = true; + validationError = ""; + estimatedGas = MAX_DEX_GAS_LIMIT; + } + + // ============================================================================ + // INTERNAL FUNCTIONS + // ============================================================================ + + /** + * @notice Get quote with automatic fallback to configured slippage + * @dev Implements try quoter -> use tight buffer, catch -> use raw tested slippage values + */ + function _getQuoteWithFallback( + address tokenIn, + address tokenOut, + uint256 amountIn, + ExecutionStrategy memory strategy + ) internal returns (uint256 quotedAmount, uint256 minAmountOut) { + // Try quoter first + try this._performQuoteExternal(tokenIn, tokenOut, amountIn, strategy.primaryRouteData) returns ( + uint256 quoterOutput + ) { + if (quoterOutput > 0) { + // Quoter succeeded - use tight buffer + quotedAmount = quoterOutput; + minAmountOut = (quotedAmount * (10000 - TIGHT_BUFFER_BPS)) / 10000; + return (quotedAmount, minAmountOut); + } + } catch { + // Quoter failed - continue to fallback + } + + // Fallback: Use raw decimal-adjusted amount as quote (no haircuts) + quotedAmount = _getRawDecimalAdjustedAmount(amountIn, tokenIn, tokenOut); + + // Get pre-tested slippage for this pair + uint256 slippage = slippageTolerance[tokenIn][tokenOut]; + if (slippage == 0) { + // No configured slippage means route not properly tested + revert NoConfigSlippage(); + } + + // For bridge routes, use combined slippage of both legs + if (strategy.routeType == RouteType.Bridge) { + // Get slippage for second leg + uint256 secondLegSlippage = slippageTolerance[strategy.bridgeAsset][tokenOut]; + if (secondLegSlippage == 0) { + secondLegSlippage = slippageTolerance[tokenOut][strategy.bridgeAsset]; // Try reverse + } + + // Combine slippages (not just double) - more accurate + slippage = slippage + secondLegSlippage; + if (slippage > MAX_SLIPPAGE) slippage = MAX_SLIPPAGE; + } + + // Apply the pre-tested slippage directly + minAmountOut = (quotedAmount * (10000 - slippage)) / 10000; + } + + /** + * @notice Get raw decimal-adjusted amount without any haircuts + * @dev Pure decimal conversion with no reductions + */ + function _getRawDecimalAdjustedAmount( + uint256 amountIn, + address tokenIn, + address tokenOut + ) internal view returns (uint256) { + uint8 decimalsIn = tokenIn == ETH_ADDRESS ? 18 : tokenDecimals[tokenIn]; + uint8 decimalsOut = tokenOut == ETH_ADDRESS ? 18 : tokenDecimals[tokenOut]; + + // MODIFY: Add validation + require(decimalsIn > 0 && decimalsOut > 0, "Token decimals not configured"); + + if (decimalsIn == decimalsOut) { + return amountIn; + } else if (decimalsIn > decimalsOut) { + return amountIn / (10 ** (decimalsIn - decimalsOut)); + } else { + return amountIn * (10 ** (decimalsOut - decimalsIn)); + } + } + + /** + * @notice Calculate minimum amounts for each step in multi-step swap (enhanced) + * @dev Now takes token path instead of just protocols + */ + function _calculateMultiStepMinAmounts( + address[] memory tokens, + uint256 amountIn, + Protocol[] memory protocols, + bytes[] memory routeDatas + ) internal returns (uint256[] memory minAmounts, uint256 finalQuotedAmount) { + minAmounts = new uint256[](protocols.length); + uint256 currentAmount = amountIn; + + for (uint256 i = 0; i < protocols.length; i++) { + // Get quote for this step + ExecutionStrategy memory stepStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[i], + bridgeAsset: address(0), + primaryRouteData: routeDatas[i], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[i]) + }); + + (uint256 stepQuote, uint256 stepMin) = _getQuoteWithFallback( + tokens[i], + tokens[i + 1], + currentAmount, + stepStrategy + ); + + minAmounts[i] = stepMin; + currentAmount = stepQuote; // Use quote for next step input + } + + finalQuotedAmount = currentAmount; + } + /** + * @notice Generate direct execution data for single-step swaps + * @dev Returns calldata that LTM can execute directly on DEX + */ + function _generateDirectExecutionData( + ExecutionStrategy memory strategy, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) internal view returns (bytes memory executionData, address targetContract) { + if (strategy.protocol == Protocol.UniswapV3) { + UniswapV3Route memory route = abi.decode(strategy.primaryRouteData, (UniswapV3Route)); + targetContract = address(uniswapRouter); + + if (!route.isMultiHop) { + executionData = abi.encodeWithSelector( + IUniswapV3Router.exactInputSingle.selector, + IUniswapV3Router.ExactInputSingleParams({ + tokenIn: tokenIn == ETH_ADDRESS ? address(WETH) : tokenIn, + tokenOut: tokenOut == ETH_ADDRESS ? address(WETH) : tokenOut, + fee: route.fee, + recipient: recipient, + deadline: block.timestamp + 1800, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + sqrtPriceLimitX96: 0 + }) + ); + } else { + executionData = abi.encodeWithSelector( + IUniswapV3Router.exactInput.selector, + IUniswapV3Router.ExactInputParams({ + path: route.path, + recipient: recipient, + deadline: block.timestamp + 1800, + amountIn: amountIn, + amountOutMinimum: minAmountOut + }) + ); + } + } else if (strategy.protocol == Protocol.Curve) { + CurveRoute memory route = abi.decode(strategy.primaryRouteData, (CurveRoute)); + targetContract = route.pool; + + // Generate direct calldata for Curve + if (route.useUnderlying) { + executionData = abi.encodeWithSelector( + ICurvePool.exchange_underlying.selector, + route.indexIn, + route.indexOut, + amountIn, + minAmountOut + ); + } else { + executionData = abi.encodeWithSelector( + ICurvePool.exchange.selector, + route.indexIn, + route.indexOut, + amountIn, + minAmountOut + ); + } + } else if (strategy.protocol == Protocol.DirectMint) { + address minter = abi.decode(strategy.primaryRouteData, (address)); + targetContract = minter; + + executionData = abi.encodeWithSelector(IFrxETHMinter.submitAndDeposit.selector, recipient); + } else if (strategy.protocol == Protocol.MultiHop) { + targetContract = address(uniswapRouter); + bytes memory path = strategy.primaryRouteData; + + executionData = abi.encodeWithSelector( + IUniswapV3Router.exactInput.selector, + IUniswapV3Router.ExactInputParams({ + path: path, + recipient: recipient, + deadline: block.timestamp + 1800, + amountIn: amountIn, + amountOutMinimum: minAmountOut + }) + ); + } else { + revert UnsupportedRoute(); + } + } + /** + * @notice Generate complex route data for multi-step/bridge swaps + * @dev Returns structured data for LTM to execute multiple swaps + */ + function _generateComplexRouteData( + ExecutionStrategy memory strategy, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) internal view returns (bytes memory) { + if (strategy.routeType == RouteType.Bridge) { + // Bridge route: two separate swaps + ExecutionStrategy memory firstStep = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: strategy.protocol, + bridgeAsset: address(0), + primaryRouteData: strategy.primaryRouteData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(strategy.protocol) + }); + + // Get execution data for first swap + (bytes memory firstExecution, address firstTarget) = _generateDirectExecutionData( + firstStep, + tokenIn, + strategy.bridgeAsset, + amountIn, + 0, // No minimum for intermediate + recipient // Important: bridge asset goes to recipient (LTM) + ); + + // Decode second route for protocol info + RouteConfig memory secondRoute; + bytes32 secondKey = keccak256(abi.encodePacked(strategy.bridgeAsset, tokenOut)); + secondRoute = routes[secondKey]; + + // Return structured data for LTM + return + abi.encode( + uint8(2), // Flag: Bridge swap + tokenIn, + strategy.bridgeAsset, + tokenOut, + amountIn, + minAmountOut, + firstTarget, + firstExecution, + secondRoute.protocol, + strategy.secondaryRouteData + ); + } else if (strategy.protocol == Protocol.MultiStep) { + // Multi-step route + ( + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas, + uint256[] memory minAmounts + ) = abi.decode(strategy.primaryRouteData, (address[], Protocol[], bytes[], uint256[])); + + // Generate first step data + ExecutionStrategy memory firstStep = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[0], + bridgeAsset: address(0), + primaryRouteData: routeDatas[0], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[0]) + }); + + (bytes memory firstExecution, address firstTarget) = _generateDirectExecutionData( + firstStep, + tokens[0], + tokens[1], + amountIn, + minAmounts[0], + recipient + ); + + // Return structured data + return + abi.encode( + uint8(3), // Flag: Multi-step swap + tokens, + protocols, + routeDatas, + minAmounts, + firstTarget, + firstExecution, + recipient + ); + } + + revert UnsupportedRoute(); + } + + /** + * @notice Decode complex execution data for LTM + * @dev Helper function for LTM to understand complex route data + */ + function decodeComplexExecutionData( + bytes calldata complexData + ) + external + pure + returns (uint8 routeType, address firstTarget, bytes memory firstCalldata, bytes memory additionalData) + { + routeType = abi.decode(complexData, (uint8)); + + if (routeType == 2) { + // Bridge swap + (, address target, bytes memory calldata_, address bridgeAsset, address finalToken, uint256 minOut) = abi + .decode(complexData, (uint8, address, bytes, address, address, uint256)); + + firstTarget = target; + firstCalldata = calldata_; + additionalData = abi.encode(bridgeAsset, finalToken, minOut); + } else if (routeType == 3) { + // Multi-step swap + ( + , + address target, + bytes memory calldata_, + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas, + uint256[] memory minAmounts + ) = abi.decode(complexData, (uint8, address, bytes, address[], Protocol[], bytes[], uint256[])); + + firstTarget = target; + firstCalldata = calldata_; + additionalData = abi.encode(tokens, protocols, routeDatas, minAmounts); + } + } + /** + * @notice Find optimal execution strategy (enhanced with multi-hop support) + * @dev Now handles 3+ hop routes via MultiStep protocol + */ + function _findOptimalExecutionStrategy( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut + ) internal view returns (ExecutionStrategy memory strategy) { + // Validate cross-category early + if (_isCrossCategory(tokenIn, tokenOut)) { + revert NoRouteFound(); + } + + // Try to find multi-hop route + ( + bool found, + address[] memory path, + Protocol[] memory protocols, + bytes[] memory routeDatas + ) = _findMultiHopRoute(tokenIn, tokenOut, MAX_MULTI_STEP_OPERATIONS); + + if (!found) { + revert NoRouteFound(); + } + + // Single hop - direct route + if (path.length == 2) { + strategy.routeType = RouteType.Direct; + strategy.protocol = protocols[0]; + strategy.primaryRouteData = routeDatas[0]; + strategy.expectedGas = _estimateGasForProtocol(protocols[0]); + return strategy; + } + + // Two hops - bridge route + if (path.length == 3) { + strategy.routeType = RouteType.Bridge; + strategy.protocol = protocols[0]; + strategy.bridgeAsset = path[1]; + strategy.primaryRouteData = routeDatas[0]; + strategy.secondaryRouteData = routeDatas[1]; + strategy.expectedGas = _estimateGasForProtocol(protocols[0]) + _estimateGasForProtocol(protocols[1]); + return strategy; + } + + // Three or more hops - multi-step route + strategy.routeType = RouteType.Direct; // Will be treated as MultiStep by protocol + strategy.protocol = Protocol.MultiStep; + + // Create placeholder min amounts (will be calculated later) + uint256[] memory placeholderMinAmounts = new uint256[](protocols.length); + for (uint256 i = 0; i < protocols.length; i++) { + placeholderMinAmounts[i] = 0; // Will be calculated in _calculateMultiStepMinAmounts + } + + strategy.primaryRouteData = abi.encode(path, protocols, routeDatas, placeholderMinAmounts); + strategy.expectedGas = _calculateMultiStepGas(protocols); + return strategy; + } + + /** + * @notice External view function for finding strategy + */ + function _findOptimalExecutionStrategyView( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut + ) external view returns (ExecutionStrategy memory) { + return _findOptimalExecutionStrategy(tokenIn, tokenOut, amountIn, minAmountOut); + } + + /** + * @notice External quote function with Curve and Uniswap support + * @dev Elegantly handles both protocols with proper validation + */ + function _performQuoteExternal( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes memory routeData + ) external returns (uint256 expectedOutput) { + require(msg.sender == address(this), "Internal only"); + + if (routeData.length == 0) return 0; + + // Elegant protocol detection without try-catch overhead + bytes4 routeSignature = bytes4(routeData); + + // Curve route signature check + if (routeSignature == bytes4(keccak256("CurveRoute"))) { + return _performCurveQuote(tokenIn, tokenOut, amountIn, routeData); + } + + // Default to Uniswap quoting + return _performUniswapQuote(tokenIn, tokenOut, amountIn, routeData); + } + + /** + * @notice Perform Curve pool quote with elegant fallback + * @dev Handles both regular and underlying variants seamlessly + */ + function _performCurveQuote( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes memory routeData + ) internal returns (uint256) { + CurveRoute memory route = abi.decode(routeData, (CurveRoute)); + + // Pre-calculate validation bounds for efficiency + uint256 rawAmount = _getRawDecimalAdjustedAmount(amountIn, tokenIn, tokenOut); + uint256 upperBound = (rawAmount * 11000) / 10000; // 110% + uint256 lowerBound = (rawAmount * 9000) / 10000; // 90% + + // Single assembly block for gas-efficient external call + uint256 outputAmount; + bool success; + + assembly { + // Prepare calldata for get_dy or get_dy_underlying + let freePtr := mload(0x40) + + // Function selector based on useUnderlying + let selector := 0x5e0d443f // get_dy(int128,int128,uint256) + if eq(mload(add(routeData, 0x80)), 1) { + // Check useUnderlying + selector := 0x07211ef7 // get_dy_underlying(int128,int128,uint256) + } + + mstore(freePtr, selector) + mstore(add(freePtr, 0x04), mload(add(routeData, 0x40))) // indexIn + mstore(add(freePtr, 0x24), mload(add(routeData, 0x60))) // indexOut + mstore(add(freePtr, 0x44), amountIn) + + success := staticcall( + gas(), + mload(add(routeData, 0x20)), // pool address + freePtr, + 0x64, + freePtr, + 0x20 + ) + + if success { + outputAmount := mload(freePtr) + } + } + + // Validate output with elegant boundary check + if (success && outputAmount >= lowerBound && outputAmount <= upperBound) { + return outputAmount; + } + + return 0; // Trigger fallback + } + + /** + * @notice Perform Uniswap V3 quote with multi-fee tier support + * @dev Extracted for clarity and reusability + */ + function _performUniswapQuote( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes memory routeData + ) internal returns (uint256) { + // Convert ETH to WETH for quoter + address quoteTokenIn = tokenIn == ETH_ADDRESS ? address(WETH) : tokenIn; + address quoteTokenOut = tokenOut == ETH_ADDRESS ? address(WETH) : tokenOut; + + // Extract fee tier from route data efficiently + uint24 primaryFee = _extractUniswapFee(routeData); + + // Try primary fee tier first + uint256 quotedAmount = _tryUniswapQuote(quoteTokenIn, quoteTokenOut, primaryFee, amountIn); + if (quotedAmount > 0) return quotedAmount; + + // Elegant fee tier fallback array + uint24[4] memory feeTiers = [uint24(500), uint24(3000), uint24(10000), uint24(100)]; + + for (uint256 i = 0; i < feeTiers.length; i++) { + if (feeTiers[i] == primaryFee) continue; // Skip already tried + + quotedAmount = _tryUniswapQuote(quoteTokenIn, quoteTokenOut, feeTiers[i], amountIn); + if (quotedAmount > 0) return quotedAmount; + } + + return 0; // Trigger fallback + } + + /** + * @notice Try single Uniswap quote with validation + * @dev Isolated for clean error handling + */ + function _tryUniswapQuote( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn + ) internal returns (uint256) { + try uniswapQuoter.quoteExactInputSingle(tokenIn, tokenOut, fee, amountIn, 0) returns (uint256 amount) { + // Validate against reasonable bounds + uint256 rawAmount = _getRawDecimalAdjustedAmount(amountIn, tokenIn, tokenOut); + + if (amount >= (rawAmount * 5000) / 10000 && amount <= (rawAmount * 11000) / 10000) { + return amount; + } + } catch { + // Silent fail - try next option + } + + return 0; + } + + /** + * @notice Extract Uniswap fee from route data + * @dev Pure function for gas efficiency + */ + function _extractUniswapFee(bytes memory routeData) internal pure returns (uint24) { + if (routeData.length < 32) return 3000; // Default + + // UniswapV3Route struct has fee at second position + uint24 fee; + assembly { + fee := mload(add(routeData, 0x40)) + } + + return fee == 0 ? 3000 : fee; + } + /** + * @notice Try to decode Uniswap route + */ + function _tryDecodeUniswapRoute(bytes memory routeData) external pure returns (uint24) { + UniswapV3Route memory route = abi.decode(routeData, (UniswapV3Route)); + return route.fee; + } + + /** + * @notice Encode route data + */ + function _encodeRouteData( + RouteConfig memory config, + address tokenIn, + address tokenOut + ) internal pure returns (bytes memory) { + if (config.protocol == Protocol.UniswapV3) { + UniswapV3Route memory route = UniswapV3Route({ + pool: config.pool, + fee: config.fee, + isMultiHop: config.path.length > 0, + path: config.path + }); + return abi.encode(route); + } else if (config.protocol == Protocol.Curve) { + CurveRoute memory route = CurveRoute({ + pool: config.pool, + indexIn: config.tokenIndexIn, + indexOut: config.tokenIndexOut, + useUnderlying: config.useUnderlying + }); + return abi.encode(route); + } else if (config.protocol == Protocol.DirectMint) { + return abi.encode(config.specialContract); + } else if (config.protocol == Protocol.MultiHop) { + return config.routeData; + } else if (config.protocol == Protocol.MultiStep) { + return config.routeData; + } + + return config.routeData; + } + + /** + * @notice Encode reverse route data with proper execution parameters + */ + function _encodeReverseRouteData( + RouteConfig memory config, + address tokenIn, + address tokenOut + ) internal pure returns (bytes memory) { + if (config.protocol == Protocol.UniswapV3) { + if (config.path.length > 0) { + // Multi-hop path needs reversal + bytes memory reversedPath = _reversePath(config.path); + return + abi.encode( + UniswapV3Route({pool: config.pool, fee: config.fee, isMultiHop: true, path: reversedPath}) + ); + } else { + // Single hop - just swap the tokens logically + return abi.encode(UniswapV3Route({pool: config.pool, fee: config.fee, isMultiHop: false, path: ""})); + } + } else if (config.protocol == Protocol.Curve) { + // Swap indices for reverse + return + abi.encode( + CurveRoute({ + pool: config.pool, + indexIn: config.tokenIndexOut, + indexOut: config.tokenIndexIn, + useUnderlying: config.useUnderlying + }) + ); + } else if (config.protocol == Protocol.MultiHop) { + // Reverse the entire path + return _reversePath(config.routeData); + } else if (config.protocol == Protocol.DirectMint) { + // DirectMint cannot be reversed + revert UnsupportedRoute(); + } + + return config.routeData; + } + + /** + * @notice Reverse a Uniswap V3 path + */ + function _reversePath(bytes memory path) internal pure returns (bytes memory) { + require(path.length >= 43, "Path too short"); + require((path.length - 20) % 23 == 0, "Invalid path length"); + + uint256 numPools = (path.length - 20) / 23; + bytes memory reversed = new bytes(path.length); + + // Copy the last token to the beginning + for (uint256 i = 0; i < 20; i++) { + reversed[i] = path[path.length - 20 + i]; + } + + // Reverse each pool + token pair + for (uint256 i = 0; i < numPools; i++) { + uint256 srcPoolStart = 20 + i * 23; + uint256 dstPoolStart = 20 + (numPools - 1 - i) * 23; + + // Copy fee (3 bytes) + for (uint256 j = 0; j < 3; j++) { + reversed[dstPoolStart + j] = path[srcPoolStart + j]; + } + + // Copy token (20 bytes) + uint256 srcTokenStart = srcPoolStart + 3; + uint256 dstTokenStart = dstPoolStart + 3; + + // For all but the last pool, copy the preceding token + if (i < numPools - 1) { + for (uint256 j = 0; j < 20; j++) { + reversed[dstTokenStart + j] = path[srcTokenStart - 23 + j]; + } + } else { + // For the last pool, copy the first token + for (uint256 j = 0; j < 20; j++) { + reversed[dstTokenStart + j] = path[j]; + } + } + } + + return reversed; + } + + /** + * @notice Apply production slippage configuration with all tested values + */ + function _applyProductionSlippageConfig() internal { + // ETH to LST tokens - very tight + slippageTolerance[ETH_ADDRESS][0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84] = 50; // ETH->stETH + slippageTolerance[ETH_ADDRESS][FRXETH] = 50; // ETH->frxETH + slippageTolerance[ETH_ADDRESS][SFRXETH] = 50; // ETH->sfrxETH + + // WETH to LST tokens - varying by liquidity + slippageTolerance[address(WETH)][0xBe9895146f7AF43049ca1c1AE358B0541Ea49704] = 350; // WETH->cbETH + slippageTolerance[address(WETH)][RETH] = 750; // WETH->rETH + slippageTolerance[address(WETH)][OSETH] = 500; // WETH->osETH + + // Reverse routes + slippageTolerance[0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84][ETH_ADDRESS] = 50; + slippageTolerance[SFRXETH][ETH_ADDRESS] = 50; + slippageTolerance[0xBe9895146f7AF43049ca1c1AE358B0541Ea49704][address(WETH)] = 350; + slippageTolerance[RETH][address(WETH)] = 750; + slippageTolerance[OSETH][address(WETH)] = 500; + + // BTC wrapped pairs + slippageTolerance[WBTC][0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa] = 200; // WBTC->uniBTC + slippageTolerance[0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa][WBTC] = 200; + + // Bridge routes through WETH + slippageTolerance[0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84][RETH] = 800; // stETH->rETH + slippageTolerance[RETH][OSETH] = 600; // rETH->osETH + } + + /** + * @notice Get bridge asset for a token pair + */ + function _getBridgeAsset(address tokenIn, address tokenOut) internal view returns (address) { + // No bridge for same token + if (tokenIn == tokenOut) return address(0); + + // Get asset types + AssetType typeIn = tokenIn == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenIn]; + AssetType typeOut = tokenOut == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenOut]; + + // Cross-category forbidden - return early + if (typeIn != typeOut) return address(0); + + // BTC tokens always bridge through WBTC + if (typeIn == AssetType.BTC_WRAPPED && typeOut == AssetType.BTC_WRAPPED) { + // Only use WBTC as bridge if it's not one of the tokens + if (tokenIn != WBTC && tokenOut != WBTC) { + // Check if both routes exist + bytes32 firstKey = keccak256(abi.encodePacked(tokenIn, WBTC)); + bytes32 secondKey = keccak256(abi.encodePacked(WBTC, tokenOut)); + bytes32 firstReverseKey = keccak256(abi.encodePacked(WBTC, tokenIn)); + bytes32 secondReverseKey = keccak256(abi.encodePacked(tokenOut, WBTC)); + + bool firstExists = routes[firstKey].isConfigured || routes[firstReverseKey].isConfigured; + bool secondExists = routes[secondKey].isConfigured || routes[secondReverseKey].isConfigured; + + if (firstExists && secondExists) { + return WBTC; + } + } + } + + // ETH LST tokens - try WETH first, then ETH + if (typeIn == AssetType.ETH_LST && typeOut == AssetType.ETH_LST) { + // Try WETH bridge first (most common according to config) + if (tokenIn != address(WETH) && tokenOut != address(WETH)) { + bytes32 firstKey = keccak256(abi.encodePacked(tokenIn, address(WETH))); + bytes32 secondKey = keccak256(abi.encodePacked(address(WETH), tokenOut)); + bytes32 firstReverseKey = keccak256(abi.encodePacked(address(WETH), tokenIn)); + bytes32 secondReverseKey = keccak256(abi.encodePacked(tokenOut, address(WETH))); + + bool firstExists = routes[firstKey].isConfigured || routes[firstReverseKey].isConfigured; + bool secondExists = routes[secondKey].isConfigured || routes[secondReverseKey].isConfigured; + + if (firstExists && secondExists) { + return address(WETH); + } + } + + // Try ETH bridge for tokens that have ETH pairs + if (tokenIn != ETH_ADDRESS && tokenOut != ETH_ADDRESS) { + bytes32 firstKey = keccak256(abi.encodePacked(tokenIn, ETH_ADDRESS)); + bytes32 secondKey = keccak256(abi.encodePacked(ETH_ADDRESS, tokenOut)); + bytes32 firstReverseKey = keccak256(abi.encodePacked(ETH_ADDRESS, tokenIn)); + bytes32 secondReverseKey = keccak256(abi.encodePacked(tokenOut, ETH_ADDRESS)); + + bool firstExists = routes[firstKey].isConfigured || routes[firstReverseKey].isConfigured; + bool secondExists = routes[secondKey].isConfigured || routes[secondReverseKey].isConfigured; + + if (firstExists && secondExists) { + return ETH_ADDRESS; + } + } + } + + return address(0); + } + + /* + function _getDefaultSlippage(address tokenIn, address tokenOut) internal view returns (uint256) { + AssetType typeIn = assetTypes[tokenIn]; + AssetType typeOut = assetTypes[tokenOut]; + + // Same type swaps - lower slippage + if (typeIn == typeOut) { + if (typeIn == AssetType.STABLE) return 30; // 0.3% + if (typeIn == AssetType.ETH_LST) return 50; // 0.5% + if (typeIn == AssetType.BTC_WRAPPED) return 100; // 1% + } + + // Cross-type swaps - higher slippage + if (typeIn == AssetType.VOLATILE || typeOut == AssetType.VOLATILE) { + return 500; // 5% + } + + return 200; // 2% default + } + + function _getSimpleEstimate(uint256 amountIn, address tokenIn, address tokenOut) internal view returns (uint256) { + // Handle ETH as 18 decimals + uint8 decimalsIn = tokenIn == ETH_ADDRESS ? 18 : tokenDecimals[tokenIn]; + uint8 decimalsOut = tokenOut == ETH_ADDRESS ? 18 : tokenDecimals[tokenOut]; + + // Default to 18 if not set + if (decimalsIn == 0) decimalsIn = 18; + if (decimalsOut == 0) decimalsOut = 18; + + // Get asset types + AssetType typeIn = tokenIn == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenIn]; + AssetType typeOut = tokenOut == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenOut]; + + // Start with decimal adjustment + uint256 estimate; + if (decimalsIn == decimalsOut) { + estimate = amountIn; + } else if (decimalsIn > decimalsOut) { + estimate = amountIn / (10 ** (decimalsIn - decimalsOut)); + } else { + estimate = amountIn * (10 ** (decimalsOut - decimalsIn)); + } + + // Apply type-based adjustments + if (typeIn == AssetType.ETH_LST && typeOut == AssetType.ETH_LST) { + // ETH LST pairs are close to 1:1 after decimal adjustment + // Check if either token is rebasing (stETH) + if ( + tokenIn == 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 || + tokenOut == 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 + ) { + return (estimate * 9900) / 10000; // 1% haircut for rebasing + } else { + return (estimate * 9950) / 10000; // 0.5% haircut for non-rebasing + } + } else if (typeIn == AssetType.BTC_WRAPPED && typeOut == AssetType.BTC_WRAPPED) { + // BTC wrapped pairs are very close to 1:1 + return (estimate * 9980) / 10000; // 0.2% haircut + } else if (typeIn == AssetType.STABLE && typeOut == AssetType.STABLE) { + // Stablecoins should be exactly 1:1 after decimal adjustment + return (estimate * 9990) / 10000; // 0.1% haircut + } else { + // Different types or volatile - shouldn't happen due to cross-category check + // But if it does, apply conservative estimate + return (estimate * 9500) / 10000; // 5% haircut + } + } +*/ + /** + * @notice Estimate gas for protocol with better accuracy + */ + function _estimateGasForProtocol(Protocol protocol) internal pure returns (uint256) { + if (protocol == Protocol.UniswapV3) return 150000; + if (protocol == Protocol.Curve) return 250000; // Increased for Curve complexity + if (protocol == Protocol.DirectMint) return 120000; + if (protocol == Protocol.MultiHop) return 300000; + if (protocol == Protocol.MultiStep) return 500000; + return 200000; // Default + } + + /** + * @notice Calculate gas for multi-step route + */ + function _calculateMultiStepGas(Protocol[] memory protocols) internal pure returns (uint256) { + uint256 totalGas = 50000; // Base overhead + for (uint256 i = 0; i < protocols.length; i++) { + totalGas += _estimateGasForProtocol(protocols[i]); + } + return totalGas; + } + + /** + * @notice Estimate swap output (view safe) + */ + function _estimateSwapOutputView( + address tokenIn, + address tokenOut, + uint256 amountIn, + ExecutionStrategy memory strategy + ) internal view returns (uint256) { + // For view context, use simple estimation + return _getRawDecimalAdjustedAmount(amountIn, tokenIn, tokenOut); + } + + /** + * @notice Validate strategy pools + */ + function _validateStrategyPools(ExecutionStrategy memory strategy) internal view { + if (strategy.protocol == Protocol.UniswapV3) { + UniswapV3Route memory route = abi.decode(strategy.primaryRouteData, (UniswapV3Route)); + if (!route.isMultiHop && route.pool != address(0)) { + if (!poolWhitelist[route.pool]) revert PoolNotWhitelisted(); + if (poolPaused[route.pool]) revert PoolIsPaused(); + } + } else if (strategy.protocol == Protocol.Curve) { + CurveRoute memory route = abi.decode(strategy.primaryRouteData, (CurveRoute)); + if (!poolWhitelist[route.pool]) revert PoolNotWhitelisted(); + if (poolPaused[route.pool]) revert PoolIsPaused(); + } + } + + /** + * @notice Compute Uniswap V3 pool address + */ + function _computeUniswapV3Pool(address tokenA, address tokenB, uint24 fee) internal pure returns (address pool) { + (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + + pool = address( + uint160( + uint256( + keccak256( + abi.encodePacked( + hex"ff", + UNISWAP_V3_FACTORY, + keccak256(abi.encode(token0, token1, fee)), + POOL_INIT_CODE_HASH + ) + ) + ) + ) + ); + } + + // ============================================================================ + // ROUTE CONFIGURATION + // ============================================================================ + + /** + * @notice Configure a new route + * @param tokenIn Input token address + * @param tokenOut Output token address + * @param protocol Protocol to use + * @param poolAddress Pool address (for Uniswap/Curve) + * @param fee Fee tier (for Uniswap) + * @param curveIndices Token indices (for Curve) + * @param useUnderlying Use underlying (for Curve) + * @param specialContract Special contract (for DirectMint) + * @param password Security password + */ + function configureRoute( + address tokenIn, + address tokenOut, + Protocol protocol, + address poolAddress, + uint24 fee, + int128[2] memory curveIndices, + bool useUnderlying, + address specialContract, + string calldata password + ) external onlyRouteManager { + require(keccak256(abi.encode(password, address(this))) == ROUTE_PASSWORD_HASH, "Invalid password"); + require(tokenIn != address(0) && tokenOut != address(0), "Invalid tokens"); + require(tokenIn != tokenOut, "Same token"); + + bytes32 routeKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + + // Create route config + RouteConfig memory config = RouteConfig({ + protocol: protocol, + pool: poolAddress, + fee: fee, + directSwap: true, + path: "", + tokenIndexIn: curveIndices[0], + tokenIndexOut: curveIndices[1], + useUnderlying: useUnderlying, + specialContract: specialContract, + isConfigured: true, + routeData: "" + }); + + // Encode route data based on protocol + if (protocol == Protocol.UniswapV3) { + config.routeData = abi.encode(UniswapV3Route({pool: poolAddress, fee: fee, isMultiHop: false, path: ""})); + } else if (protocol == Protocol.Curve) { + config.routeData = abi.encode( + CurveRoute({ + pool: poolAddress, + indexIn: curveIndices[0], + indexOut: curveIndices[1], + useUnderlying: useUnderlying + }) + ); + } else if (protocol == Protocol.DirectMint) { + config.routeData = abi.encode(specialContract); + } + + routes[routeKey] = config; + emit RouteConfigured(tokenIn, tokenOut, protocol, poolAddress); + } + + /** + * @notice Configure a multi-hop route + * @param tokenIn Starting token + * @param tokenOut Ending token + * @param path Encoded Uniswap V3 path + * @param password Security password + */ + function configureMultiHopRoute( + address tokenIn, + address tokenOut, + bytes calldata path, + string calldata password + ) external onlyRouteManager { + require(keccak256(abi.encode(password, address(this))) == ROUTE_PASSWORD_HASH, "Invalid password"); + require(path.length >= 43, "Path too short"); + require((path.length - 20) % 23 == 0, "Invalid path length"); + + bytes32 routeKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + + RouteConfig memory config = RouteConfig({ + protocol: Protocol.MultiHop, + pool: address(0), + fee: 0, + directSwap: false, + path: path, + tokenIndexIn: 0, + tokenIndexOut: 0, + useUnderlying: false, + specialContract: address(0), + isConfigured: true, + routeData: path + }); + + routes[routeKey] = config; + emit RouteConfigured(tokenIn, tokenOut, Protocol.MultiHop, address(0)); + } + + /** + * @notice Configure a multi-step route + * @param tokenIn Starting token + * @param tokenOut Ending token + * @param tokens Token path + * @param protocols Protocols for each step + * @param routeDatas Route data for each step + * @param minAmounts Minimum amounts for each step + * @param password Security password + */ + function configureMultiStepRoute( + address tokenIn, + address tokenOut, + address[] calldata tokens, + Protocol[] calldata protocols, + bytes[] calldata routeDatas, + uint256[] calldata minAmounts, + string calldata password + ) external onlyRouteManager { + require(keccak256(abi.encode(password, address(this))) == ROUTE_PASSWORD_HASH, "Invalid password"); + require(tokens.length >= 2, "Invalid tokens"); + require(tokens[0] == tokenIn && tokens[tokens.length - 1] == tokenOut, "Token mismatch"); + require(protocols.length == tokens.length - 1, "Invalid protocols"); + require(routeDatas.length == protocols.length, "Invalid route data"); + require(minAmounts.length == protocols.length, "Invalid min amounts"); + + bytes32 routeKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + + // Encode multi-step data + bytes memory encodedData = abi.encode(tokens, protocols, routeDatas, minAmounts); + + RouteConfig memory config = RouteConfig({ + protocol: Protocol.MultiStep, + pool: address(0), + fee: 0, + directSwap: false, + path: "", + tokenIndexIn: 0, + tokenIndexOut: 0, + useUnderlying: false, + specialContract: address(0), + isConfigured: true, + routeData: encodedData + }); + + routes[routeKey] = config; + emit RouteConfigured(tokenIn, tokenOut, Protocol.MultiStep, address(0)); + } + + // ============================================================================ + // ADMIN FUNCTIONS + // ============================================================================ + + /** + * @notice Grant operator role + */ + function grantOperatorRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) { + grantRole(OPERATOR_ROLE, account); + } + + /** + * @notice Revoke operator role + */ + function revokeOperatorRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(OPERATOR_ROLE, account); + } + + /** + * @notice Emergency pause + */ + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); + } + + /** + * @notice Unpause + */ + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + /** + * @notice Configure slippage tolerance + */ + function configureSlippage( + address tokenIn, + address tokenOut, + uint256 slippageBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(slippageBps <= MAX_SLIPPAGE, "Slippage too high"); + slippageTolerance[tokenIn][tokenOut] = slippageBps; + emit SlippageConfigured(tokenIn, tokenOut, slippageBps, msg.sender, block.timestamp); + } + + /** + * @notice Add supported token + */ + function addSupportedToken( + address token, + AssetType assetType, + uint8 decimals + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(token != address(0), "Invalid token"); + require(decimals > 0 && decimals <= 18, "Invalid decimals"); + + farSupportedTokens[token] = true; + assetTypes[token] = assetType; + tokenDecimals[token] = decimals; + + emit TokenSupported(token, true, assetType, decimals, block.timestamp); + } + + /** + * @notice Remove supported token + */ + function removeSupportedToken(address token) external onlyRole(DEFAULT_ADMIN_ROLE) { + farSupportedTokens[token] = false; + emit TokenSupported(token, false, assetTypes[token], tokenDecimals[token], block.timestamp); + } + + /** + * @notice Whitelist pool + */ + function whitelistPool( + address pool, + uint256 tokenCount, + CurveInterface curveInterface + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(pool != address(0), "Invalid pool"); + poolWhitelist[pool] = true; + curvePoolTokenCounts[pool] = tokenCount; + curvePoolInterfaces[pool] = curveInterface; + emit PoolWhitelisted(pool, true, curveInterface, msg.sender, block.timestamp); + } + + /** + * @notice Remove pool from whitelist + */ + function removePoolFromWhitelist(address pool) external onlyRole(DEFAULT_ADMIN_ROLE) { + poolWhitelist[pool] = false; + emit PoolWhitelisted(pool, false, curvePoolInterfaces[pool], msg.sender, block.timestamp); + } + + /** + * @notice Pause/unpause pool + */ + function setPoolPaused(address pool, bool paused) external onlyRole(DEFAULT_ADMIN_ROLE) { + poolPaused[pool] = paused; + } + + /** + * @notice Pause/unpause protocol + */ + function setProtocolPaused(Protocol protocol, bool paused) external onlyRole(DEFAULT_ADMIN_ROLE) { + protocolPaused[protocol] = paused; + } + + /** + * @notice Update route manager + */ + function updateRouteManager(address newManager) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(newManager != address(0), "Invalid manager"); + routeManager = newManager; + } + + /** + * @notice Register a new DEX for custom routing + */ + function registerDEX( + address dex, + string calldata name, + string calldata password + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(dex != address(0), "Invalid DEX address"); + require(bytes(name).length > 0 && bytes(name).length <= 32, "Invalid DEX name length"); + require(keccak256(abi.encode(password, address(this))) == ROUTE_PASSWORD_HASH, "Invalid password"); + require(!registeredDEXes[dex], "DEX already registered"); + + // Validate DEX is a contract + uint256 codeSize; + assembly { + codeSize := extcodesize(dex) + } + require(codeSize > 0, "DEX must be a contract"); + + registeredDEXes[dex] = true; + dexNames[dex] = name; + dexRegistrationTime[dex] = block.timestamp; + dexRegisteredBy[dex] = msg.sender; + allRegisteredDEXes.push(dex); + + emit DexRegistered(dex, name, msg.sender, block.timestamp); + } + + /** + * @notice Unregister a DEX + */ + function unregisterDEX(address dex) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(registeredDEXes[dex], "DEX not registered"); + require(block.timestamp >= dexRegistrationTime[dex] + DEX_TIMELOCK, "Timelock not expired"); + + registeredDEXes[dex] = false; + emit DexUnregistered(dex, msg.sender, block.timestamp); + } + + /** + * @notice Whitelist a function selector for custom DEX calls + */ + function whitelistSelector(bytes4 selector, string calldata description) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(!dangerousSelectors[selector], "Selector is blacklisted"); + require(bytes(description).length > 0, "Description required"); + + whitelistedSelectors[selector] = true; + selectorDescriptions[selector] = description; + allWhitelistedSelectors.push(selector); + + emit SelectorWhitelisted(selector, description, block.timestamp); + } + + /** + * @notice Blacklist a dangerous function selector + */ + function blacklistSelector(bytes4 selector, string calldata reason) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(bytes(reason).length > 0, "Reason required"); + + dangerousSelectors[selector] = true; + whitelistedSelectors[selector] = false; + + // Add common dangerous selectors if not already added + if (allDangerousSelectors.length == 0) { + _initializeDangerousSelectors(); + } + + allDangerousSelectors.push(selector); + emit SelectorBlacklisted(selector, reason, block.timestamp); + } + + /** + * @notice Initialize common dangerous selectors + */ + function _initializeDangerousSelectors() internal { + // Ownership functions + dangerousSelectors[0x13af4035] = true; // setOwner(address) + dangerousSelectors[0xf2fde38b] = true; // transferOwnership(address) + dangerousSelectors[0x715018a6] = true; // renounceOwnership() + + // Upgrade functions + dangerousSelectors[0x3659cfe6] = true; // upgradeTo(address) + dangerousSelectors[0x4f1ef286] = true; // upgradeToAndCall(address,bytes) + + // Self-destruct + dangerousSelectors[0x83197ef0] = true; // destroy() + dangerousSelectors[0x00f55d9d] = true; // destroy(address) + + // Initialization + dangerousSelectors[0x8129fc1c] = true; // initialize() + } + + // ============================================================================ + // VIEW FUNCTIONS + // ============================================================================ + + /** + * @notice Generate complete execution plan for multi-step swaps (enhanced) + * @dev Now handles any number of hops discovered by route finding + */ + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external returns (uint256 totalQuotedAmount, MultiStepExecutionPlan memory plan) { + // Validate inputs + if (amountIn == 0) revert ZeroAmount(); + if (tokenIn == tokenOut) revert SameTokenSwap(); + if (_isCrossCategory(tokenIn, tokenOut)) revert NoRouteFound(); + + // Find multi-hop route + ( + bool found, + address[] memory path, + Protocol[] memory protocols, + bytes[] memory routeDatas + ) = _findMultiHopRoute(tokenIn, tokenOut, MAX_MULTI_STEP_OPERATIONS); + + if (!found) { + revert NoRouteFound(); + } + + // Calculate all minimum amounts + uint256[] memory minAmounts; + (minAmounts, totalQuotedAmount) = _calculateMultiStepMinAmounts(path, amountIn, protocols, routeDatas); + + // Build execution steps + plan.steps = new SwapStep[](protocols.length); + uint256 currentAmount = amountIn; + + for (uint256 i = 0; i < protocols.length; i++) { + SwapStep memory step; + step.tokenIn = path[i]; + step.tokenOut = path[i + 1]; + step.amountIn = currentAmount; + step.minAmountOut = minAmounts[i]; + step.protocol = protocols[i]; + + // Generate execution data for this step + ExecutionStrategy memory stepStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[i], + bridgeAsset: address(0), + primaryRouteData: routeDatas[i], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[i]) + }); + + (step.data, step.target) = _generateDirectExecutionData( + stepStrategy, + step.tokenIn, + step.tokenOut, + step.amountIn, + step.minAmountOut, + recipient + ); + + step.value = (step.tokenIn == ETH_ADDRESS) ? step.amountIn : 0; + plan.steps[i] = step; + + // Update for next iteration - use quote as input for next step + if (i < protocols.length - 1) { + currentAmount = (minAmounts[i] * 10050) / 10000; // Add 0.5% buffer + } + } + + plan.expectedFinalAmount = totalQuotedAmount; + emit MultiStepPlanGenerated(tokenIn, tokenOut, amountIn, plan.steps.length); + } + + /** + * @notice Build complete multi-step execution plan + * @dev Pre-calculates all amounts and generates all calldata upfront + */ + function _buildMultiStepPlan( + ExecutionStrategy memory strategy, + uint256 amountIn, + address recipient + ) internal returns (MultiStepExecutionPlan memory plan) { + // Decode multi-step configuration + (address[] memory tokens, Protocol[] memory protocols, bytes[] memory routeDatas, ) = abi.decode( + strategy.primaryRouteData, + (address[], Protocol[], bytes[], uint256[]) + ); + + // Pre-calculate all amounts + uint256[] memory minAmounts; + uint256 finalAmount; + (minAmounts, finalAmount) = _calculateMultiStepMinAmounts(tokens, amountIn, protocols, routeDatas); + + // Build execution steps + plan.steps = new SwapStep[](protocols.length); + uint256 currentAmount = amountIn; + + for (uint256 i = 0; i < protocols.length; i++) { + SwapStep memory step; + step.tokenIn = tokens[i]; + step.tokenOut = tokens[i + 1]; + step.amountIn = currentAmount; + step.minAmountOut = minAmounts[i]; + step.protocol = protocols[i]; + + // Generate execution data for this step + ExecutionStrategy memory stepStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: protocols[i], + bridgeAsset: address(0), + primaryRouteData: routeDatas[i], + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(protocols[i]) + }); + + (step.data, step.target) = _generateDirectExecutionData( + stepStrategy, + step.tokenIn, + step.tokenOut, + step.amountIn, + step.minAmountOut, + recipient + ); + + step.value = (step.tokenIn == ETH_ADDRESS) ? step.amountIn : 0; + plan.steps[i] = step; + + // Update for next iteration + currentAmount = (minAmounts[i] * 10050) / 10000; // Add 0.5% buffer for next step + } + + plan.expectedFinalAmount = finalAmount; + } + + /** + * @notice Build bridge swap as multi-step plan + * @dev Converts bridge route to two-step execution plan + */ + function _buildBridgePlan( + ExecutionStrategy memory strategy, + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) internal returns (MultiStepExecutionPlan memory plan) { + plan.steps = new SwapStep[](2); + + // First leg: tokenIn -> bridgeAsset + (uint256 firstLegQuote, uint256 firstLegMin) = _getQuoteWithFallback( + tokenIn, + strategy.bridgeAsset, + amountIn, + ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: strategy.protocol, + bridgeAsset: address(0), + primaryRouteData: strategy.primaryRouteData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(strategy.protocol) + }) + ); + + SwapStep memory firstStep; + firstStep.tokenIn = tokenIn; + firstStep.tokenOut = strategy.bridgeAsset; + firstStep.amountIn = amountIn; + firstStep.minAmountOut = firstLegMin; + firstStep.protocol = strategy.protocol; + + (firstStep.data, firstStep.target) = _generateDirectExecutionData( + strategy, + tokenIn, + strategy.bridgeAsset, + amountIn, + firstLegMin, + recipient + ); + + firstStep.value = (tokenIn == ETH_ADDRESS) ? amountIn : 0; + plan.steps[0] = firstStep; + + // Second leg: bridgeAsset -> tokenOut + bytes32 secondRouteKey = keccak256(abi.encodePacked(strategy.bridgeAsset, tokenOut)); + RouteConfig memory secondConfig = routes[secondRouteKey]; + + ExecutionStrategy memory secondStrategy = ExecutionStrategy({ + routeType: RouteType.Direct, + protocol: secondConfig.protocol, + bridgeAsset: address(0), + primaryRouteData: secondConfig.routeData, + secondaryRouteData: "", + expectedGas: _estimateGasForProtocol(secondConfig.protocol) + }); + + (uint256 secondLegQuote, uint256 secondLegMin) = _getQuoteWithFallback( + strategy.bridgeAsset, + tokenOut, + firstLegQuote, + secondStrategy + ); + + SwapStep memory secondStep; + secondStep.tokenIn = strategy.bridgeAsset; + secondStep.tokenOut = tokenOut; + secondStep.amountIn = firstLegQuote; + secondStep.minAmountOut = secondLegMin; + secondStep.protocol = secondConfig.protocol; + + (secondStep.data, secondStep.target) = _generateDirectExecutionData( + secondStrategy, + strategy.bridgeAsset, + tokenOut, + firstLegQuote, + secondLegMin, + recipient + ); + + secondStep.value = (strategy.bridgeAsset == ETH_ADDRESS) ? firstLegQuote : 0; + plan.steps[1] = secondStep; + + plan.expectedFinalAmount = secondLegQuote; + } + + /** + * @notice Wrap single step in plan for consistent interface + */ + function _wrapSingleStepPlan( + ExecutionStrategy memory strategy, + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) internal returns (MultiStepExecutionPlan memory plan) { + plan.steps = new SwapStep[](1); + + (uint256 quotedAmount, uint256 minAmountOut) = _getQuoteWithFallback(tokenIn, tokenOut, amountIn, strategy); + + SwapStep memory step; + step.tokenIn = tokenIn; + step.tokenOut = tokenOut; + step.amountIn = amountIn; + step.minAmountOut = minAmountOut; + step.protocol = strategy.protocol; + + (step.data, step.target) = _generateDirectExecutionData( + strategy, + tokenIn, + tokenOut, + amountIn, + minAmountOut, + recipient + ); + + step.value = (tokenIn == ETH_ADDRESS) ? amountIn : 0; + plan.steps[0] = step; + plan.expectedFinalAmount = quotedAmount; + } + + /** + * @notice Check if a route exists (enhanced with multi-hop support) + * @dev Now discovers routes up to MAX_MULTI_STEP_OPERATIONS hops + */ + function hasRoute(address tokenIn, address tokenOut) external view returns (bool) { + if (tokenIn == tokenOut) return false; + if (_isCrossCategory(tokenIn, tokenOut)) return false; + + // Try to find multi-hop route + (bool found, , , ) = _findMultiHopRoute(tokenIn, tokenOut, MAX_MULTI_STEP_OPERATIONS); + return found; + } + + /** + * @notice Find multi-hop route with iterative breadth-first search + * @dev More reliable than recursive approach - finds shortest paths first + */ + function _findMultiHopRoute( + address tokenIn, + address tokenOut, + uint256 maxHops + ) + internal + view + returns (bool found, address[] memory path, Protocol[] memory protocols, bytes[] memory routeDatas) + { + if (maxHops == 0 || tokenIn == tokenOut) return (false, new address[](0), new Protocol[](0), new bytes[](0)); + + // Try direct route first (1-hop) + bytes32 directKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + bytes32 reverseKey = keccak256(abi.encodePacked(tokenOut, tokenIn)); + + if (routes[directKey].isConfigured) { + path = new address[](2); + path[0] = tokenIn; + path[1] = tokenOut; + + protocols = new Protocol[](1); + protocols[0] = routes[directKey].protocol; + + routeDatas = new bytes[](1); + routeDatas[0] = routes[directKey].routeData; + + return (true, path, protocols, routeDatas); + } + + if (routes[reverseKey].isConfigured) { + path = new address[](2); + path[0] = tokenIn; + path[1] = tokenOut; + + protocols = new Protocol[](1); + protocols[0] = routes[reverseKey].protocol; + + routeDatas = new bytes[](1); + routeDatas[0] = _encodeReverseRouteData(routes[reverseKey], tokenOut, tokenIn); + + return (true, path, protocols, routeDatas); + } + + // Try 2-hop routes (bridge routes) + if (maxHops >= 2) { + address[] memory intermediates = _getIntermediateTokens(tokenIn, tokenOut); + + for (uint256 i = 0; i < intermediates.length; i++) { + address bridge = intermediates[i]; + + // Check first leg: tokenIn -> bridge + bool firstLegExists = false; + Protocol firstProtocol; + bytes memory firstRouteData; + + bytes32 firstDirectKey = keccak256(abi.encodePacked(tokenIn, bridge)); + bytes32 firstReverseKey = keccak256(abi.encodePacked(bridge, tokenIn)); + + if (routes[firstDirectKey].isConfigured) { + firstLegExists = true; + firstProtocol = routes[firstDirectKey].protocol; + firstRouteData = routes[firstDirectKey].routeData; + } else if (routes[firstReverseKey].isConfigured) { + firstLegExists = true; + firstProtocol = routes[firstReverseKey].protocol; + firstRouteData = _encodeReverseRouteData(routes[firstReverseKey], bridge, tokenIn); + } + + if (!firstLegExists) continue; + + // Check second leg: bridge -> tokenOut + bool secondLegExists = false; + Protocol secondProtocol; + bytes memory secondRouteData; + + bytes32 secondDirectKey = keccak256(abi.encodePacked(bridge, tokenOut)); + bytes32 secondReverseKey = keccak256(abi.encodePacked(tokenOut, bridge)); + + if (routes[secondDirectKey].isConfigured) { + secondLegExists = true; + secondProtocol = routes[secondDirectKey].protocol; + secondRouteData = routes[secondDirectKey].routeData; + } else if (routes[secondReverseKey].isConfigured) { + secondLegExists = true; + secondProtocol = routes[secondReverseKey].protocol; + secondRouteData = _encodeReverseRouteData(routes[secondReverseKey], tokenOut, bridge); + } + + if (secondLegExists) { + // Found 2-hop route! + path = new address[](3); + path[0] = tokenIn; + path[1] = bridge; + path[2] = tokenOut; + + protocols = new Protocol[](2); + protocols[0] = firstProtocol; + protocols[1] = secondProtocol; + + routeDatas = new bytes[](2); + routeDatas[0] = firstRouteData; + routeDatas[1] = secondRouteData; + + return (true, path, protocols, routeDatas); + } + } + } + + // Try 3-hop routes if needed + if (maxHops >= 3) { + return _find3HopRoute(tokenIn, tokenOut); + } + + return (false, new address[](0), new Protocol[](0), new bytes[](0)); + } + + /** + * @notice Find 3-hop routes specifically + * @dev Handles cases like STETH->WETH->RETH->OSETH + */ + function _find3HopRoute( + address tokenIn, + address tokenOut + ) + internal + view + returns (bool found, address[] memory path, Protocol[] memory protocols, bytes[] memory routeDatas) + { + address[] memory intermediates = _getIntermediateTokens(tokenIn, tokenOut); + + // Try each intermediate as first bridge + for (uint256 i = 0; i < intermediates.length; i++) { + address firstBridge = intermediates[i]; + + // Check if tokenIn -> firstBridge exists + if (!_hasDirectRoute(tokenIn, firstBridge)) continue; + + // Try each intermediate as second bridge + for (uint256 j = 0; j < intermediates.length; j++) { + address secondBridge = intermediates[j]; + if (secondBridge == firstBridge) continue; + + // Check the 3-hop path: tokenIn -> firstBridge -> secondBridge -> tokenOut + if (_hasDirectRoute(firstBridge, secondBridge) && _hasDirectRoute(secondBridge, tokenOut)) { + // Build the path + path = new address[](4); + path[0] = tokenIn; + path[1] = firstBridge; + path[2] = secondBridge; + path[3] = tokenOut; + + protocols = new Protocol[](3); + routeDatas = new bytes[](3); + + // Get route data for each leg + (protocols[0], routeDatas[0]) = _getRouteInfo(tokenIn, firstBridge); + (protocols[1], routeDatas[1]) = _getRouteInfo(firstBridge, secondBridge); + (protocols[2], routeDatas[2]) = _getRouteInfo(secondBridge, tokenOut); + + return (true, path, protocols, routeDatas); + } + } + } + + return (false, new address[](0), new Protocol[](0), new bytes[](0)); + } + + /** + * @notice Check if direct route exists (either direction) + */ + function _hasDirectRoute(address tokenA, address tokenB) internal view returns (bool) { + bytes32 directKey = keccak256(abi.encodePacked(tokenA, tokenB)); + bytes32 reverseKey = keccak256(abi.encodePacked(tokenB, tokenA)); + return routes[directKey].isConfigured || routes[reverseKey].isConfigured; + } + + /** + * @notice Get route protocol and data (handles reverse routes) + */ + function _getRouteInfo( + address tokenA, + address tokenB + ) internal view returns (Protocol protocol, bytes memory routeData) { + bytes32 directKey = keccak256(abi.encodePacked(tokenA, tokenB)); + bytes32 reverseKey = keccak256(abi.encodePacked(tokenB, tokenA)); + + if (routes[directKey].isConfigured) { + protocol = routes[directKey].protocol; + routeData = routes[directKey].routeData; + } else if (routes[reverseKey].isConfigured) { + protocol = routes[reverseKey].protocol; + routeData = _encodeReverseRouteData(routes[reverseKey], tokenB, tokenA); + } else { + revert("No route found"); + } + } + + /** + * @notice Recursive multi-hop route discovery + * @dev Core algorithm for finding complex routes + */ + function _findMultiHopRouteRecursive( + address tokenIn, + address tokenOut, + uint256 remainingHops, + address[] memory visitedTokens + ) + internal + view + returns (bool found, address[] memory path, Protocol[] memory protocols, bytes[] memory routeDatas) + { + if (remainingHops == 0) return (false, new address[](0), new Protocol[](0), new bytes[](0)); + + // Prevent infinite loops + for (uint256 i = 0; i < visitedTokens.length; i++) { + if (visitedTokens[i] == tokenIn) { + return (false, new address[](0), new Protocol[](0), new bytes[](0)); + } + } + + // Get potential intermediate tokens based on asset type + address[] memory intermediateTokens = _getIntermediateTokens(tokenIn, tokenOut); + + for (uint256 i = 0; i < intermediateTokens.length; i++) { + address intermediate = intermediateTokens[i]; + + // Skip if already visited + bool alreadyVisited = false; + for (uint256 j = 0; j < visitedTokens.length; j++) { + if (visitedTokens[j] == intermediate) { + alreadyVisited = true; + break; + } + } + if (alreadyVisited) continue; + + // Check if there's a direct route from tokenIn to intermediate + bytes32 directKey = keccak256(abi.encodePacked(tokenIn, intermediate)); + bytes32 reverseKey = keccak256(abi.encodePacked(intermediate, tokenIn)); + + if (!routes[directKey].isConfigured && !routes[reverseKey].isConfigured) continue; + + // Create new visited array + address[] memory newVisited = new address[](visitedTokens.length + 1); + for (uint256 j = 0; j < visitedTokens.length; j++) { + newVisited[j] = visitedTokens[j]; + } + newVisited[visitedTokens.length] = tokenIn; + + // Recursively find route from intermediate to tokenOut + ( + bool foundNext, + address[] memory nextPath, + Protocol[] memory nextProtocols, + bytes[] memory nextRouteDatas + ) = _findMultiHopRouteRecursive(intermediate, tokenOut, remainingHops - 1, newVisited); + + if (foundNext) { + // Construct complete path + path = new address[](nextPath.length + 1); + path[0] = tokenIn; + for (uint256 j = 0; j < nextPath.length; j++) { + path[j + 1] = nextPath[j]; + } + + // Construct protocols + protocols = new Protocol[](nextProtocols.length + 1); + protocols[0] = routes[directKey].isConfigured + ? routes[directKey].protocol + : routes[reverseKey].protocol; + for (uint256 j = 0; j < nextProtocols.length; j++) { + protocols[j + 1] = nextProtocols[j]; + } + + // Construct route datas + routeDatas = new bytes[](nextRouteDatas.length + 1); + if (routes[directKey].isConfigured) { + routeDatas[0] = routes[directKey].routeData; + } else { + routeDatas[0] = _encodeReverseRouteData(routes[reverseKey], intermediate, tokenIn); + } + for (uint256 j = 0; j < nextRouteDatas.length; j++) { + routeDatas[j + 1] = nextRouteDatas[j]; + } + + return (true, path, protocols, routeDatas); + } + } + + return (false, new address[](0), new Protocol[](0), new bytes[](0)); + } + + /** + * @notice Get potential intermediate tokens (simplified and more reliable) + */ + function _getIntermediateTokens(address tokenIn, address tokenOut) internal view returns (address[] memory) { + AssetType typeIn = tokenIn == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenIn]; + AssetType typeOut = tokenOut == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenOut]; + + // Cross-category not allowed + if (typeIn != typeOut && !(typeIn == AssetType.ETH_LST && typeOut == AssetType.ETH_LST)) { + return new address[](0); + } + + if (typeIn == AssetType.ETH_LST) { + // ETH LST tokens - return all major tokens except input/output + address[] memory allCandidates = new address[](6); + allCandidates[0] = address(WETH); // Most liquid + allCandidates[1] = ETH_ADDRESS; // Native ETH + allCandidates[2] = RETH; // rETH + allCandidates[3] = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; // stETH + allCandidates[4] = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; // cbETH + allCandidates[5] = OSETH; // osETH + + // Filter out input and output tokens + uint256 validCount = 0; + for (uint256 i = 0; i < allCandidates.length; i++) { + if (allCandidates[i] != tokenIn && allCandidates[i] != tokenOut) { + validCount++; + } + } + + address[] memory result = new address[](validCount); + uint256 resultIndex = 0; + for (uint256 i = 0; i < allCandidates.length; i++) { + if (allCandidates[i] != tokenIn && allCandidates[i] != tokenOut) { + result[resultIndex++] = allCandidates[i]; + } + } + + return result; + } else if (typeIn == AssetType.BTC_WRAPPED) { + // BTC wrapped tokens - use WBTC as bridge + if (tokenIn != WBTC && tokenOut != WBTC) { + address[] memory result = new address[](1); + result[0] = WBTC; + return result; + } + } + + return new address[](0); + } + + /** + * @notice Get route configuration + */ + function getRoute(address tokenIn, address tokenOut) external view returns (RouteConfig memory) { + bytes32 routeKey = keccak256(abi.encodePacked(tokenIn, tokenOut)); + return routes[routeKey]; + } + + /** + * @notice Get pool status + */ + function getPoolStatus(address pool) external view returns (bool whitelisted, bool paused) { + return (poolWhitelist[pool], poolPaused[pool]); + } + + /** + * @notice Get protocol status + */ + function getProtocolStatus(Protocol protocol) external view returns (bool paused) { + return protocolPaused[protocol]; + } + + /** + * @notice Check if swap is cross-category (forbidden) + */ + function _isCrossCategory(address tokenIn, address tokenOut) internal view returns (bool) { + AssetType typeIn = tokenIn == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenIn]; + AssetType typeOut = tokenOut == ETH_ADDRESS ? AssetType.ETH_LST : assetTypes[tokenOut]; + + // Same type is always allowed + if (typeIn == typeOut) return false; + + // ETH is considered ETH_LST, so ETH <-> ETH_LST is allowed + if ( + (tokenIn == ETH_ADDRESS && tokenOut != address(0) && assetTypes[tokenOut] == AssetType.ETH_LST) || + (tokenOut == ETH_ADDRESS && tokenIn != address(0) && assetTypes[tokenIn] == AssetType.ETH_LST) + ) { + return false; + } + + // Everything else is cross-category + return true; + } + + /** + * @notice Validate multi-step route configuration + */ + function _validateMultiStepRoute( + address[] memory tokens, + Protocol[] memory protocols, + bytes[] memory routeDatas + ) internal view returns (bool) { + // Check array lengths + if (tokens.length < 2) return false; + if (protocols.length != tokens.length - 1) return false; + if (routeDatas.length != protocols.length) return false; + + // Validate each step doesn't create cross-category swap + for (uint256 i = 0; i < protocols.length; i++) { + if (_isCrossCategory(tokens[i], tokens[i + 1])) { + return false; + } + } + + return true; + } + + function emergencyWithdraw(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(paused(), "Not in emergency"); + if (token == ETH_ADDRESS) { + (bool success, ) = payable(to).call{value: amount}(""); + require(success, "ETH transfer failed"); + } else { + IERC20(token).safeTransfer(to, amount); + } + } +} \ No newline at end of file diff --git a/src/core/LiquidTokenManager.sol b/src/core/LiquidTokenManager.sol index f00dc2ab..6c8a8edc 100644 --- a/src/core/LiquidTokenManager.sol +++ b/src/core/LiquidTokenManager.sol @@ -18,6 +18,7 @@ import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; import {IStakerNode} from "../interfaces/IStakerNode.sol"; import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; import {ITokenRegistryOracle} from "../interfaces/ITokenRegistryOracle.sol"; +import "../interfaces/IFinalAutoRouting.sol"; /// @title LiquidTokenManager /// @notice Manages liquid tokens and their staking to EigenLayer strategies @@ -52,6 +53,9 @@ contract LiquidTokenManager is IStakerNodeCoordinator public stakerNodeCoordinator; ITokenRegistryOracle public tokenRegistryOracle; + /// @notice FinalAutoRouting contract for swap execution + IFinalAutoRouting public finalAutoRouting; + /// @notice Mapping of tokens to their corresponding token info mapping(IERC20 => TokenInfo) public tokens; @@ -64,6 +68,9 @@ contract LiquidTokenManager is /// @notice Array of supported token addresses IERC20[] public supportedTokens; + /// @notice Constant for ETH address representation + address private constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -101,7 +108,22 @@ contract LiquidTokenManager is } // ------------------------------------------------------------------------------ - // Core functions + // Admin functions + // ------------------------------------------------------------------------------ + + /// @notice Updates the FinalAutoRouting contract address + /// @param newFinalAutoRouting The new FAR contract address + function updateFinalAutoRouting(address newFinalAutoRouting) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newFinalAutoRouting == address(0)) revert ZeroAddress(); + + address oldFAR = address(finalAutoRouting); + finalAutoRouting = IFinalAutoRouting(newFinalAutoRouting); + + emit FinalAutoRoutingUpdated(oldFAR, newFinalAutoRouting, msg.sender); + } + + // ------------------------------------------------------------------------------ + // Core functions (existing - exactly as in initial version) // ------------------------------------------------------------------------------ /// @inheritdoc ILiquidTokenManager @@ -145,6 +167,7 @@ contract LiquidTokenManager is if (decimalsFromContract == 0) revert InvalidDecimals(); if (decimals != decimalsFromContract) revert InvalidDecimals(); } catch {} // Fallback to `decimals` if token contract doesn't implement `decimals()` + uint256 fetchedPrice; if (!isNative) { (uint256 price, bool ok) = tokenRegistryOracle._getTokenPrice_getter(address(token)); @@ -337,6 +360,194 @@ contract LiquidTokenManager is emit AssetsDepositedToEigenlayer(depositAssets, depositAmounts, strategiesForNode, address(node)); } + // ------------------------------------------------------------------------------ + // New Swap and Stake functions - Following colleague's exact specification + // ------------------------------------------------------------------------------ + + /// @inheritdoc ILiquidTokenManager + function swapAndStakeAssetsToNodes( + NodeAllocationWithSwap[] calldata allocationsWithSwaps + ) external onlyRole(STRATEGY_CONTROLLER_ROLE) nonReentrant { + for (uint256 i = 0; i < allocationsWithSwaps.length; i++) { + NodeAllocationWithSwap memory allocationWithSwap = allocationsWithSwaps[i]; + _swapAndStakeAssetsToNode( + allocationWithSwap.nodeId, + allocationWithSwap.assetsToSwap, + allocationWithSwap.amountsToSwap, + allocationWithSwap.assetsToStake + ); + } + } + + /// @inheritdoc ILiquidTokenManager + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) external onlyRole(STRATEGY_CONTROLLER_ROLE) nonReentrant { + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + /// @dev Called by `swapAndStakeAssetsToNode` and `swapAndStakeAssetsToNodes` + /// @dev Flow: LTM >> DEX >> LTM (using FAR for routing data) + function _swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) internal { + uint256 assetsLength = assetsToStake.length; + + if (assetsLength != assetsToSwap.length) { + revert LengthMismatch(assetsLength, assetsToSwap.length); + } + if (assetsLength != amountsToSwap.length) { + revert LengthMismatch(assetsLength, amountsToSwap.length); + } + + IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); + + // Find EigenLayer strategies for the given assets (using assetsToStake not assetsToSwap) + IStrategy[] memory strategiesForNode = new IStrategy[](assetsLength); + for (uint256 i = 0; i < assetsLength; i++) { + IERC20 asset = assetsToStake[i]; // using `assetsToStake` not `assetsToSwap` + if (amountsToSwap[i] == 0) { + revert InvalidStakingAmount(amountsToSwap[i]); + } + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + strategiesForNode[i] = strategy; + } + + // Bring unstaked assets in from `LiquidToken` + liquidToken.transferAssets(assetsToSwap, amountsToSwap); + + uint256[] memory amountsToStake = new uint256[](assetsLength); + + // Swap using FAR - for every tokenIn swap to corresponding tokenOut + for (uint256 i = 0; i < assetsLength; i++) { + address tokenIn = address(assetsToSwap[i]); + address tokenOut = address(assetsToStake[i]); + uint256 amountIn = amountsToSwap[i]; + + if (tokenIn == tokenOut) { + // No swap needed, direct stake + amountsToStake[i] = amountIn; + } else { + // Get swap plan from FAR + (uint256 quotedAmount, IFinalAutoRouting.MultiStepExecutionPlan memory plan) = finalAutoRouting + .getCompleteMultiStepPlan( + tokenIn, + tokenOut, + amountIn, + address(this) // LTM is the recipient + ); + + // Execute the swap plan step by step + uint256 actualAmountOut = _executeFARSwapPlan(tokenIn, tokenOut, amountIn, plan); + amountsToStake[i] = actualAmountOut; + + emit SwapExecuted(tokenIn, tokenOut, amountIn, actualAmountOut, nodeId); + } + } + + IERC20[] memory depositAssets = new IERC20[](assetsLength); + uint256[] memory depositAmounts = new uint256[](assetsLength); + + // Transfer assets to node + for (uint256 i = 0; i < assetsLength; i++) { + depositAssets[i] = assetsToStake[i]; + depositAmounts[i] = amountsToStake[i]; + assetsToStake[i].safeTransfer(address(node), amountsToStake[i]); + } + + emit AssetsSwappedAndStakedToNode( + nodeId, + assetsToSwap, + amountsToSwap, + assetsToStake, + amountsToStake, + msg.sender + ); + + // Call for node to deposit assets into EigenLayer + node.depositAssets(depositAssets, depositAmounts, strategiesForNode); + + emit AssetsDepositedToEigenlayer(depositAssets, depositAmounts, strategiesForNode, address(node)); + } + + /// @dev Executes a swap plan from FAR following LTM >> DEX >> LTM flow + /// @param tokenIn Input token address + /// @param tokenOut Output token address + /// @param amountIn Input amount + /// @param plan Execution plan from FAR + /// @return actualAmountOut The actual amount received from the swap + function _executeFARSwapPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + IFinalAutoRouting.MultiStepExecutionPlan memory plan + ) internal returns (uint256 actualAmountOut) { + require(plan.steps.length > 0, "Empty swap plan"); + require(address(finalAutoRouting) != address(0), "FAR not configured"); + + // Track balances before and after + uint256 initialBalance = tokenOut == ETH_ADDRESS + ? address(this).balance + : IERC20(tokenOut).balanceOf(address(this)); + + // Execute each step in the plan + for (uint256 i = 0; i < plan.steps.length; i++) { + IFinalAutoRouting.SwapStep memory step = plan.steps[i]; + + // Approve the target DEX to spend our tokens + if (step.tokenIn != ETH_ADDRESS) { + IERC20(step.tokenIn).safeApprove(step.target, 0); + IERC20(step.tokenIn).safeApprove(step.target, step.amountIn); + } + + // Execute the swap on the DEX + (bool success, bytes memory returnData) = step.target.call{value: step.value}(step.data); + + if (!success) { + // Decode revert reason if possible + if (returnData.length > 0) { + assembly { + let returnDataSize := mload(returnData) + revert(add(32, returnData), returnDataSize) + } + } else { + revert("Swap execution failed"); + } + } + + // Reset approval + if (step.tokenIn != ETH_ADDRESS) { + IERC20(step.tokenIn).safeApprove(step.target, 0); + } + } + + // Calculate actual output amount + uint256 finalBalance = tokenOut == ETH_ADDRESS + ? address(this).balance + : IERC20(tokenOut).balanceOf(address(this)); + + actualAmountOut = finalBalance - initialBalance; + + // Validate we received at least the minimum expected + IFinalAutoRouting.SwapStep memory lastStep = plan.steps[plan.steps.length - 1]; + require(actualAmountOut >= lastStep.minAmountOut, "Insufficient output amount"); + + return actualAmountOut; + } + + /// @notice Fallback to receive ETH from swaps + receive() external payable { + // Accept ETH from DEX swaps + } /// @dev OUT OF SCOPE FOR V1 /** function undelegateNodes( @@ -379,8 +590,8 @@ contract LiquidTokenManager is } return balances; } + */ - // ------------------------------------------------------------------------------ // Getter functions // ------------------------------------------------------------------------------ @@ -553,4 +764,4 @@ contract LiquidTokenManager is if (address(strategy) == address(0)) return false; return address(strategyTokens[strategy]) != address(0); } -} +} \ No newline at end of file diff --git a/src/interfaces/ICurvePool.sol b/src/interfaces/ICurvePool.sol new file mode 100644 index 00000000..df35e310 --- /dev/null +++ b/src/interfaces/ICurvePool.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface ICurvePool { + function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + + function get_dy(int128 i, int128 j, uint256 amount) external view returns (uint256); + function get_dy_underlying(int128 i, int128 j, uint256 dx) external returns (uint256 out); +} \ No newline at end of file diff --git a/src/interfaces/IFinalAutoRouting.sol b/src/interfaces/IFinalAutoRouting.sol new file mode 100644 index 00000000..50f14657 --- /dev/null +++ b/src/interfaces/IFinalAutoRouting.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IFinalAutoRouting { + enum Protocol { + UniswapV3, // 0 + Curve, // 1 + DirectMint, // 2 + MultiHop, // 3 + MultiStep // 4 + } + + enum RouteType { + Direct, // 0 + Reverse, // 1 + Bridge // 2 + } + + struct UniswapV3Route { + address pool; + uint24 fee; + bool isMultiHop; + bytes path; + } + + struct CurveRoute { + address pool; + int128 indexIn; + int128 indexOut; + bool useUnderlying; + } + + struct ExecutionStrategy { + RouteType routeType; + Protocol protocol; + address bridgeAsset; + bytes primaryRouteData; + bytes secondaryRouteData; + uint256 expectedGas; + } + + struct SwapStep { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + address target; + bytes data; + uint256 value; + Protocol protocol; + } + + struct MultiStepExecutionPlan { + SwapStep[] steps; + uint256 expectedFinalAmount; + } + + function ETH_ADDRESS() external view returns (address); + function uniswapRouter() external view returns (address); + + function getWETHRequirements( + address tokenIn, + address tokenOut, + Protocol protocol + ) external view returns (bool needsWrap, bool needsUnwrap, address wethAddress); + + function getQuoteAndExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedAmount, + bytes memory executionData, + uint8 protocol, + address targetContract, + uint256 value + ); + + function decodeComplexExecutionData( + bytes calldata complexData + ) + external + pure + returns (uint8 routeType, address firstTarget, bytes memory firstCalldata, bytes memory additionalData); + + function getBridgeSecondLegData( + address bridgeAsset, + address finalToken, + uint256 bridgeAmount, + uint256 originalMinOut, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool requiresApproval); + + function getNextStepExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes calldata fullRouteData, + uint256 stepIndex, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool isFinalStep); + + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external returns (uint256 quotedOutput, MultiStepExecutionPlan memory plan); +} \ No newline at end of file diff --git a/src/interfaces/IFrxETHMinter.sol b/src/interfaces/IFrxETHMinter.sol new file mode 100644 index 00000000..9c8e15ff --- /dev/null +++ b/src/interfaces/IFrxETHMinter.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IFrxETHMinter { + function submitAndDeposit( + address recipient + ) external payable returns (uint256 shares); +} \ No newline at end of file diff --git a/src/interfaces/ILiquidTokenManager.sol b/src/interfaces/ILiquidTokenManager.sol index 7f58785c..105c2c07 100644 --- a/src/interfaces/ILiquidTokenManager.sol +++ b/src/interfaces/ILiquidTokenManager.sol @@ -6,7 +6,8 @@ import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationMa import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; - +import {IFinalAutoRouting} from "../interfaces/IFinalAutoRouting.sol"; +import {IWETH} from "../interfaces/IWETH.sol"; import {ILiquidToken} from "./ILiquidToken.sol"; import {IStakerNodeCoordinator} from "./IStakerNodeCoordinator.sol"; import {ITokenRegistryOracle} from "./ITokenRegistryOracle.sol"; @@ -47,6 +48,14 @@ interface ILiquidTokenManager { uint256[] amounts; } + /// @notice Represents an allocation of assets to a node with swap + struct NodeAllocationWithSwap { + uint256 nodeId; + IERC20[] assetsToSwap; + uint256[] amountsToSwap; + IERC20[] assetsToStake; + } + // ============================================================================ // EVENTS // ============================================================================ @@ -95,6 +104,28 @@ interface ILiquidTokenManager { /// @notice Emitted when a token is removed from the registry event TokenRemoved(IERC20 indexed token, address indexed remover); + /// @notice Emitted when FinalAutoRouting contract is updated + event FinalAutoRoutingUpdated(address indexed oldFAR, address indexed newFAR, address updatedBy); + + /// @notice Emitted when assets are swapped and staked to a node + event AssetsSwappedAndStakedToNode( + uint256 indexed nodeId, + IERC20[] assetsSwapped, + uint256[] amountsSwapped, + IERC20[] assetsStaked, + uint256[] amountsStaked, + address indexed initiator + ); + + /// @notice Emitted when a swap is executed + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + uint256 indexed nodeId + ); + // ============================================================================ // CUSTOM ERRORS // ============================================================================ @@ -158,6 +189,10 @@ interface ILiquidTokenManager { /// @param init Initialization parameters function initialize(Init memory init) external; + /// @notice Updates the FinalAutoRouting contract address + /// @param newFinalAutoRouting The new FAR contract address + function updateFinalAutoRouting(address newFinalAutoRouting) external; + /// @notice Adds a new token to the registry and configures its price sources /// @param token Address of the token to add /// @param decimals Number of decimals for the token @@ -216,12 +251,21 @@ interface ILiquidTokenManager { /// @param allocations Array of NodeAllocation structs containing staking information function stakeAssetsToNodes(NodeAllocation[] calldata allocations) external; - /// @dev Out OF SCOPE FOR V1 - /** - function undelegateNodes( - uint256[] calldata nodeIds + /// @notice Swaps multiple assets and stakes them to multiple nodes + /// @param allocationsWithSwaps Array of node allocations with swap instructions + function swapAndStakeAssetsToNodes(NodeAllocationWithSwap[] calldata allocationsWithSwaps) external; + + /// @notice Swaps assets and stakes them to a single node + /// @param nodeId The node ID to stake to + /// @param assetsToSwap Array of input tokens to swap from + /// @param amountsToSwap Array of amounts to swap + /// @param assetsToStake Array of output tokens to receive and stake + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake ) external; - */ /// @notice Retrieves the list of supported tokens /// @return An array of addresses of supported tokens @@ -307,4 +351,8 @@ interface ILiquidTokenManager { /// @notice Returns the LiquidToken contract /// @return The ILiquidToken interface function liquidToken() external view returns (ILiquidToken); -} + + /// @notice Returns the FinalAutoRouting contract + /// @return The IFinalAutoRouting interface + function finalAutoRouting() external view returns (IFinalAutoRouting); +} \ No newline at end of file diff --git a/src/interfaces/IQuoterV2.sol b/src/interfaces/IQuoterV2.sol new file mode 100644 index 00000000..0bc13976 --- /dev/null +++ b/src/interfaces/IQuoterV2.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IQuoterV2 { + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountOut); +} \ No newline at end of file diff --git a/src/interfaces/ISfrxETH.sol b/src/interfaces/ISfrxETH.sol new file mode 100644 index 00000000..7c37b584 --- /dev/null +++ b/src/interfaces/ISfrxETH.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface ISfrxETH { + function deposit( + uint256 assets, + address receiver + ) external returns (uint256 shares); +} diff --git a/src/interfaces/IUniswapV3Quoter.sol b/src/interfaces/IUniswapV3Quoter.sol new file mode 100644 index 00000000..ea08a7c3 --- /dev/null +++ b/src/interfaces/IUniswapV3Quoter.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/** + * @title IUniswapV3Quoter + * @notice Interface for the Uniswap V3 Quoter contract + * @dev Used for getting swap quotes without executing trades + */ +interface IUniswapV3Quoter { + /** + * @notice Returns the amount out received for a given exact input swap without executing the swap + * @param tokenIn The token being swapped in + * @param tokenOut The token being swapped out + * @param fee The fee of the pool + * @param amountIn The desired input amount + * @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + * @return amountOut The amount of `tokenOut` that would be received + */ + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountOut); + + /** + * @notice Returns the amount out received for a given exact input but for a swap of a single pool + * @param path The path of the swap, i.e. each token pair and the pool fee + * @param amountIn The desired input amount + * @return amountOut The amount of the final output token that would be received + */ + function quoteExactInput( + bytes memory path, + uint256 amountIn + ) external returns (uint256 amountOut); + + /** + * @notice Returns the amount in required for a given exact output swap without executing the swap + * @param tokenIn The token being swapped in + * @param tokenOut The token being swapped out + * @param fee The fee of the pool + * @param amountOut The desired output amount + * @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + * @return amountIn The amount of `tokenIn` that would be required + */ + function quoteExactOutputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountOut, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountIn); + + /** + * @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool + * @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order + * @param amountOut The desired output amount + * @return amountIn The amount of the first input token that would be required + */ + function quoteExactOutput( + bytes memory path, + uint256 amountOut + ) external returns (uint256 amountIn); + + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + uint256 amountIn; + uint160 sqrtPriceLimitX96; + } +} \ No newline at end of file diff --git a/src/interfaces/IUniswapV3Router.sol b/src/interfaces/IUniswapV3Router.sol new file mode 100644 index 00000000..2577844a --- /dev/null +++ b/src/interfaces/IUniswapV3Router.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IUniswapV3Router { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable returns (uint256); + function exactInput( + ExactInputParams calldata params + ) external payable returns (uint256); +} \ No newline at end of file diff --git a/src/interfaces/IWETH.sol b/src/interfaces/IWETH.sol new file mode 100644 index 00000000..ab60a711 --- /dev/null +++ b/src/interfaces/IWETH.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IWETH { + function deposit() external payable; + function withdraw(uint256) external; + function balanceOf(address) external view returns (uint256); +} \ No newline at end of file diff --git a/test/LTMFARIntegrationtest.t.sol b/test/LTMFARIntegrationtest.t.sol new file mode 100644 index 00000000..65d76361 --- /dev/null +++ b/test/LTMFARIntegrationtest.t.sol @@ -0,0 +1,706 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +// Import contracts +import "../src/FinalAutoRouting.sol"; +import "./mocks/MockLiquidTokenManager.sol"; + +contract LTMFARIntegrationTest is Test { + // Contracts + FinalAutoRouting public far; + MockLiquidTokenManager public ltm; + + // Mainnet addresses + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant UNISWAP_V3_QUOTER = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6; + address constant CURVE_STETH_POOL = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022; + address constant FRXETH_MINTER = 0xbAFA44EFE7901E04E39Dad13167D089C559c1138; + + // Token addresses + address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address constant CBETH = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; + address constant RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + address constant FRXETH = 0x5E8422345238F34275888049021821E8E08CAa1f; + address constant SFRXETH = 0xac3E018457B222d93114458476f3E3416Abbe38F; + address constant OSETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + address constant UNIBTC = 0x004E9C3EF86bc1ca1f0bB5C7662861Ee93350568; + + // Pool addresses from your config + address constant WETH_CBETH_POOL = 0x840DEEef2f115Cf50DA625F7368C24af6fE74410; + address constant WETH_RETH_POOL = 0x553e9C493678d8606d6a5ba284643dB2110Df823; + address constant WETH_WBTC_POOL = 0xCBCdF9626bC03E24f779434178A73a0B4bad62eD; + address constant WBTC_UNIBTC_POOL = 0x109707Ad4AbD299b3cF6F2b011c2bff88523E2f0; + address constant RETH_OSETH_POOL = 0xe080027Bd47353b5D1639772b4a75E9Ed3658A0d; + address constant WETH_STETH_POOL = 0x63818BbDd21E69bE108A23aC1E84cBf66399Bd7D; + + // Test user + address user; + string constant PASSWORD = "[REDACTED]"; + + function setUp() public { + console.log("\n=== SETUP START ==="); + + // Fork mainnet + vm.createSelectFork("wss://eth.drpc.org"); + + user = makeAddr("user"); + + // Deploy contracts + _deployContracts(); + + // Initialize FAR + _initializeFAR(); + + // Configure essential routes for auto-routing tests + _configureMinimalRoutes(); + + // Get test assets + _getTestAssets(); + + console.log("=== SETUP COMPLETE ===\n"); + } + + function _deployContracts() internal { + // Compute FAR address + address predictedFARAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + bytes32 passwordHash = keccak256(abi.encode(PASSWORD, predictedFARAddress)); + + // Deploy FAR + far = new FinalAutoRouting( + WETH, + UNISWAP_V3_ROUTER, + UNISWAP_V3_QUOTER, + FRXETH_MINTER, + address(this), + passwordHash, + address(this), + false + ); + + // Deploy Mock LTM + ltm = new MockLiquidTokenManager(); + + // Initialize LTM + MockLiquidTokenManager.Init memory ltmInit = MockLiquidTokenManager.Init({ + strategyManager: address(0), + delegationManager: address(0), + liquidToken: address(0), + stakerNodeCoordinator: address(0), + tokenRegistryOracle: address(0), + initialOwner: address(this), + strategyController: address(this), + priceUpdater: address(this), + finalAutoRouting: address(far), + weth: WETH + }); + + ltm.initialize(ltmInit); + far.grantOperatorRole(address(ltm)); + } + + function _initializeFAR() internal { + // Token set matching your config + address[] memory tokens = new address[](8); + tokens[0] = ETH_ADDRESS; + tokens[1] = WETH; + tokens[2] = STETH; + tokens[3] = CBETH; + tokens[4] = RETH; + tokens[5] = OSETH; + tokens[6] = WBTC; + tokens[7] = UNIBTC; + + FinalAutoRouting.AssetType[] memory types = new FinalAutoRouting.AssetType[](8); + for (uint i = 0; i < 6; i++) types[i] = FinalAutoRouting.AssetType.ETH_LST; + types[6] = FinalAutoRouting.AssetType.BTC_WRAPPED; + types[7] = FinalAutoRouting.AssetType.BTC_WRAPPED; + + uint8[] memory decimals = new uint8[](8); + for (uint i = 0; i < 6; i++) decimals[i] = 18; + decimals[6] = 8; + decimals[7] = 8; + + // Essential pools matching your config + address[] memory pools = new address[](5); + pools[0] = CURVE_STETH_POOL; // ETH -> stETH (Curve) + pools[1] = WETH_CBETH_POOL; // UniswapV3 + pools[2] = WETH_RETH_POOL; // UniswapV3 + pools[3] = RETH_OSETH_POOL; // Curve + pools[4] = WETH_STETH_POOL; // UniswapV3 WETH-stETH + + uint256[] memory tokenCounts = new uint256[](5); + for (uint i = 0; i < 5; i++) tokenCounts[i] = 2; + + FinalAutoRouting.CurveInterface[] memory interfaces = new FinalAutoRouting.CurveInterface[](5); + interfaces[0] = FinalAutoRouting.CurveInterface.Exchange; // ETH-stETH Curve + interfaces[1] = FinalAutoRouting.CurveInterface.None; // UniswapV3 + interfaces[2] = FinalAutoRouting.CurveInterface.None; // UniswapV3 + interfaces[3] = FinalAutoRouting.CurveInterface.Exchange; // rETH-osETH Curve + interfaces[4] = FinalAutoRouting.CurveInterface.None; // WETH-stETH UniswapV3 + + // More conservative slippage configs + FinalAutoRouting.SlippageConfig[] memory slippages = new FinalAutoRouting.SlippageConfig[](12); + slippages[0] = FinalAutoRouting.SlippageConfig(ETH_ADDRESS, STETH, 500); // ETH->stETH + slippages[1] = FinalAutoRouting.SlippageConfig(WETH, CBETH, 1000); // Increased from 700 + slippages[2] = FinalAutoRouting.SlippageConfig(WETH, RETH, 1500); // Increased from 1300 + slippages[3] = FinalAutoRouting.SlippageConfig(STETH, WETH, 1000); // Increased from 700 + slippages[4] = FinalAutoRouting.SlippageConfig(CBETH, WETH, 1000); // Increased from 700 + slippages[5] = FinalAutoRouting.SlippageConfig(RETH, WETH, 1500); // Increased from 1300 + slippages[6] = FinalAutoRouting.SlippageConfig(RETH, OSETH, 1200); // Increased from 800 + slippages[7] = FinalAutoRouting.SlippageConfig(OSETH, RETH, 1200); // Increased from 800 + slippages[8] = FinalAutoRouting.SlippageConfig(STETH, CBETH, 1500); // Multi-step + slippages[9] = FinalAutoRouting.SlippageConfig(CBETH, RETH, 1500); // Multi-step + slippages[10] = FinalAutoRouting.SlippageConfig(WETH, OSETH, 1500); // Multi-step + slippages[11] = FinalAutoRouting.SlippageConfig(OSETH, WETH, 1500); // Multi-step + + far.initialize(tokens, types, decimals, pools, tokenCounts, interfaces, slippages); + } + + function _configureMinimalRoutes() internal { + // Configure routes matching your config exactly + + // 1. WETH <-> stETH (UniswapV3 with fee 10000) + far.configureRoute( + WETH, + STETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_STETH_POOL, + 10000, // Fee from your config + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + far.configureRoute( + STETH, + WETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_STETH_POOL, + 10000, // Fee from your config + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 2. WETH <-> cbETH (UniswapV3 with fee 500) + far.configureRoute( + WETH, + CBETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_CBETH_POOL, + 500, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + far.configureRoute( + CBETH, + WETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_CBETH_POOL, + 500, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 3. WETH <-> rETH (UniswapV3 with fee 100) + far.configureRoute( + WETH, + RETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_RETH_POOL, + 100, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + far.configureRoute( + RETH, + WETH, + FinalAutoRouting.Protocol.UniswapV3, + WETH_RETH_POOL, + 100, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 4. rETH <-> osETH (Curve) + far.configureRoute( + RETH, + OSETH, + FinalAutoRouting.Protocol.Curve, + RETH_OSETH_POOL, + 0, + [int128(1), int128(0)], // rETH index 1, osETH index 0 + false, + address(0), + PASSWORD + ); + + far.configureRoute( + OSETH, + RETH, + FinalAutoRouting.Protocol.Curve, + RETH_OSETH_POOL, + 0, + [int128(0), int128(1)], // osETH index 0, rETH index 1 + false, + address(0), + PASSWORD + ); + + // 5. ETH <-> stETH (Curve) - for setup + far.configureRoute( + ETH_ADDRESS, + STETH, + FinalAutoRouting.Protocol.Curve, + CURVE_STETH_POOL, + 0, + [int128(0), int128(1)], // ETH index 0, stETH index 1 + false, + address(0), + PASSWORD + ); + } + + function _getTestAssets() internal { + vm.deal(address(this), 10 ether); + + // Get WETH + IWETH(WETH).deposit{value: 5 ether}(); + + // Get stETH via Curve (ETH -> stETH) + ICurvePool(CURVE_STETH_POOL).exchange{value: 2 ether}(0, 1, 2 ether, 0); + + // Get cbETH via UniswapV3 + IERC20(WETH).approve(UNISWAP_V3_ROUTER, 1 ether); + IUniswapV3Router.ExactInputSingleParams memory params = IUniswapV3Router.ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: CBETH, + fee: 500, + recipient: address(this), + deadline: block.timestamp + 3600, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + IUniswapV3Router(UNISWAP_V3_ROUTER).exactInputSingle(params); + + // Get rETH via UniswapV3 + IERC20(WETH).approve(UNISWAP_V3_ROUTER, 1 ether); + IUniswapV3Router.ExactInputSingleParams memory rethParams = IUniswapV3Router.ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: RETH, + fee: 100, + recipient: address(this), + deadline: block.timestamp + 3600, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + IUniswapV3Router(UNISWAP_V3_ROUTER).exactInputSingle(rethParams); + + // Get osETH by converting some rETH via Curve + uint256 rethBalance = IERC20(RETH).balanceOf(address(this)); + if (rethBalance > 0.5 ether) { + IERC20(RETH).approve(RETH_OSETH_POOL, 0.5 ether); + ICurvePool(RETH_OSETH_POOL).exchange(1, 0, 0.5 ether, 0); // rETH index 1 -> osETH index 0 + } + } + + // Test 1: Auto-routing stETH -> cbETH (should find WETH bridge) + function testAutoRoutingStETHToCbETH() public { + console.log("\n=== Test: stETH -> cbETH Auto-routing (Bridge via WETH) ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Very conservative + + // Check route exists + assertTrue(far.hasRoute(STETH, CBETH), "Route should exist via bridge"); + + IERC20(STETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(1, CBETH); + + ltm.swapAndStake(STETH, CBETH, amountIn, 1, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(1, CBETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + + // Test 2: Auto-routing cbETH -> rETH (should find WETH bridge) + function testAutoRoutingCbETHToRETH() public { + console.log("\n=== Test: cbETH -> rETH Auto-routing (Bridge via WETH) ==="); + + uint256 amountIn = 0.3 ether; + uint256 minAmountOut = 0.15 ether; // Very conservative + + assertTrue(far.hasRoute(CBETH, RETH), "Route should exist via bridge"); + + IERC20(CBETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(2, RETH); + + ltm.swapAndStake(CBETH, RETH, amountIn, 2, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(2, RETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + + // Test 3: Multi-step auto-routing stETH -> osETH (via WETH -> rETH) + function testAutoRoutingStETHToOsETH() public { + console.log("\n=== Test: stETH -> osETH Multi-step Auto-routing ==="); + + // Use a smaller amount and account for potential 1-2 wei loss + uint256 amountIn = 0.1 ether; + uint256 minAmountOut = 0.04 ether; // Lower expectation + + assertTrue(far.hasRoute(STETH, OSETH), "Multi-step route should exist"); + + IERC20(STETH).approve(address(ltm), amountIn); + + uint256 osethBalanceBefore = ltm.mockStakedBalances(3, OSETH); + + ltm.swapAndStake(STETH, OSETH, amountIn, 3, minAmountOut); + + uint256 osethBalanceAfter = ltm.mockStakedBalances(3, OSETH); + uint256 amountStaked = osethBalanceAfter - osethBalanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + // Test 4: Reverse auto-routing osETH -> WETH + function testAutoRoutingOsETHToWETH() public { + console.log("\n=== Test: osETH -> WETH Reverse Auto-routing ==="); + + // Use the osETH we got in setup + uint256 osETHBalance = IERC20(OSETH).balanceOf(address(this)); + require(osETHBalance > 0, "No osETH balance available for test"); + + uint256 amountIn = osETHBalance / 2; // Use half of available balance + uint256 minAmountOut = (amountIn * 4) / 10; // Expect at least 40% due to multi-step slippage + + console.log("osETH balance:", osETHBalance); + console.log("Amount to swap:", amountIn); + console.log("Min amount out:", minAmountOut); + + IERC20(OSETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(4, WETH); + + ltm.swapAndStake(OSETH, WETH, amountIn, 4, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(4, WETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + + // Test 5: Complex multi-step cbETH -> osETH + function testAutoRoutingCbETHToOsETH() public { + console.log("\n=== Test: cbETH -> osETH Complex Auto-routing ==="); + + uint256 amountIn = 0.2 ether; + uint256 minAmountOut = 0.08 ether; // Very conservative for 3-step + + // Should find: cbETH -> WETH -> rETH -> osETH + assertTrue(far.hasRoute(CBETH, OSETH), "Complex route should exist"); + + IERC20(CBETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(5, OSETH); + + ltm.swapAndStake(CBETH, OSETH, amountIn, 5, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(5, OSETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + + // Test 6: Quote validation for auto-routed paths + function testAutoRoutingQuoteValidation() public { + console.log("\n=== Test: Auto-routing Quote Validation ==="); + + + // Test multi-step quote + (uint256 quote2, , , , ) = far.getQuoteAndExecutionData(CBETH, OSETH, 1 ether, address(ltm)); + console.log("cbETH -> osETH quote:", quote2); + assertGt(quote2, 0.4 ether, "Multi-step quote too low"); + + // Validate execution + (bool isValid, string memory reason, uint256 estimate) = far.validateSwapExecution( + STETH, + CBETH, + 1 ether, + 0.5 ether, + address(ltm) + ); + assertTrue(isValid, reason); + console.log("Validation passed with estimate:", estimate); + } + + // Test 7: Error handling for impossible routes + function testAutoRoutingErrors() public { + console.log("\n=== Test: Auto-routing Error Cases ==="); + + // Test cross-category (should fail) + address USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + vm.expectRevert(); + ltm.swapAndStake(WETH, USDC, 1 ether, 1, 0); + console.log(" Cross-category swap rejected"); + + // Test unsupported token + vm.expectRevert(); + ltm.swapAndStake(address(0x123), WETH, 1 ether, 1, 0); + console.log(" Unsupported token rejected"); + + // Test same token + vm.expectRevert(); + ltm.swapAndStake(WETH, WETH, 1 ether, 1, 0); + console.log(" Same token swap rejected"); + } + + + + function testFARRouteDiagnostics() public { + console.log("=== FAR Route Discovery Diagnostics ==="); + + // Test direct routes + console.log("Direct routes:"); + console.log("STETH->WETH:", far.hasRoute(STETH, WETH)); + console.log("WETH->RETH:", far.hasRoute(WETH, RETH)); + console.log("RETH->OSETH:", far.hasRoute(RETH, OSETH)); + console.log("CBETH->WETH:", far.hasRoute(CBETH, WETH)); + console.log("WETH->CBETH:", far.hasRoute(WETH, CBETH)); + + // Test bridge discovery + console.log("\nBridge routes:"); + console.log("STETH->OSETH:", far.hasRoute(STETH, OSETH)); + console.log("CBETH->OSETH:", far.hasRoute(CBETH, OSETH)); + console.log("STETH->CBETH:", far.hasRoute(STETH, CBETH)); + console.log("CBETH->RETH:", far.hasRoute(CBETH, RETH)); + console.log("OSETH->WETH:", far.hasRoute(OSETH, WETH)); + + // Test reverse routes + console.log("\nReverse routes:"); + console.log("WETH->STETH:", far.hasRoute(WETH, STETH)); + console.log("RETH->WETH:", far.hasRoute(RETH, WETH)); + console.log("OSETH->RETH:", far.hasRoute(OSETH, RETH)); + } + + function testIndividualRouteQuotes() public { + console.log("=== Individual Route Quote Testing ==="); + + // Test each leg of stETH->osETH path + console.log("Testing STETH->WETH..."); + (bool success1, bytes memory result1) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, STETH, WETH, 1 ether, address(this)) + ); + if (success1) { + (uint256 quote1, , , , ) = abi.decode(result1, (uint256, bytes, uint8, address, uint256)); + console.log(" STETH->WETH quote:", quote1); + } else { + console.log(" STETH->WETH failed"); + } + + console.log("Testing WETH->RETH..."); + (bool success2, bytes memory result2) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, WETH, RETH, 1 ether, address(this)) + ); + if (success2) { + (uint256 quote2, , , , ) = abi.decode(result2, (uint256, bytes, uint8, address, uint256)); + console.log(" WETH->RETH quote:", quote2); + } else { + console.log(" WETH->RETH failed"); + } + + console.log("Testing RETH->OSETH..."); + (bool success3, bytes memory result3) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, RETH, OSETH, 1 ether, address(this)) + ); + if (success3) { + (uint256 quote3, , , , ) = abi.decode(result3, (uint256, bytes, uint8, address, uint256)); + console.log(" RETH->OSETH quote:", quote3); + } else { + console.log(" RETH->OSETH failed"); + } + } + + function testBridgeRouteQuotes() public { + console.log("=== Bridge Route Quote Testing ==="); + + console.log("Testing STETH->OSETH..."); + (bool success1, bytes memory result1) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, STETH, OSETH, 1 ether, address(this)) + ); + if (success1) { + (uint256 quote1, , uint8 protocol1, , ) = abi.decode(result1, (uint256, bytes, uint8, address, uint256)); + console.log(" STETH->OSETH quote:", quote1); + console.log(" Protocol:", protocol1); + } else { + console.log(" STETH->OSETH failed"); + } + + console.log("Testing CBETH->OSETH..."); + (bool success2, bytes memory result2) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, CBETH, OSETH, 1 ether, address(this)) + ); + if (success2) { + (uint256 quote2, , uint8 protocol2, , ) = abi.decode(result2, (uint256, bytes, uint8, address, uint256)); + console.log(" CBETH->OSETH quote:", quote2); + console.log(" Protocol:", protocol2); + } else { + console.log(" CBETH->OSETH failed"); + } + + console.log("Testing OSETH->WETH..."); + (bool success3, bytes memory result3) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, OSETH, WETH, 1 ether, address(this)) + ); + if (success3) { + (uint256 quote3, , uint8 protocol3, , ) = abi.decode(result3, (uint256, bytes, uint8, address, uint256)); + console.log(" OSETH->WETH quote:", quote3); + console.log(" Protocol:", protocol3); + } else { + console.log(" OSETH->WETH failed"); + } + } + + function testSlippageAnalysis() public { + console.log("=== Slippage Analysis ==="); + + // Test the cbETH->rETH route + console.log("Testing cbETH->rETH with 0.3 ether..."); + (bool success, bytes memory result) = address(far).call( + abi.encodeWithSelector(far.getQuoteAndExecutionData.selector, CBETH, RETH, 0.3 ether, address(this)) + ); + + if (success) { + (uint256 quote, , , , ) = abi.decode(result, (uint256, bytes, uint8, address, uint256)); + console.log("Quote:", quote); + console.log("Input:", 0.3 ether); + + uint256 slippageBps = ((0.3 ether - quote) * 10000) / 0.3 ether; + console.log("Slippage:", slippageBps, "bps"); + + // Test what min amounts would work + console.log("Would pass with:"); + console.log(" 10% slippage (270000000000000000):", quote >= 270000000000000000); + console.log(" 15% slippage (255000000000000000):", quote >= 255000000000000000); + console.log(" 20% slippage (240000000000000000):", quote >= 240000000000000000); + console.log(" 30% slippage (210000000000000000):", quote >= 210000000000000000); + console.log(" 40% slippage (180000000000000000):", quote >= 180000000000000000); + console.log(" 50% slippage (150000000000000000):", quote >= 150000000000000000); + } else { + console.log(" cbETH->rETH failed completely"); + } + } + + function testMultiStepPlanGeneration() public { + console.log("=== Multi-Step Plan Generation ==="); + + console.log("Testing STETH->OSETH getCompleteMultiStepPlan..."); + (bool success1, bytes memory result1) = address(far).call( + abi.encodeWithSelector(far.getCompleteMultiStepPlan.selector, STETH, OSETH, 1 ether, address(this)) + ); + + if (success1) { + console.log(" STETH->OSETH plan generated successfully"); + } else { + console.log(" STETH->OSETH plan generation failed"); + } + + console.log("Testing CBETH->OSETH getCompleteMultiStepPlan..."); + (bool success2, bytes memory result2) = address(far).call( + abi.encodeWithSelector(far.getCompleteMultiStepPlan.selector, CBETH, OSETH, 1 ether, address(this)) + ); + + if (success2) { + console.log(" CBETH->OSETH plan generated successfully"); + } else { + console.log(" CBETH->OSETH plan generation failed"); + } + + console.log("Testing OSETH->WETH getCompleteMultiStepPlan..."); + (bool success3, bytes memory result3) = address(far).call( + abi.encodeWithSelector(far.getCompleteMultiStepPlan.selector, OSETH, WETH, 1 ether, address(this)) + ); + + if (success3) { + console.log(" OSETH->WETH plan generated successfully"); + } else { + console.log(" OSETH->WETH plan generation failed"); + } + } + + function testAutoRoutingCbETHToWETHViaStETH() public { + console.log("\n=== Test: cbETH -> WETH (via stETH route) ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Lower expectation + + IERC20(CBETH).approve(address(ltm), amountIn); + + // Check WETH balance before - should be done differently + uint256 wethBalanceBefore = ltm.mockStakedBalances(2, WETH); + + ltm.swapAndStake(CBETH, WETH, amountIn, 2, minAmountOut); + + uint256 wethBalanceAfter = ltm.mockStakedBalances(2, WETH); + uint256 amountReceived = wethBalanceAfter - wethBalanceBefore; + + console.log(string.concat("WETH received: ", Strings.toString(amountReceived))); + assertGe(amountReceived, minAmountOut, "Output too low"); + } + + function testAutoRoutingWETHToStETH() public { + console.log("\n=== Test: WETH -> stETH Auto-routing ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Lower expectation due to fees + + assertTrue(far.hasRoute(WETH, STETH), "Route should exist"); + + IERC20(WETH).approve(address(ltm), amountIn); + + // Use mock staked balance instead of direct balance check + uint256 stethBalanceBefore = ltm.mockStakedBalances(1, STETH); + + ltm.swapAndStake(WETH, STETH, amountIn, 1, minAmountOut); + + uint256 stethBalanceAfter = ltm.mockStakedBalances(1, STETH); + uint256 amountReceived = stethBalanceAfter - stethBalanceBefore; + + console.log(string.concat("stETH received: ", Strings.toString(amountReceived))); + assertGe(amountReceived, minAmountOut, "Output too low"); + } + + receive() external payable {} +} \ No newline at end of file diff --git a/test/LiquidTokenManager.t.sol b/test/LiquidTokenManager.t.sol index ba8d9bde..04ca67a8 100644 --- a/test/LiquidTokenManager.t.sol +++ b/test/LiquidTokenManager.t.sol @@ -21,11 +21,16 @@ import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISigna import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {MockFinalAutoRouting, MockSwapExecutor} from "./mocks/MockFar.sol"; +import {IFinalAutoRouting} from "./mocks/MockFar.sol"; + contract LiquidTokenManagerTest is BaseTest { IStakerNode public stakerNode; bool public isLocalTestNetwork; event TokenRemoved(IERC20 indexed token, address indexed remover); - + // Add FAR integration properties + MockFinalAutoRouting public mockFAR; + MockSwapExecutor public mockExecutor; // For token oracle admin - needed for various tests bytes32 internal constant ORACLE_ADMIN_ROLE = keccak256("ORACLE_ADMIN_ROLE"); bytes32 internal constant RATE_UPDATER_ROLE = keccak256("RATE_UPDATER_ROLE"); @@ -249,6 +254,60 @@ contract LiquidTokenManagerTest is BaseTest { "After setup - testToken2 supported:", liquidTokenManager.tokenIsSupported(IERC20(address(testToken2))) ); + + setupFARIntegration(); + } + function setupFARIntegration() public payable { + console.log("Setting up FAR integration for LTM tests..."); + + // Deploy mock FAR and executor + mockFAR = new MockFinalAutoRouting(); + mockExecutor = new MockSwapExecutor(); + + // Configure FAR in LTM + vm.startPrank(admin); + liquidTokenManager.updateFinalAutoRouting(address(mockFAR)); + vm.stopPrank(); + + // Add our test tokens to FAR (from BaseTest) + mockFAR.addSupportedToken(address(testToken), 18); + mockFAR.addSupportedToken(address(testToken2), 18); + + // Configure swap routes between test tokens + // testToken -> testToken2: 0.95 rate (1 TEST = 0.95 TEST2) + mockFAR.setMockRate( + address(testToken), + address(testToken2), + 0.95e18, + IFinalAutoRouting.Protocol.UniswapV3, + address(mockExecutor) + ); + + // testToken2 -> testToken: 1.05 rate (1 TEST2 = 1.05 TEST) + mockFAR.setMockRate( + address(testToken2), + address(testToken), + 1.05e18, + IFinalAutoRouting.Protocol.UniswapV3, + address(mockExecutor) + ); + + // Set slippage settings + mockFAR.setSlippage(address(testToken), address(testToken2), 50); // 0.5% + mockFAR.setSlippage(address(testToken2), address(testToken), 50); // 0.5% + + // Fund the mock executor (DEX) with tokens for swaps + testToken.mint(address(mockExecutor), 10000 ether); + testToken2.mint(address(mockExecutor), 10000 ether); + + // Fund liquidToken for testing + testToken.mint(address(liquidToken), 1000 ether); + testToken2.mint(address(liquidToken), 1000 ether); + + console.log("FAR integration setup completed"); + console.log("MockFAR address:", address(mockFAR)); + console.log("MockExecutor (DEX) address:", address(mockExecutor)); + console.log("LiquidTokenManager address:", address(liquidTokenManager)); } // Helper function to ensure a node is delegated (only on test networks) function _ensureNodeIsDelegated(uint256 nodeId) internal { @@ -1570,6 +1629,415 @@ contract LiquidTokenManagerTest is BaseTest { assertTrue(address(testToken2Feed) != address(0), "Test token 2 feed should exist"); } + // ================= FAR INTEGRATION TESTS ================= + + function testFARIntegrationBasicSetup() public { + // Verify FAR is properly configured + assertEq(address(liquidTokenManager.finalAutoRouting()), address(mockFAR), "FAR not set correctly"); + assertTrue(mockFAR.hasRoute(address(testToken), address(testToken2)), "Route not configured"); + + // Test quote functionality + (uint256 quotedAmount, , , address targetContract, ) = mockFAR.getQuoteAndExecutionData( + address(testToken), + address(testToken2), + 10 ether, + address(liquidTokenManager) + ); + + assertGt(quotedAmount, 0, "Quote should be greater than 0"); + assertEq(targetContract, address(mockExecutor), "Target should be mockExecutor"); + } + + function testSwapAndStakeWithMockFAR() public { + // Skip if no staker node + if (!isLocalTestNetwork || address(stakerNode) == address(0)) { + console.log("Skipping testSwapAndStakeWithMockFAR - no staker node available"); + return; + } + + _ensureNodeIsDelegated(0); + + // Prepare test data + IERC20[] memory assetsToSwap = new IERC20[](1); + assetsToSwap[0] = IERC20(address(testToken)); + + uint256[] memory amountsToSwap = new uint256[](1); + amountsToSwap[0] = 10 ether; + + IERC20[] memory assetsToStake = new IERC20[](1); + assetsToStake[0] = IERC20(address(testToken2)); + + // Ensure liquidToken has testToken balance + uint256 liquidTokenBalance = testToken.balanceOf(address(liquidToken)); + console.log("LiquidToken testToken balance:", liquidTokenBalance); + + // Record initial balances + uint256 initialTestToken2Balance = testToken2.balanceOf(address(liquidTokenManager)); + uint256 initialNodeBalance = testToken2.balanceOf(address(stakerNode)); + + // Execute swap and stake + vm.startPrank(admin); + liquidTokenManager.swapAndStakeAssetsToNode(0, assetsToSwap, amountsToSwap, assetsToStake); + vm.stopPrank(); + + // Verify swap occurred - LTM should have received testToken2 + uint256 finalTestToken2Balance = testToken2.balanceOf(address(liquidTokenManager)); + console.log("LTM received from swap:", finalTestToken2Balance - initialTestToken2Balance); + + // Verify stake occurred - node should have received testToken2 + uint256 finalNodeBalance = testToken2.balanceOf(address(stakerNode)); + assertGt(finalNodeBalance, initialNodeBalance, "Node should have received tokens"); + } + + function testSwapAndStakeSameToken() public { + // Test when no swap is needed (same token in and out) + if (!isLocalTestNetwork || address(stakerNode) == address(0)) { + console.log("Skipping testSwapAndStakeSameToken - no staker node available"); + return; + } + + _ensureNodeIsDelegated(0); + + IERC20[] memory assetsToSwap = new IERC20[](1); + assetsToSwap[0] = IERC20(address(testToken)); + + uint256[] memory amountsToSwap = new uint256[](1); + amountsToSwap[0] = 5 ether; + + IERC20[] memory assetsToStake = new IERC20[](1); + assetsToStake[0] = IERC20(address(testToken)); // Same token + + uint256 initialNodeBalance = testToken.balanceOf(address(stakerNode)); + + vm.startPrank(admin); + liquidTokenManager.swapAndStakeAssetsToNode(0, assetsToSwap, amountsToSwap, assetsToStake); + vm.stopPrank(); + + uint256 finalNodeBalance = testToken.balanceOf(address(stakerNode)); + assertEq(finalNodeBalance - initialNodeBalance, 5 ether, "Node should receive exact amount when no swap"); + } + + function testSwapAndStakeMultipleAssets() public { + if (!isLocalTestNetwork || address(stakerNode) == address(0)) { + console.log("Skipping testSwapAndStakeMultipleAssets - no staker node available"); + return; + } + + _ensureNodeIsDelegated(0); + + // Test with multiple assets - one needs swap, one doesn't + IERC20[] memory assetsToSwap = new IERC20[](2); + assetsToSwap[0] = IERC20(address(testToken)); + assetsToSwap[1] = IERC20(address(testToken2)); + + uint256[] memory amountsToSwap = new uint256[](2); + amountsToSwap[0] = 5 ether; + amountsToSwap[1] = 3 ether; + + IERC20[] memory assetsToStake = new IERC20[](2); + assetsToStake[0] = IERC20(address(testToken2)); // Needs swap + assetsToStake[1] = IERC20(address(testToken2)); // No swap needed + + vm.startPrank(admin); + liquidTokenManager.swapAndStakeAssetsToNode(0, assetsToSwap, amountsToSwap, assetsToStake); + vm.stopPrank(); + + // Verify node received tokens + uint256 nodeBalance = testToken2.balanceOf(address(stakerNode)); + assertGt(nodeBalance, 0, "Node should have received testToken2"); + } + + function testSwapExecutionFlow() public { + // Test the actual flow: LiquidToken -> LTM -> DEX -> LTM -> Node + + // Setup amounts + uint256 swapAmount = 10 ether; + + // Fund LTM with tokens directly for this test + testToken.mint(address(liquidTokenManager), swapAmount); + + // Record initial balances at each step + uint256 liquidTokenInitial = testToken.balanceOf(address(liquidToken)); + uint256 ltmInitial = testToken.balanceOf(address(liquidTokenManager)); + uint256 executorInitial = testToken.balanceOf(address(mockExecutor)); + + console.log("=== Initial Balances ==="); + console.log("LiquidToken testToken:", liquidTokenInitial); + console.log("LTM testToken:", ltmInitial); + console.log("Executor testToken:", executorInitial); + + // Get quote first + (uint256 quotedAmount, bytes memory executionData, , address target, ) = mockFAR.getQuoteAndExecutionData( + address(testToken), + address(testToken2), + swapAmount, + address(liquidTokenManager) + ); + + console.log("Quoted output:", quotedAmount); + console.log("Target DEX:", target); + + // Simulate the swap execution that would happen inside LTM + vm.startPrank(address(liquidTokenManager)); + + // 1. LTM approves DEX + testToken.approve(address(mockExecutor), swapAmount); + + // 2. LTM calls DEX + (bool success, ) = target.call(executionData); + assertTrue(success, "Swap execution should succeed"); + + vm.stopPrank(); + + // Verify final balances + uint256 ltmFinalTestToken = testToken.balanceOf(address(liquidTokenManager)); + uint256 ltmFinalTestToken2 = testToken2.balanceOf(address(liquidTokenManager)); + + console.log("LTM final testToken:", ltmFinalTestToken); + console.log("LTM received testToken2:", ltmFinalTestToken2); + + assertEq(ltmFinalTestToken, 0, "LTM should have spent all testToken"); + assertGt(ltmFinalTestToken2, 0, "LTM should have received testToken2"); + + // Verify the amount received (accounting for 0.1% fee in MockSwapExecutor) + uint256 expectedOutput = (swapAmount * 9990) / 10000; + assertEq(ltmFinalTestToken2, expectedOutput, "Output amount should match expected"); + } + + function testFARErrorHandling() public { + // Test error cases + + // 1. No route available + MockERC20 unsupportedToken = new MockERC20("Unsupported", "UNS"); + + IERC20[] memory assetsToSwap = new IERC20[](1); + assetsToSwap[0] = IERC20(address(unsupportedToken)); + + uint256[] memory amountsToSwap = new uint256[](1); + amountsToSwap[0] = 1 ether; + + IERC20[] memory assetsToStake = new IERC20[](1); + assetsToStake[0] = IERC20(address(testToken2)); + + vm.startPrank(admin); + vm.expectRevert(); // Should revert due to no route + liquidTokenManager.swapAndStakeAssetsToNode(0, assetsToSwap, amountsToSwap, assetsToStake); + vm.stopPrank(); + } + + //More complex tests : + + function testSwapValidation() public { + console.log("=== Testing Swap Validation ==="); + + // Test various validation scenarios + vm.startPrank(address(liquidTokenManager)); + + // 1. Valid swap + (bool isValid, string memory reason, uint256 estimatedOutput) = mockFAR.validateSwapExecution( + address(testToken), + address(testToken2), + 10 ether, + 9 ether, + address(liquidTokenManager) + ); + assertTrue(isValid, "Valid swap should pass validation"); + assertGt(estimatedOutput, 0, "Should return estimated output"); + + // 2. Zero amount + (isValid, reason, ) = mockFAR.validateSwapExecution( + address(testToken), + address(testToken2), + 0, + 0, + address(liquidTokenManager) + ); + assertFalse(isValid, "Zero amount should fail"); + assertEq(reason, "Zero amount", "Should return zero amount reason"); + + // 3. Same token swap + (isValid, reason, ) = mockFAR.validateSwapExecution( + address(testToken), + address(testToken), + 10 ether, + 10 ether, + address(liquidTokenManager) + ); + assertFalse(isValid, "Same token swap should fail"); + assertEq(reason, "Same token swap", "Should return same token reason"); + + // 4. Insufficient output + (isValid, reason, estimatedOutput) = mockFAR.validateSwapExecution( + address(testToken), + address(testToken2), + 10 ether, + 20 ether, // Requesting more than possible + address(liquidTokenManager) + ); + assertFalse(isValid, "Should fail when minAmountOut too high"); + assertEq(reason, "Output below minimum", "Should return output below minimum reason"); + + vm.stopPrank(); + } + + function testCompleteExecutionPlan() public { + console.log("=== Testing Complete Execution Plan ==="); + + vm.prank(address(liquidTokenManager)); + ( + uint256 quotedOutput, + uint256 minAmountOut, + IFinalAutoRouting.ExecutionStep[] memory steps, + uint256 totalGas, + uint256 ethValue + ) = mockFAR.getCompleteExecutionPlan( + address(testToken), + address(testToken2), + 5 ether, + address(liquidTokenManager) + ); + + console.log("Quoted output:", quotedOutput); + console.log("Min amount out:", minAmountOut); + console.log("Number of steps:", steps.length); + console.log("Total gas estimate:", totalGas); + console.log("ETH value:", ethValue); + + // Verify execution plan + assertEq(steps.length, 1, "Should have single step"); + assertEq(steps[0].tokenIn, address(testToken), "Token in should be testToken"); + assertEq(steps[0].tokenOut, address(testToken2), "Token out should be testToken2"); + assertTrue(steps[0].requiresApproval, "Should require approval for ERC20"); + assertEq(steps[0].value, 0, "Should have 0 ETH value for ERC20 swap"); + assertEq(steps[0].target, address(mockExecutor), "Target should be mockExecutor"); + + // Verify slippage calculation (0.5% slippage) + uint256 expectedQuoted = (5 ether * 0.95e18) / 1e18; + uint256 expectedMinOut = (expectedQuoted * 9950) / 10000; + assertEq(quotedOutput, expectedQuoted, "Quoted output incorrect"); + assertEq(minAmountOut, expectedMinOut, "Min amount out incorrect"); + } + + function testSwapWithInsufficientLiquidity() public { + console.log("=== Testing Swap With Insufficient Liquidity ==="); + + // Create new tokens with no liquidity in executor + MockERC20 tokenA = new MockERC20("Token A", "TKA"); + MockERC20 tokenB = new MockERC20("Token B", "TKB"); + + mockFAR.addSupportedToken(address(tokenA), 18); + mockFAR.addSupportedToken(address(tokenB), 18); + mockFAR.setMockRate( + address(tokenA), + address(tokenB), + 1e18, + IFinalAutoRouting.Protocol.UniswapV3, + address(mockExecutor) + ); + + // Fund LTM with tokenA + tokenA.mint(address(liquidTokenManager), 10 ether); + + // Try to execute swap - should fail due to insufficient tokenB in executor + vm.startPrank(address(liquidTokenManager)); + tokenA.approve(address(mockExecutor), 10 ether); + + (uint256 quotedAmount, bytes memory executionData, , address target, ) = mockFAR.getQuoteAndExecutionData( + address(tokenA), + address(tokenB), + 10 ether, + address(liquidTokenManager) + ); + + (bool success, ) = target.call(executionData); + assertFalse(success, "Swap should fail due to insufficient liquidity"); + + vm.stopPrank(); + } + + function testBridgeSecondLegData() public { + console.log("=== Testing Bridge Second Leg Data ==="); + + vm.prank(address(liquidTokenManager)); + (bytes memory executionData, address targetContract, bool requiresApproval) = mockFAR.getBridgeSecondLegData( + address(testToken), + address(testToken2), + 5 ether, + 4.5 ether, + address(liquidTokenManager) + ); + + console.log("Target contract:", targetContract); + console.log("Requires approval:", requiresApproval); + console.log("Execution data length:", executionData.length); + + assertEq(targetContract, address(mockExecutor), "Target should be mockExecutor"); + assertTrue(requiresApproval, "Should require approval for ERC20"); + assertGt(executionData.length, 0, "Should have execution data"); + } + + function testETHSwapHandling() public { + console.log("=== Testing ETH Swap Handling ==="); + + // Add ETH support to FAR + address ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + mockFAR.addSupportedToken(ETH_ADDRESS, 18); + + // Set up ETH -> testToken2 route + mockFAR.setMockRate( + ETH_ADDRESS, + address(testToken2), + 2000e18, // 1 ETH = 2000 TEST2 + IFinalAutoRouting.Protocol.UniswapV3, + address(mockExecutor) + ); + + // Fund executor with ETH + vm.deal(address(mockExecutor), 100 ether); + + vm.prank(address(liquidTokenManager)); + (uint256 quotedOutput, , IFinalAutoRouting.ExecutionStep[] memory steps, , uint256 ethValue) = mockFAR + .getCompleteExecutionPlan(ETH_ADDRESS, address(testToken2), 1 ether, address(liquidTokenManager)); + + console.log("ETH swap quoted output:", quotedOutput); + console.log("ETH value required:", ethValue); + + assertEq(ethValue, 1 ether, "Should require 1 ETH value"); + assertFalse(steps[0].requiresApproval, "ETH should not require approval"); + assertEq(quotedOutput, 2000e18, "Should return correct quote for ETH"); + } + + function testGetNextStepExecutionData() public { + console.log("=== Testing Get Next Step Execution Data ==="); + + vm.prank(address(liquidTokenManager)); + (bytes memory executionData, address targetContract, bool isFinalStep) = mockFAR.getNextStepExecutionData( + address(testToken), + address(testToken2), + 10 ether, + "", // Empty route data for single step + 0, // First step + address(liquidTokenManager) + ); + + console.log("Target contract:", targetContract); + console.log("Is final step:", isFinalStep); + console.log("Execution data length:", executionData.length); + + assertEq(targetContract, address(mockExecutor), "Target should be mockExecutor"); + assertTrue(isFinalStep, "Should be final step for direct swap"); + assertGt(executionData.length, 0, "Should have execution data"); + } + // Helper function to fund liquidToken for tests + function _fundLiquidToken(address token, uint256 amount) internal { + MockERC20(token).mint(address(liquidToken), amount); + } + + // Helper to check swap execution event + function _expectSwapExecutedEvent(address tokenIn, address tokenOut, uint256 amountIn, uint256 nodeId) internal { + vm.expectEmit(true, true, true, false); + emit ILiquidTokenManager.SwapExecuted(tokenIn, tokenOut, amountIn, 0, nodeId); + } /// Tests for withdrawal functionality that will be implemented in future versions /// OUT OF SCOPE FOR V1 /** @@ -2399,4 +2867,4 @@ contract LiquidTokenManagerTest is BaseTest { } return upgradeableTokens; } -} +} \ No newline at end of file diff --git a/test/common/BaseTest.sol b/test/common/BaseTest.sol index d279b165..d08ca5d4 100644 --- a/test/common/BaseTest.sol +++ b/test/common/BaseTest.sol @@ -27,6 +27,7 @@ import {IStakerNode} from "../../src/interfaces/IStakerNode.sol"; import {ILiquidToken} from "../../src/interfaces/ILiquidToken.sol"; import {ITokenRegistryOracle} from "../../src/interfaces/ITokenRegistryOracle.sol"; import {ILiquidTokenManager} from "../../src/interfaces/ILiquidTokenManager.sol"; +import {IFinalAutoRouting} from "../../src/interfaces/IFinalAutoRouting.sol"; // Added import import {NetworkAddresses} from "../utils/NetworkAddresses.sol"; contract BaseTest is Test { @@ -52,6 +53,9 @@ contract BaseTest is Test { StakerNodeCoordinator public stakerNodeCoordinator; StakerNode public stakerNodeImplementation; + // Mock FAR contract for testing - Added + IFinalAutoRouting public mockFinalAutoRouting; + // Mock contracts - base test tokens MockERC20 public testToken; MockERC20 public testToken2; @@ -273,6 +277,9 @@ contract BaseTest is Test { // Deploy price feed mocks with realistic values for test tokens testTokenFeed = new MockChainlinkFeed(int256(100000000), 8); // 1 ETH per TEST (8 decimals) testToken2Feed = new MockChainlinkFeed(int256(50000000), 8); // 0.5 ETH per TEST2 (8 decimals) + + // Deploy mock FAR contract - Added + mockFinalAutoRouting = IFinalAutoRouting(address(0xDEAD)); // Placeholder address for now } function _deployMainContracts() private { @@ -287,9 +294,16 @@ contract BaseTest is Test { tokenRegistryOracle = TokenRegistryOracle( address(new TransparentUpgradeableProxy(address(_tokenRegistryOracleImplementation), proxyAdminAddress, "")) ); + + // Fixed: Use payable() for LiquidTokenManager since it has receive() function liquidTokenManager = LiquidTokenManager( - address(new TransparentUpgradeableProxy(address(_liquidTokenManagerImplementation), proxyAdminAddress, "")) + payable( + address( + new TransparentUpgradeableProxy(address(_liquidTokenManagerImplementation), proxyAdminAddress, "") + ) + ) ); + liquidToken = LiquidToken( address(new TransparentUpgradeableProxy(address(_liquidTokenImplementation), proxyAdminAddress, "")) ); @@ -396,6 +410,11 @@ contract BaseTest is Test { vm.startPrank(deployer); liquidTokenManager.grantRole(liquidTokenManager.DEFAULT_ADMIN_ROLE(), address(this)); liquidTokenManager.grantRole(liquidTokenManager.STRATEGY_CONTROLLER_ROLE(), address(this)); + + // Update FAR address if mockFinalAutoRouting is set - Added + if (address(mockFinalAutoRouting) != address(0) && address(mockFinalAutoRouting) != address(0xDEAD)) { + liquidTokenManager.updateFinalAutoRouting(address(mockFinalAutoRouting)); + } vm.stopPrank(); } @@ -592,4 +611,41 @@ contract BaseTest is Test { function _createMockFailingOracle() internal returns (MockFailingOracle) { return new MockFailingOracle(); } -} + + // ================= NEW SWAP AND STAKE HELPER METHODS - Added ================= + + /** + * @dev Sets a mock FAR contract for testing swap functionality + */ + function _setMockFinalAutoRouting(address mockFAR) internal { + mockFinalAutoRouting = IFinalAutoRouting(mockFAR); + + // Update in LiquidTokenManager if it's already initialized + if (address(liquidTokenManager) != address(0)) { + vm.prank(deployer); + liquidTokenManager.updateFinalAutoRouting(mockFAR); + } + } + + /** + * @dev Helper to test swap and stake functionality with mock data + */ + function _testSwapAndStake( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) internal { + // This would be overridden in actual test contracts that set up proper mocks + vm.startPrank(deployer); + liquidTokenManager.swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + vm.stopPrank(); + } + + /** + * @dev Helper to get current FAR address + */ + function _getFinalAutoRoutingAddress() internal view returns (address) { + return address(liquidTokenManager.finalAutoRouting()); + } +} \ No newline at end of file diff --git a/test/mocks/MockFar.sol b/test/mocks/MockFar.sol new file mode 100644 index 00000000..2350a6e7 --- /dev/null +++ b/test/mocks/MockFar.sol @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +// Mock interfaces for FAR integration +interface IFinalAutoRouting { + enum Protocol { + UniswapV3, + Curve, + DirectMint, + MultiHop, + MultiStep + } + + struct SwapStep { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + address target; + bytes data; + uint256 value; + Protocol protocol; + } + + struct MultiStepExecutionPlan { + SwapStep[] steps; + uint256 expectedFinalAmount; + } + + struct ExecutionStep { + address target; + uint256 value; + bytes data; + address tokenIn; + address tokenOut; + bool requiresApproval; + bool isCurvePool; + } + + function getQuoteAndExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedAmount, + bytes memory executionData, + Protocol protocol, + address targetContract, + uint256 value + ); + + function getCompleteExecutionPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedOutput, + uint256 minAmountOut, + ExecutionStep[] memory steps, + uint256 totalGas, + uint256 ethValue + ); + + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external returns (uint256 totalQuotedAmount, MultiStepExecutionPlan memory plan); + + function getBridgeSecondLegData( + address bridgeAsset, + address finalToken, + uint256 bridgeAmount, + uint256 originalMinOut, + address recipient + ) external returns (bytes memory executionData, address targetContract, bool requiresApproval); + + function getNextStepExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes calldata fullRouteData, + uint256 stepIndex, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool isFinalStep); + + function validateSwapExecution( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address executor + ) external view returns (bool isValid, string memory reason, uint256 estimatedOutput); + + function hasRoute(address tokenIn, address tokenOut) external view returns (bool); +} + +// Mock FinalAutoRouting contract for testing +contract MockFinalAutoRouting is IFinalAutoRouting { + using SafeERC20 for IERC20; + + // Mock storage for routes and rates + mapping(address => mapping(address => uint256)) public mockRates; + mapping(address => mapping(address => bool)) public routeExists; + mapping(address => mapping(address => Protocol)) public routeProtocols; + mapping(address => mapping(address => address)) public routeTargets; + mapping(address => mapping(address => uint256)) public slippageSettings; + mapping(address => bool) public supportedTokens; + mapping(address => uint8) public tokenDecimals; + + // Mock balances for swaps + mapping(address => uint256) public mockBalances; + + // Events + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + address indexed recipient + ); + + event RouteConfigured(address indexed tokenIn, address indexed tokenOut, Protocol protocol, address target); + + // Errors + error NoRouteFound(); + error TokenNotSupported(); + error ZeroAmount(); + error SameTokenSwap(); + error InsufficientOutput(); + error SwapFailed(string reason); + + // Constants + address public constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + constructor() { + // Set up some default supported tokens + supportedTokens[ETH_ADDRESS] = true; + supportedTokens[WETH] = true; + tokenDecimals[ETH_ADDRESS] = 18; + tokenDecimals[WETH] = 18; + } + + // Configuration functions + function setMockRate(address tokenIn, address tokenOut, uint256 rate, Protocol protocol, address target) external { + mockRates[tokenIn][tokenOut] = rate; + routeExists[tokenIn][tokenOut] = true; + routeProtocols[tokenIn][tokenOut] = protocol; + routeTargets[tokenIn][tokenOut] = target; + + emit RouteConfigured(tokenIn, tokenOut, protocol, target); + } + + function setSlippage(address tokenIn, address tokenOut, uint256 slippageBps) external { + slippageSettings[tokenIn][tokenOut] = slippageBps; + } + + function addSupportedToken(address token, uint8 decimals) external { + supportedTokens[token] = true; + tokenDecimals[token] = decimals; + } + + function fundMockBalance(address token, uint256 amount) external { + mockBalances[token] = amount; + if (token != ETH_ADDRESS) { + // For ERC20 tokens, we need actual balance + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + } + } + + // Main integration functions + function getQuoteAndExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + override + returns ( + uint256 quotedAmount, + bytes memory executionData, + Protocol protocol, + address targetContract, + uint256 value + ) + { + _validateSwapInputs(tokenIn, tokenOut, amountIn); + + if (!routeExists[tokenIn][tokenOut]) { + revert NoRouteFound(); + } + + // Calculate quoted amount + quotedAmount = _calculateQuote(tokenIn, tokenOut, amountIn); + + // Get protocol and target + protocol = routeProtocols[tokenIn][tokenOut]; + targetContract = routeTargets[tokenIn][tokenOut]; + + // Generate execution data + executionData = _generateExecutionData(tokenIn, tokenOut, amountIn, quotedAmount, recipient, protocol); + + // Set ETH value + value = (tokenIn == ETH_ADDRESS) ? amountIn : 0; + } + + function getCompleteExecutionPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + override + returns ( + uint256 quotedOutput, + uint256 minAmountOut, + ExecutionStep[] memory steps, + uint256 totalGas, + uint256 ethValue + ) + { + _validateSwapInputs(tokenIn, tokenOut, amountIn); + + if (!routeExists[tokenIn][tokenOut]) { + revert NoRouteFound(); + } + + quotedOutput = _calculateQuote(tokenIn, tokenOut, amountIn); + minAmountOut = _calculateMinOutput(tokenIn, tokenOut, quotedOutput); + + // Create single step execution plan + steps = new ExecutionStep[](1); + steps[0] = ExecutionStep({ + target: routeTargets[tokenIn][tokenOut], + value: (tokenIn == ETH_ADDRESS) ? amountIn : 0, + data: _generateExecutionData( + tokenIn, + tokenOut, + amountIn, + quotedOutput, + recipient, + routeProtocols[tokenIn][tokenOut] + ), + tokenIn: tokenIn, + tokenOut: tokenOut, + requiresApproval: tokenIn != ETH_ADDRESS, + isCurvePool: routeProtocols[tokenIn][tokenOut] == Protocol.Curve + }); + + totalGas = 150000; // Mock gas estimate + ethValue = (tokenIn == ETH_ADDRESS) ? amountIn : 0; + } + + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external override returns (uint256 totalQuotedAmount, MultiStepExecutionPlan memory plan) { + _validateSwapInputs(tokenIn, tokenOut, amountIn); + + // For mock, create simple 2-step plan if direct route doesn't exist + if (routeExists[tokenIn][tokenOut]) { + // Single step + totalQuotedAmount = _calculateQuote(tokenIn, tokenOut, amountIn); + + plan.steps = new SwapStep[](1); + plan.steps[0] = SwapStep({ + tokenIn: tokenIn, + tokenOut: tokenOut, + amountIn: amountIn, + minAmountOut: _calculateMinOutput(tokenIn, tokenOut, totalQuotedAmount), + target: routeTargets[tokenIn][tokenOut], + data: _generateExecutionData( + tokenIn, + tokenOut, + amountIn, + totalQuotedAmount, + recipient, + routeProtocols[tokenIn][tokenOut] + ), + value: (tokenIn == ETH_ADDRESS) ? amountIn : 0, + protocol: routeProtocols[tokenIn][tokenOut] + }); + + plan.expectedFinalAmount = totalQuotedAmount; + } else { + // Try bridge route through WETH + address bridgeAsset = WETH; + + if (routeExists[tokenIn][bridgeAsset] && routeExists[bridgeAsset][tokenOut]) { + uint256 bridgeAmount = _calculateQuote(tokenIn, bridgeAsset, amountIn); + totalQuotedAmount = _calculateQuote(bridgeAsset, tokenOut, bridgeAmount); + + plan.steps = new SwapStep[](2); + + // First step + plan.steps[0] = SwapStep({ + tokenIn: tokenIn, + tokenOut: bridgeAsset, + amountIn: amountIn, + minAmountOut: _calculateMinOutput(tokenIn, bridgeAsset, bridgeAmount), + target: routeTargets[tokenIn][bridgeAsset], + data: _generateExecutionData( + tokenIn, + bridgeAsset, + amountIn, + bridgeAmount, + recipient, + routeProtocols[tokenIn][bridgeAsset] + ), + value: (tokenIn == ETH_ADDRESS) ? amountIn : 0, + protocol: routeProtocols[tokenIn][bridgeAsset] + }); + + // Second step + plan.steps[1] = SwapStep({ + tokenIn: bridgeAsset, + tokenOut: tokenOut, + amountIn: bridgeAmount, + minAmountOut: _calculateMinOutput(bridgeAsset, tokenOut, totalQuotedAmount), + target: routeTargets[bridgeAsset][tokenOut], + data: _generateExecutionData( + bridgeAsset, + tokenOut, + bridgeAmount, + totalQuotedAmount, + recipient, + routeProtocols[bridgeAsset][tokenOut] + ), + value: 0, + protocol: routeProtocols[bridgeAsset][tokenOut] + }); + + plan.expectedFinalAmount = totalQuotedAmount; + } else { + revert NoRouteFound(); + } + } + } + + function getBridgeSecondLegData( + address bridgeAsset, + address finalToken, + uint256 bridgeAmount, + uint256 originalMinOut, + address recipient + ) external override returns (bytes memory executionData, address targetContract, bool requiresApproval) { + if (!routeExists[bridgeAsset][finalToken]) { + revert NoRouteFound(); + } + + uint256 quotedAmount = _calculateQuote(bridgeAsset, finalToken, bridgeAmount); + uint256 minAmountOut = _calculateMinOutput(bridgeAsset, finalToken, quotedAmount); + + // Use the higher of calculated min or original min + if (originalMinOut > minAmountOut) { + minAmountOut = originalMinOut; + } + + targetContract = routeTargets[bridgeAsset][finalToken]; + executionData = _generateExecutionData( + bridgeAsset, + finalToken, + bridgeAmount, + quotedAmount, + recipient, + routeProtocols[bridgeAsset][finalToken] + ); + requiresApproval = bridgeAsset != ETH_ADDRESS; + } + + function getNextStepExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes calldata fullRouteData, + uint256 stepIndex, + address recipient + ) external view override returns (bytes memory executionData, address targetContract, bool isFinalStep) { + if (!routeExists[tokenIn][tokenOut]) { + revert NoRouteFound(); + } + + // For mock, assume single step + isFinalStep = true; + targetContract = routeTargets[tokenIn][tokenOut]; + + uint256 quotedAmount = _calculateQuote(tokenIn, tokenOut, amountIn); + executionData = _generateExecutionData( + tokenIn, + tokenOut, + amountIn, + quotedAmount, + recipient, + routeProtocols[tokenIn][tokenOut] + ); + } + + function validateSwapExecution( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address executor + ) external view override returns (bool isValid, string memory reason, uint256 estimatedOutput) { + // Basic validation + if (!supportedTokens[tokenIn]) { + return (false, "Input token not supported", 0); + } + + if (!supportedTokens[tokenOut]) { + return (false, "Output token not supported", 0); + } + + if (amountIn == 0) { + return (false, "Zero amount", 0); + } + + if (tokenIn == tokenOut) { + return (false, "Same token swap", 0); + } + + if (!routeExists[tokenIn][tokenOut]) { + return (false, "No route found", 0); + } + + estimatedOutput = _calculateQuote(tokenIn, tokenOut, amountIn); + + if (estimatedOutput < minAmountOut) { + return (false, "Output below minimum", estimatedOutput); + } + + return (true, "Valid", estimatedOutput); + } + + function hasRoute(address tokenIn, address tokenOut) external view override returns (bool) { + return routeExists[tokenIn][tokenOut]; + } + + // Mock swap execution for testing + function mockSwap( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) external payable returns (uint256 amountOut) { + _validateSwapInputs(tokenIn, tokenOut, amountIn); + + if (!routeExists[tokenIn][tokenOut]) { + revert NoRouteFound(); + } + + amountOut = _calculateQuote(tokenIn, tokenOut, amountIn); + + if (amountOut < minAmountOut) { + revert InsufficientOutput(); + } + + // Handle token transfers + if (tokenIn == ETH_ADDRESS) { + require(msg.value >= amountIn, "Insufficient ETH"); + } else { + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + } + + // Transfer output tokens + if (tokenOut == ETH_ADDRESS) { + (bool success, ) = payable(recipient).call{value: amountOut}(""); + require(success, "ETH transfer failed"); + } else { + IERC20(tokenOut).safeTransfer(recipient, amountOut); + } + + emit SwapExecuted(tokenIn, tokenOut, amountIn, amountOut, recipient); + } + + // Internal helper functions + function _validateSwapInputs(address tokenIn, address tokenOut, uint256 amountIn) internal view { + if (amountIn == 0) revert ZeroAmount(); + if (tokenIn == tokenOut) revert SameTokenSwap(); + if (!supportedTokens[tokenIn]) revert TokenNotSupported(); + if (!supportedTokens[tokenOut]) revert TokenNotSupported(); + } + + function _calculateQuote(address tokenIn, address tokenOut, uint256 amountIn) internal view returns (uint256) { + uint256 rate = mockRates[tokenIn][tokenOut]; + if (rate == 0) { + // Default 1:1 rate with decimal adjustment + rate = _getDecimalAdjustedRate(tokenIn, tokenOut); + } + return (amountIn * rate) / 1e18; + } + + function _calculateMinOutput( + address tokenIn, + address tokenOut, + uint256 quotedAmount + ) internal view returns (uint256) { + uint256 slippage = slippageSettings[tokenIn][tokenOut]; + if (slippage == 0) { + slippage = 50; // Default 0.5% slippage + } + return (quotedAmount * (10000 - slippage)) / 10000; + } + + function _getDecimalAdjustedRate(address tokenIn, address tokenOut) internal view returns (uint256) { + uint8 decimalsIn = tokenDecimals[tokenIn]; + uint8 decimalsOut = tokenDecimals[tokenOut]; + + if (decimalsIn == decimalsOut) { + return 1e18; // 1:1 rate + } else if (decimalsIn > decimalsOut) { + return 1e18 / (10 ** (decimalsIn - decimalsOut)); + } else { + return 1e18 * (10 ** (decimalsOut - decimalsIn)); + } + } + + function _generateExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 quotedAmount, + address recipient, + Protocol protocol + ) internal view returns (bytes memory) { + // Generate execution data that calls the mock executor's executeSwap function + return + abi.encodeWithSelector( + MockSwapExecutor.executeSwap.selector, + tokenIn, + tokenOut, + amountIn, + (quotedAmount * 9950) / 10000, // 0.5% slippage + recipient + ); + } + + // Allow contract to receive ETH + receive() external payable {} +} + +// Mock executor for testing actual swaps +contract MockSwapExecutor { + using SafeERC20 for IERC20; + + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + address indexed recipient + ); + + // Mock balances for testing + mapping(address => uint256) public mockBalances; + + function fundBalance(address token, uint256 amount) external { + mockBalances[token] = amount; + if (token != 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE) { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + } + } + + function executeSwap( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) external payable returns (uint256 amountOut) { + // Simple 1:1 swap with 0.1% fee + amountOut = (amountIn * 9990) / 10000; + + require(amountOut >= minAmountOut, "Insufficient output"); + + // Handle input token + if (tokenIn == 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE) { + require(msg.value >= amountIn, "Insufficient ETH"); + } else { + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + } + + // Handle output token + if (tokenOut == 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE) { + (bool success, ) = payable(recipient).call{value: amountOut}(""); + require(success, "ETH transfer failed"); + } else { + IERC20(tokenOut).safeTransfer(recipient, amountOut); + } + + emit SwapExecuted(tokenIn, tokenOut, amountIn, amountOut, recipient); + } + + receive() external payable {} +} \ No newline at end of file diff --git a/test/mocks/MockFrxETHMinter.sol b/test/mocks/MockFrxETHMinter.sol new file mode 100644 index 00000000..3e05b6ec --- /dev/null +++ b/test/mocks/MockFrxETHMinter.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IFrxETHMinter} from "../../src/interfaces/IFrxETHMinter.sol"; + +contract MockFrxETHMinter is IFrxETHMinter { + uint256 public mockShares = 1e18; // Default 1:1 ratio + + function setMockShares(uint256 _shares) external { + mockShares = _shares; + } + + function submitAndDeposit(address recipient) external payable override returns (uint256 shares) { + // Mock implementation - return shares based on msg.value + shares = (msg.value * mockShares) / 1e18; + return shares; + } +} \ No newline at end of file diff --git a/test/mocks/MockLiquidTokenManager.sol b/test/mocks/MockLiquidTokenManager.sol new file mode 100644 index 00000000..df5c9dd1 --- /dev/null +++ b/test/mocks/MockLiquidTokenManager.sol @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "../../src/interfaces/IFinalAutoRouting.sol"; +import "forge-std/console.sol"; + +contract MockLiquidTokenManager is ReentrancyGuard { + using SafeERC20 for IERC20; + + // State + IFinalAutoRouting public finalAutoRouting; + address public weth; + mapping(uint256 => mapping(address => uint256)) public mockStakedBalances; + + // Constants + address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address constant STETH_ADDRESS = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + // Events - matching the interface + event FinalAutoRoutingUpdated(address indexed oldFAR, address indexed newFAR, address updatedBy); + event AssetsSwappedAndStakedToNode( + uint256 indexed nodeId, + IERC20[] assetsSwapped, + uint256[] amountsSwapped, + IERC20[] assetsStaked, + uint256[] amountsStaked, + address indexed initiator + ); + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + uint256 indexed nodeId + ); + + // Structs - matching the interface + struct Init { + address strategyManager; + address delegationManager; + address liquidToken; + address stakerNodeCoordinator; + address tokenRegistryOracle; + address initialOwner; + address strategyController; + address priceUpdater; + address finalAutoRouting; + address weth; + } + + struct NodeAllocationWithSwap { + uint256 nodeId; + IERC20[] assetsToSwap; + uint256[] amountsToSwap; + IERC20[] assetsToStake; + } + + // Initialize + function initialize(Init memory init) external { + require(address(finalAutoRouting) == address(0), "Already initialized"); + finalAutoRouting = IFinalAutoRouting(init.finalAutoRouting); + weth = init.weth; + } + + // Admin function to update FAR + function updateFinalAutoRouting(address newFinalAutoRouting) external { + require(newFinalAutoRouting != address(0), "Zero address"); + address oldFAR = address(finalAutoRouting); + finalAutoRouting = IFinalAutoRouting(newFinalAutoRouting); + emit FinalAutoRoutingUpdated(oldFAR, newFinalAutoRouting, msg.sender); + } + + // Main functions following the 3-function pattern from your colleague + + /// @notice Swaps multiple assets and stakes them to multiple nodes + function swapAndStakeAssetsToNodes(NodeAllocationWithSwap[] calldata allocationsWithSwaps) external nonReentrant { + for (uint256 i = 0; i < allocationsWithSwaps.length; i++) { + NodeAllocationWithSwap memory allocationWithSwap = allocationsWithSwaps[i]; + _swapAndStakeAssetsToNode( + allocationWithSwap.nodeId, + allocationWithSwap.assetsToSwap, + allocationWithSwap.amountsToSwap, + allocationWithSwap.assetsToStake + ); + } + } + + /// @notice Swaps assets and stakes them to a single node + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) external nonReentrant { + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + /// @dev Called by `swapAndStakeAssetsToNode` and `swapAndStakeAssetsToNodes` + /// @dev Flow: MockLTM >> DEX >> MockLTM (using FAR for routing data) + function _swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) internal { + uint256 assetsLength = assetsToStake.length; + + require(assetsLength == assetsToSwap.length, "Assets length mismatch"); + require(assetsLength == amountsToSwap.length, "Amounts length mismatch"); + require(address(finalAutoRouting) != address(0), "FAR not configured"); + + console.log("\n[MockLTM] SwapAndStakeAssetsToNode called:"); + console.log("Node ID:", nodeId); + console.log("Assets to swap:", assetsLength); + + // Mock: Simulate bringing assets from LiquidToken + console.log("[MockLTM] Simulating asset retrieval from LiquidToken..."); + + uint256[] memory amountsToStake = new uint256[](assetsLength); + + // Swap using FAR - for every tokenIn swap to corresponding tokenOut + for (uint256 i = 0; i < assetsLength; i++) { + address tokenIn = address(assetsToSwap[i]); + address tokenOut = address(assetsToStake[i]); + uint256 amountIn = amountsToSwap[i]; + + console.log("\n[MockLTM] Processing swap", i + 1, "of", assetsLength); + console.log("Token In:", tokenIn); + console.log("Token Out:", tokenOut); + console.log("Amount In:", amountIn); + + require(amountIn > 0, "Invalid swap amount"); + + if (tokenIn == tokenOut) { + // No swap needed, direct stake + amountsToStake[i] = amountIn; + console.log("Direct stake (no swap needed)"); + } else { + // Execute swap using FAR + uint256 actualAmountOut = _executeFARSwapPlan(tokenIn, tokenOut, amountIn); + amountsToStake[i] = actualAmountOut; + + emit SwapExecuted(tokenIn, tokenOut, amountIn, actualAmountOut, nodeId); + } + } + + // Mock: Simulate transferring assets to node and staking + console.log("\n[MockLTM] Simulating asset transfer to node and staking..."); + for (uint256 i = 0; i < assetsLength; i++) { + address tokenAddress = address(assetsToStake[i]); + uint256 amount = amountsToStake[i]; + + // Mock staking - just track the balance + mockStakedBalances[nodeId][tokenAddress] += amount; + + console.log("Staked", amount, "of"); + console.log(tokenAddress, "to node", nodeId); + } + + emit AssetsSwappedAndStakedToNode( + nodeId, + assetsToSwap, + amountsToSwap, + assetsToStake, + amountsToStake, + msg.sender + ); + + console.log("[MockLTM] SwapAndStakeAssetsToNode completed successfully"); + } + + /// @dev Executes a swap plan from FAR following MockLTM >> DEX >> MockLTM flow + function _executeFARSwapPlan( + address tokenIn, + address tokenOut, + uint256 amountIn + ) internal returns (uint256 actualAmountOut) { + console.log("\n[MockLTM] Executing FAR swap plan:"); + console.log("Token In:", tokenIn); + console.log("Token Out:", tokenOut); + console.log("Amount In:", amountIn); + + // Execute step by step with dynamic planning + return _executeStepByStepSwap(tokenIn, tokenOut, amountIn); + } + + /// @dev Execute swap step by step, regenerating execution data for each step + function _executeStepByStepSwap(address tokenIn, address tokenOut, uint256 amountIn) internal returns (uint256) { + console.log("\n[MockLTM] Starting step-by-step swap execution"); + + address currentTokenIn = tokenIn; + uint256 currentAmountIn = amountIn; + uint256 totalSteps = 0; + + // Handle stETH input precision issue at the beginning + if (currentTokenIn == STETH_ADDRESS) { + // Measure actual balance after transfer + uint256 actualBalance = IERC20(STETH_ADDRESS).balanceOf(address(this)); + console.log("stETH requested:", currentAmountIn); + console.log("stETH actual balance:", actualBalance); + + // Use actual balance if it's less than requested (precision loss) + if (actualBalance < currentAmountIn) { + currentAmountIn = actualBalance; + console.log("Adjusted stETH amount to:", currentAmountIn); + } + } + + while (currentTokenIn != tokenOut) { + totalSteps++; + console.log("\n[MockLTM] Step", totalSteps); + console.log("Current token:", currentTokenIn); + console.log("Target token:", tokenOut); + console.log("Current amount:", currentAmountIn); + + // CRITICAL: Always get fresh execution plan with current amount + // This ensures the swap data matches the actual amount we have + (uint256 quotedOutput, IFinalAutoRouting.MultiStepExecutionPlan memory plan) = finalAutoRouting + .getCompleteMultiStepPlan(currentTokenIn, tokenOut, currentAmountIn, address(this)); + + require(plan.steps.length > 0, "No steps in plan"); + + // Use the first step + IFinalAutoRouting.SwapStep memory firstStep = plan.steps[0]; + + console.log("Next token:", firstStep.tokenOut); + console.log("Expected out:", firstStep.minAmountOut); + console.log("Target contract:", firstStep.target); + + // Execute the step + uint256 actualOut = _executeStep( + firstStep.tokenIn, + firstStep.tokenOut, + firstStep.amountIn, + firstStep.minAmountOut, + firstStep.data, + firstStep.target, + firstStep.value + ); + + console.log("Actual output:", actualOut); + + // Update for next iteration + currentTokenIn = firstStep.tokenOut; + currentAmountIn = actualOut; + + // Safety check to prevent infinite loops + require(totalSteps <= 5, "Too many steps"); + } + + console.log("[MockLTM] Step-by-step swap completed successfully"); + return currentAmountIn; + } + + /// @dev Execute a single swap step + function _executeStep( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + bytes memory swapData, + address targetContract, + uint256 value + ) internal returns (uint256) { + console.log("\n[MockLTM] Executing step:"); + console.log("Token in:", tokenIn); + console.log("Token out:", tokenOut); + console.log("Amount in:", amountIn); + console.log("Min amount out:", minAmountOut); + console.log("Target:", targetContract); + + // Approve tokens if needed + if (tokenIn != ETH_ADDRESS && targetContract != address(0)) { + IERC20(tokenIn).safeApprove(targetContract, 0); + IERC20(tokenIn).safeApprove(targetContract, amountIn); + console.log("Approved tokens for swap"); + } + + // Get balance before swap + uint256 balanceBefore = _getBalance(tokenOut); + console.log("Balance before:", balanceBefore); + + // Execute the swap + (bool success, bytes memory result) = targetContract.call{value: value}(swapData); + + if (!success) { + if (result.length > 0) { + assembly { + let size := mload(result) + revert(add(32, result), size) + } + } else { + revert("Swap execution failed"); + } + } + + // Reset approval + if (tokenIn != ETH_ADDRESS && targetContract != address(0)) { + IERC20(tokenIn).safeApprove(targetContract, 0); + } + + // Calculate actual output + uint256 balanceAfter = _getBalance(tokenOut); + uint256 actualOutput = balanceAfter - balanceBefore; + + console.log("Balance after:", balanceAfter); + console.log("Actual output:", actualOutput); + + // Verify minimum output with tolerance for rebasing tokens + if (tokenOut == STETH_ADDRESS) { + // For stETH, allow 2 wei tolerance + require(actualOutput + 2 >= minAmountOut, "Step output too low"); + } else { + require(actualOutput >= minAmountOut, "Step output too low"); + } + + console.log("Step executed successfully"); + return actualOutput; + } + + // Legacy function for backward compatibility (if needed) + function swapAndStake( + address tokenIn, + address targetAsset, + uint256 amountIn, + uint256 nodeId, + uint256 minAmountOut + ) external payable nonReentrant { + require(amountIn > 0, "Zero amount"); + require(tokenIn != targetAsset, "Same token"); + + // Convert to new format + IERC20[] memory assetsToSwap = new IERC20[](1); + uint256[] memory amountsToSwap = new uint256[](1); + IERC20[] memory assetsToStake = new IERC20[](1); + + assetsToSwap[0] = IERC20(tokenIn); + amountsToSwap[0] = amountIn; + assetsToStake[0] = IERC20(targetAsset); + + // Handle ETH input + if (tokenIn == ETH_ADDRESS) { + require(msg.value == amountIn, "ETH value mismatch"); + } else { + require(msg.value == 0, "Unexpected ETH"); + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + } + + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + // Helper to get token balance + function _getBalance(address token) internal view returns (uint256) { + if (token == ETH_ADDRESS) { + return address(this).balance; + } else { + return IERC20(token).balanceOf(address(this)); + } + } + + // Getter functions for testing + function getStakedBalance(uint256 nodeId, address token) external view returns (uint256) { + return mockStakedBalances[nodeId][token]; + } + + function getFinalAutoRouting() external view returns (address) { + return address(finalAutoRouting); + } + + // Receive ETH + receive() external payable {} +} \ No newline at end of file diff --git a/test/mocks/MockStrategy.sol b/test/mocks/MockStrategy.sol index b11b9e6f..ca012765 100644 --- a/test/mocks/MockStrategy.sol +++ b/test/mocks/MockStrategy.sol @@ -28,4 +28,4 @@ contract MockStrategy is StrategyBase { function _afterWithdrawal(address recipient, IERC20 token, uint256 amountToSend) internal virtual override { token.safeTransfer(recipient, amountToSend); } -} +} \ No newline at end of file diff --git a/test/mocks/MockUniswapV3Quoter.sol b/test/mocks/MockUniswapV3Quoter.sol new file mode 100644 index 00000000..58ae1edd --- /dev/null +++ b/test/mocks/MockUniswapV3Quoter.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IUniswapV3Quoter} from "../../src/interfaces/IUniswapV3Quoter.sol"; + +contract MockUniswapV3Quoter is IUniswapV3Quoter { + uint256 public mockQuoteAmount = 1000e18; + + function setMockQuote(uint256 _amount) external { + mockQuoteAmount = _amount; + } + + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external override returns (uint256 amountOut) { + return mockQuoteAmount; + } + + function quoteExactInput(bytes memory path, uint256 amountIn) external override returns (uint256 amountOut) { + return mockQuoteAmount; + } + + function quoteExactOutputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountOut, + uint160 sqrtPriceLimitX96 + ) external override returns (uint256 amountIn) { + return mockQuoteAmount; + } + + function quoteExactOutput(bytes memory path, uint256 amountOut) external override returns (uint256 amountIn) { + return mockQuoteAmount; + } +} \ No newline at end of file diff --git a/test/mocks/MockUniswapV3Router.sol b/test/mocks/MockUniswapV3Router.sol new file mode 100644 index 00000000..973c31f7 --- /dev/null +++ b/test/mocks/MockUniswapV3Router.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IUniswapV3Router} from "../../src/interfaces/IUniswapV3Router.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MockERC20} from "./MockERC20.sol"; + +contract MockUniswapV3Router is IUniswapV3Router { + uint256 public mockAmountOut = 1000e18; // Default mock output + + function setMockAmountOut(uint256 _amount) external { + mockAmountOut = _amount; + } + + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable override returns (uint256 amountOut) { + // Simple mock: mint output tokens to recipient instead of real swap + MockERC20(params.tokenOut).mint(params.recipient, mockAmountOut); + return mockAmountOut; + } + + function exactInput(ExactInputParams calldata params) external payable override returns (uint256 amountOut) { + // Simple mock - just return mockAmountOut + return mockAmountOut; + } +} \ No newline at end of file diff --git a/test/mocks/MockWETH.sol b/test/mocks/MockWETH.sol new file mode 100644 index 00000000..eb92adb6 --- /dev/null +++ b/test/mocks/MockWETH.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IWETH} from "../../src/interfaces/IWETH.sol"; + +contract MockWETH is ERC20, IWETH { + constructor() ERC20("Wrapped Ether", "WETH") {} + + function deposit() public payable override { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 amount) external override { + _burn(msg.sender, amount); + payable(msg.sender).transfer(amount); + } + + function balanceOf(address account) public view override(ERC20, IWETH) returns (uint256) { + return ERC20.balanceOf(account); + } + + // Add mint function for testing + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + receive() external payable { + deposit(); + } +} \ No newline at end of file