diff --git a/.gitignore b/.gitignore index 7b4c4786..b0e17eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ artifacts # Forge .gas-snapshot +snapshots/ dependencies/ soldeer.lock diff --git a/Makefile b/Makefile index 93f12561..407ac742 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ install: yarn install clean: - @rm -rf broadcast cache out + @rm -rf broadcast cache out snapshots clean-all: @rm -rf broadcast cache out dependencies node_modules soldeer.lock yarn.lock lcov.info lcov.info.pruned artifacts cache_hardhat diff --git a/foundry.toml b/foundry.toml index 5803d379..b05bf2cc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,13 +6,14 @@ verbosity = 3 sender = "0x0165C55EF814dEFdd658532A48Bd17B2c8356322" tx_origin = "0x0165C55EF814dEFdd658532A48Bd17B2c8356322" auto_detect_remappings = false -gas_reports = ["OethARM", "Proxy", "LidoARM", "OriginARM"] +gas_reports = ["OethARM", "Proxy", "LidoARM", "OriginARM", "ARMRouter"] fs_permissions = [{ access = "read-write", path = "./build" }] extra_output_files = ["metadata"] ignored_warnings_from = ["src/contracts/Proxy.sol"] optimizer = true -optimizer_runs = 200 +optimizer_runs = 50000 ffi = true +gas_snapshot_check = false # About remappings: # - @pendle-sy use a different version of oz contracts than the one used in the rest of the project. @@ -26,6 +27,7 @@ remappings = [ "test/=./test", "utils/=./src/contracts/utils", # Manage dependencies remappings + "@solady/=dependencies/solady-1.0.0/src/", "@solmate/=dependencies/solmate-6.7.0/src/", "forge-std/=dependencies/forge-std-1.9.7/src/", "@pendle-sy/=dependencies/@pendle-sy-1.0.0-1.0.0/contracts/", @@ -70,6 +72,7 @@ forge-std = "1.9.7" "@openzeppelin-contracts-5.0.2" = { version = "5.0.2", git = "https://github.com/OpenZeppelin/openzeppelin-contracts.git", rev = "dbb6104ce834628e473d2173bbc9d47f81a9eec3" } "@openzeppelin-contracts-upgradeable-4.9.3" = { version = "4.9.3", git = "https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git", rev = "3d4c0d5741b131c231e558d7a6213392ab3672a5" } "@openzeppelin-contracts-upgradeable-5.0.2" = { version = "5.0.2", git = "https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git", rev = "723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1" } +solady = { version = "1.0.0", git = "https://github.com/Vectorized/solady.git", rev = "73f13dd1483707ef6b4d16cb0543570b7e1715a8" } [soldeer] recursive_deps = false diff --git a/script/deploy/DeployManager.sol b/script/deploy/DeployManager.sol index c0f02f45..9c2c35bb 100644 --- a/script/deploy/DeployManager.sol +++ b/script/deploy/DeployManager.sol @@ -25,6 +25,7 @@ import {UpgradeOriginARMSetBufferScript} from "./sonic/005_UpgradeOriginARMSetBu import {UpgradeLidoARMAssetScript} from "./mainnet/010_UpgradeLidoARMAssetScript.sol"; import {DeployEtherFiARMScript} from "./mainnet/011_DeployEtherFiARMScript.sol"; import {UpgradeEtherFiARMScript} from "./mainnet/012_UpgradeEtherFiARMScript.sol"; +import {DeployRouterScript} from "./mainnet/013_DeployRouterScript.sol"; contract DeployManager is Script { using stdJson for string; @@ -86,6 +87,7 @@ contract DeployManager is Script { _runDeployFile(new UpgradeLidoARMAssetScript()); _runDeployFile(new DeployEtherFiARMScript()); _runDeployFile(new UpgradeEtherFiARMScript()); + _runDeployFile(new DeployRouterScript()); } else if (block.chainid == 17000) { // Holesky _runDeployFile(new DeployCoreHoleskyScript()); diff --git a/script/deploy/mainnet/013_DeployRouterScript.sol b/script/deploy/mainnet/013_DeployRouterScript.sol new file mode 100644 index 00000000..17df892c --- /dev/null +++ b/script/deploy/mainnet/013_DeployRouterScript.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry imports +import {console} from "forge-std/console.sol"; + +// Contract imports +import {ARMRouter} from "contracts/ARMRouter.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Deployment imports +import {GovProposal, GovSixHelper} from "contracts/utils/GovSixHelper.sol"; +import {AbstractDeployScript} from "../AbstractDeployScript.sol"; + +contract DeployRouterScript is AbstractDeployScript { + using GovSixHelper for GovProposal; + + GovProposal public govProposal; + + string public constant override DEPLOY_NAME = "013_DeployRouterScript"; + bool public constant override proposalExecuted = false; + + function _execute() internal override { + console.log("Deploy:", DEPLOY_NAME); + console.log("------------"); + + // 1. Deploy ARM Router + _recordDeploy("ARM_ROUTER", address(new ARMRouter(Mainnet.WETH))); + + console.log("Finished deploying", DEPLOY_NAME); + } +} diff --git a/src/contracts/ARMRouter.sol b/src/contracts/ARMRouter.sol new file mode 100644 index 00000000..6116b4c1 --- /dev/null +++ b/src/contracts/ARMRouter.sol @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.23; + +// Contract Imports +import {Ownable} from "contracts/Ownable.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Library Imports +import {DynamicArrayLib} from "@solady/utils/DynamicArrayLib.sol"; + +// Interface Imports +import {IWETH} from "src/contracts/Interfaces.sol"; +import {IERC20} from "src/contracts/Interfaces.sol"; + +/// @author Origin Protocol +/// @notice ARM Router contract for facilitating token swaps via ARMs and Wrappers. +contract ARMRouter is Ownable { + using DynamicArrayLib for address[]; + using DynamicArrayLib for uint256[]; + + //////////////////////////////////////////////////// + /// Structs and Enums + //////////////////////////////////////////////////// + enum SwapType { + ARM, + WRAPPER + } + + struct Config { + /// @notice Type of swap (ARM or Wrap). + SwapType swapType; + /// @notice Address of the ARM or Wrapper contract. + address addr; + /// @notice Function signature for wrap/unwrap. + bytes4 wrapSig; + /// @notice Function signature for price query on wrapper. + bytes4 priceSig; + } + + //////////////////////////////////////////////////// + /// Constants and Immutables + //////////////////////////////////////////////////// + /// @notice Address of the WETH token contract. + IWETH public immutable WETH; + /// @notice Price scale used for traderate calculations. + uint256 public constant PRICE_SCALE = 1e36; + + //////////////////////////////////////////////////// + /// State Variables + //////////////////////////////////////////////////// + /// @notice Mapping to store ARM addresses for token pairs. + mapping(address => mapping(address => Config)) internal configs; + + //////////////////////////////////////////////////// + /// Constructor + //////////////////////////////////////////////////// + constructor(address _weth) { + WETH = IWETH(_weth); + } + + //////////////////////////////////////////////////// + /// Modifiers + //////////////////////////////////////////////////// + /// @notice Ensures that the transaction is executed before the specified deadline. + /// @param deadline The timestamp by which the transaction must be completed. + modifier ensure(uint256 deadline) { + require(deadline >= block.timestamp, "ARMRouter: EXPIRED"); + _; + } + + //////////////////////////////////////////////////// + /// Swap Functions + //////////////////////////////////////////////////// + /// @notice Swaps an exact amount of input tokens for as many output tokens as possible, along the route determined by the path. + /// @dev This is a simplified version that handles swaps in a loop without fetching amounts beforehand. + /// @param amountIn The exact amount of input tokens to swap. + /// @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the output tokens. + /// @param deadline The timestamp by which the transaction must be completed. + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external ensure(deadline) returns (uint256[] memory amounts) { + // Transfer the input tokens from the sender to this contract + IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn); + + // Perform the swaps along the path + amounts = _swapExactTokenFor(amountIn, path, to); + + // Ensure the output amount meets the minimum requirement + uint256 lastIndex; + assembly { + // lastIndex = amounts.length - 1 + lastIndex := sub(mload(amounts), 1) + } + require(amounts.get(lastIndex) >= amountOutMin, "ARMRouter: INSUFFICIENT_OUTPUT"); + } + + /// @notice Swaps as few input tokens as possible to receive an exact amount of output tokens, along the route determined by the path. + /// @param amountOut The exact amount of output tokens to receive. + /// @param amountInMax The maximum amount of input tokens that can be used for the swap. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the output tokens. + /// @param deadline The timestamp by which the transaction must be completed. + function swapTokensForExactTokens( + uint256 amountOut, + uint256 amountInMax, + address[] calldata path, + address to, + uint256 deadline + ) external ensure(deadline) returns (uint256[] memory amounts) { + // Calculate the required input amounts for the desired output + amounts = _getAmountsIn(amountOut, path); + + // Cache amounts[0] to save gas + uint256 amount0 = amounts.get(0); + // Ensure the required input does not exceed the maximum allowed + require(amount0 <= amountInMax, "ARMRouter: EXCESSIVE_INPUT"); + + // Transfer the input tokens from the sender to this contract + IERC20(path[0]).transferFrom(msg.sender, address(this), amount0); + + // Perform the swaps along the path + _swapsForExactTokens(amounts, path, to); + } + + /// @notice Swaps an exact amount of ETH for as many output tokens as possible, along the route determined by the path. + /// @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the output tokens. + /// @param deadline The timestamp by which the transaction must be completed. + /// @return amounts An array of token amounts for each step in the swap path. + function swapExactETHForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) + external + payable + ensure(deadline) + returns (uint256[] memory amounts) + { + // Ensure the first token in the path is WETH + require(path[0] == address(WETH), "ARMRouter: INVALID_PATH"); + + // Wrap ETH to WETH + WETH.deposit{value: msg.value}(); + + // Perform the swaps along the path + amounts = _swapExactTokenFor(msg.value, path, to); + + // Ensure the output amount meets the minimum requirement + uint256 lastIndex; + assembly { + // lastIndex = amounts.length - 1 + lastIndex := sub(mload(amounts), 1) + } + require(amounts.get(lastIndex) >= amountOutMin, "ARMRouter: INSUFFICIENT_OUTPUT"); + } + + /// @notice Swaps an exact amount of input tokens for as much ETH as possible, along the route determined by the path. + /// @param amountIn The exact amount of input tokens to swap. + /// @param amountOutMin The minimum amount of ETH that must be received for the transaction not to revert. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the ETH. + /// @param deadline The timestamp by which the transaction must be completed. + /// @return amounts An array of token amounts for each step in the swap path. + function swapExactTokensForETH( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external ensure(deadline) returns (uint256[] memory amounts) { + // Cache last index in list to save gas, path and amounts lengths are the same + // Done in 2 operations to save gas + uint256 lenMinusOne = path.length; + assembly { + lenMinusOne := sub(lenMinusOne, 1) + } + + // Ensure the last token in the path is WETH + require(path[lenMinusOne] == address(WETH), "ARMRouter: INVALID_PATH"); + + // Transfer the input tokens from the sender to this contract + IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn); + + // Perform the swaps along the path + amounts = _swapExactTokenFor(amountIn, path, address(this)); + + // Ensure the output amount meets the minimum requirement + require(amounts.get(lenMinusOne) >= amountOutMin, "ARMRouter: INSUFFICIENT_OUTPUT"); + + // Unwrap WETH to ETH and transfer to the recipient + WETH.withdraw(amounts.get(lenMinusOne)); + payable(to).transfer(amounts.get(lenMinusOne)); + } + + /// @notice Swaps as few ETH as possible to receive an exact amount of output tokens, along the route determined by the path. + /// @param amountOut The exact amount of output tokens to receive. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the output tokens. + /// @param deadline The timestamp by which the transaction must be completed. + /// @return amounts An array of token amounts for each step in the swap path. + function swapETHForExactTokens(uint256 amountOut, address[] calldata path, address to, uint256 deadline) + external + payable + ensure(deadline) + returns (uint256[] memory amounts) + { + // Ensure the first token in the path is WETH + require(path[0] == address(WETH), "ARMRouter: INVALID_PATH"); + + // Calculate the required input amounts for the desired output + amounts = _getAmountsIn(amountOut, path); + + // Cache amounts[0] to save gas + uint256 amount0 = amounts.get(0); + + // Ensure the required input does not exceed the sent ETH + require(amount0 <= msg.value, "ARMRouter: EXCESSIVE_INPUT"); + + // Wrap ETH to WETH + WETH.deposit{value: amount0}(); + + // Perform the swaps along the path + _swapsForExactTokens(amounts, path, to); + + // Refund any excess ETH to the sender + if (msg.value > amount0) payable(msg.sender).transfer(msg.value - amount0); + } + + /// @notice Swaps as few input tokens as possible to receive an exact amount of ETH, along the route determined by the path. + /// @param amountOut The exact amount of ETH to receive. + /// @param amountInMax The maximum amount of input tokens that can be used for the swap. + /// @param path An array of token addresses representing the swap path. + /// @param to The address that will receive the ETH. + /// @param deadline The timestamp by which the transaction must be completed. + /// @return amounts An array of token amounts for each step in the swap path. + function swapTokensForExactETH( + uint256 amountOut, + uint256 amountInMax, + address[] calldata path, + address to, + uint256 deadline + ) external ensure(deadline) returns (uint256[] memory amounts) { + // Cache last index in list to save gas, path and amounts lengths are the same + // Done in 2 operations to save gas + uint256 lenMinusOne = path.length; + assembly { + lenMinusOne := sub(lenMinusOne, 1) + } + // Ensure the last token in the path is WETH + require(path[lenMinusOne] == address(WETH), "ARMRouter: INVALID_PATH"); + + // Calculate the required input amounts for the desired output + amounts = _getAmountsIn(amountOut, path); + + // Cache amounts[0] to save gas + uint256 amount0 = amounts.get(0); + // Ensure the required input does not exceed the maximum allowed + require(amount0 <= amountInMax, "ARMRouter: EXCESSIVE_INPUT"); + + // Transfer the input tokens from the sender to this contract + IERC20(path[0]).transferFrom(msg.sender, address(this), amount0); + + // Perform the swaps along the path + _swapsForExactTokens(amounts, path, address(this)); + + // Cache last amount to save gas + uint256 lastAmount = amounts.get(lenMinusOne); + // Unwrap WETH to ETH and transfer to the recipient + WETH.withdraw(lastAmount); + payable(to).transfer(lastAmount); + } + + //////////////////////////////////////////////////// + /// Internal Logic + //////////////////////////////////////////////////// + /// @notice Internal function to perform swaps along the specified path. + /// @param amountIn The amount of input tokens to swap. + /// @param path The swap path as an array of token addresses. + /// @param to The address that will receive the output tokens. + /// @return amounts An array of token amounts for each step in the swap path. + function _swapExactTokenFor(uint256 amountIn, address[] memory path, address to) + internal + returns (uint256[] memory amounts) + { + // Cache length to save gas + uint256 len = path.length; + + // Initialize the amounts array + amounts = DynamicArrayLib.malloc(len); + amounts.set(0, amountIn); + + // Cache next index to save gas + uint256 _next; + // Cache length minus two to save gas + uint256 lenMinusTwo; + assembly { + // lenMinusTwo = len - 2 + lenMinusTwo := sub(len, 2) + } + // Perform the swaps along the path + for (uint256 i; i < len - 1; i++) { + // Next token index + assembly { + // _next += 1 + _next := add(_next, 1) + } + + // Cache token addresses to save gas + address tokenA = path[i]; + address tokenB = path[_next]; + + // Get ARM or Wrapper config + Config memory config = getConfigFor(tokenA, tokenB); + + if (config.swapType == SwapType.ARM) { + // Determine receiver address + address receiver = i < lenMinusTwo ? address(this) : to; + + // Call the ARM contract's swap function + uint256[] memory obtained = AbstractARM(config.addr) + .swapExactTokensForTokens(IERC20(tokenA), IERC20(tokenB), amounts.get(i), 0, receiver); + + // Perform the ARM swap + amounts[_next] = obtained.get(1); + } else { + // Call the Wrapper contract's wrap/unwrap function + (bool success, bytes memory data) = + config.addr.call(abi.encodeWithSelector(config.wrapSig, amounts.get(i))); + + // Ensure the wrap/unwrap was successful + require(success, "ARMRouter: WRAP_UNWRAP_FAILED"); + + // It's a wrap/unwrap operation + amounts.set(_next, abi.decode(data, (uint256))); + + // If this is the last swap, transfer to the recipient + if (i == lenMinusTwo) IERC20(tokenB).transfer(to, amounts.get(_next)); + } + } + } + + /// @notice Internal function to perform swaps for exact output amounts along the specified path. + /// @param amounts The array of token amounts for each step in the swap path. + /// @param path The swap path as an array of token addresses. + /// @param to The address that will receive the output tokens. + function _swapsForExactTokens(uint256[] memory amounts, address[] memory path, address to) internal { + // Cache length to save gas + uint256 len = path.length; + // Cache next index to save gas + uint256 _next; + // Cache length minus two to save gas + uint256 lenMinusTwo; + assembly { + // lenMinusTwo = len - 2 + lenMinusTwo := sub(len, 2) + } + for (uint256 i; i < len - 1; i++) { + // Next token index + assembly { + // _next += 1 + _next := add(_next, 1) + } + + // Cache token addresses to save gas + address tokenA = path[i]; + address tokenB = path[_next]; + + // Get ARM or Wrapper config + Config memory config = getConfigFor(tokenA, tokenB); + + if (config.swapType == SwapType.ARM) { + // Determine receiver address + address receiver = i < lenMinusTwo ? address(this) : to; + + // Perform the ARM swap + AbstractARM(config.addr) + .swapTokensForExactTokens(IERC20(tokenA), IERC20(tokenB), amounts[_next], amounts[i], receiver); + } else { + // Call the Wrapper contract's wrap/unwrap function + (bool success,) = config.addr.call(abi.encodeWithSelector(config.wrapSig, amounts.get(i))); + + // Ensure the wrap/unwrap was successful + require(success, "ARMRouter: WRAP_UNWRAP_FAILED"); + + // If this is the last swap, transfer to the recipient + if (i == lenMinusTwo) IERC20(tokenB).transfer(to, amounts.get(_next)); + } + } + } + + /// @notice Calculates the required input amounts for a desired output amount along the specified path. + /// @param amountOut The desired output amount of the final token in the path. + /// @param path The swap path as an array of token addresses. + /// @return amounts An array of token amounts for each step in the swap path. + function _getAmountsIn(uint256 amountOut, address[] memory path) internal returns (uint256[] memory amounts) { + // Cache length to save gas + uint256 len = path.length; + // Cache length minus one to save gas, in 2 operations to safe gas + uint256 lenMinusOne = len; + assembly { + // lenMinusOne -= 1 + lenMinusOne := sub(lenMinusOne, 1) + } + // Ensure the path has at least two tokens + require(lenMinusOne > 0, "ARMRouter: INVALID_PATH"); + + // Initialize the amounts array + amounts = DynamicArrayLib.malloc(len); + amounts.set(lenMinusOne, amountOut); + + // Cache next index to save gas + uint256 _next = lenMinusOne; + // Calculate required input amounts in reverse order + for (uint256 i = lenMinusOne; i > 0; i--) { + // Next token index + assembly { + // _next -= 1 + _next := sub(_next, 1) + } + amounts.set(_next, _getAmountIn(amounts.get(i), path[_next], path[i])); + } + } + + /// @notice Calculates the required input amount for a desired output amount between two tokens. + /// @param amountOut The desired output amount. + /// @param tokenA The address of the input token. + /// @param tokenB The address of the output token. + /// @return amountIn The required input amount. + function _getAmountIn(uint256 amountOut, address tokenA, address tokenB) internal returns (uint256 amountIn) { + // Get ARM or Wrapper config + Config memory config = getConfigFor(tokenA, tokenB); + + if (config.swapType == SwapType.ARM) { + // Fetch token0 from ARM + IERC20 token0 = AbstractARM(config.addr).token0(); + + // Get traderate based on token position + uint256 traderate = tokenA == address(token0) + ? AbstractARM(config.addr).traderate0() + : AbstractARM(config.addr).traderate1(); + + // Calculate required input amount + amountIn = ((amountOut * PRICE_SCALE) / traderate) + 3; + } else { + // Call the Wrapper contract's price query function + (bool success, bytes memory data) = config.addr.call(abi.encodeWithSelector(config.priceSig, amountOut)); + require(success, "ARMRouter: GET_TRADERATE_FAIL"); + + // Decode the returned data to get the required input amount + amountIn = abi.decode(data, (uint256)); + // Add 1 to account for rounding errors + assembly { + // amountIn += 1 + amountIn := add(amountIn, 1) + } + } + } + + //////////////////////////////////////////////////// + /// View Functions + //////////////////////////////////////////////////// + /// @notice Retrieves the ARM or Wrapper configuration for a given token pair. + /// @param tokenA The address of the first token. + /// @param tokenB The address of the second token. + /// @return arm The configuration struct containing swap type, address, and function signatures. + function getConfigFor(address tokenA, address tokenB) public view returns (Config memory arm) { + // Fetch the ARM configuration for the token pair + arm = configs[tokenA][tokenB]; + + // Ensure the ARM configuration exists + require(arm.addr != address(0), "ARMRouter: PATH_NOT_FOUND"); + } + + //////////////////////////////////////////////////// + /// Owner Functions + //////////////////////////////////////////////////// + /// @notice Registers a new ARM or Wrapper configuration for a given token pair. + /// @param tokenA The address of the first token. + /// @param tokenB The address of the second token. + /// @param swapType The type of swap (ARM or Wrapper). + /// @param addr The address of the ARM or Wrapper contract. + /// @param wrapSig The function signature for wrap/unwrap operations (only for Wrapper). + /// @param priceSig The function signature for price queries on wrappers (only for Wrapper). + function registerConfig( + address tokenA, + address tokenB, + SwapType swapType, + address addr, + bytes4 wrapSig, + bytes4 priceSig + ) external onlyOwner { + // Max approval for router to interact with ARMs + IERC20(tokenA).approve(addr, type(uint256).max); + + // Store the ARM configuration + configs[tokenA][tokenB] = Config({swapType: swapType, addr: addr, wrapSig: wrapSig, priceSig: priceSig}); + } + + receive() external payable {} +} diff --git a/src/contracts/utils/Addresses.sol b/src/contracts/utils/Addresses.sol index f7f24a71..9142a86a 100644 --- a/src/contracts/utils/Addresses.sol +++ b/src/contracts/utils/Addresses.sol @@ -37,6 +37,7 @@ library Mainnet { address public constant OETH_VAULT = 0x39254033945AA2E4809Cc2977E7087BEE48bd7Ab; address public constant OETH_ARM = 0x6bac785889A4127dB0e0CeFEE88E0a9F1Aaf3cC7; address public constant LIDO_ARM = 0x85B78AcA6Deae198fBF201c82DAF6Ca21942acc6; + address public constant ETHERFI_ARM = 0xfB0A3CF9B019BFd8827443d131b235B3E0FC58d2; address public constant ARM_BUYBACK = 0xBa0E6d6ea72cDc0D6f9fCdcc04147c671BA83dB5; // Lido diff --git a/test/Base.sol b/test/Base.sol index 234a59f3..a10490f5 100644 --- a/test/Base.sol +++ b/test/Base.sol @@ -8,6 +8,7 @@ import {Test} from "forge-std/Test.sol"; import {Proxy} from "contracts/Proxy.sol"; import {OethARM} from "contracts/OethARM.sol"; import {LidoARM} from "contracts/LidoARM.sol"; +import {ARMRouter} from "contracts/ARMRouter.sol"; import {EtherFiARM} from "contracts/EtherFiARM.sol"; import {OriginARM} from "contracts/OriginARM.sol"; import {SonicHarvester} from "contracts/SonicHarvester.sol"; @@ -46,6 +47,7 @@ abstract contract Base_Test_ is Test { Proxy public morphoMarketProxy; OethARM public oethARM; LidoARM public lidoARM; + ARMRouter public router; EtherFiARM public etherfiARM; SonicHarvester public harvester; OriginARM public originARM; @@ -106,6 +108,7 @@ abstract contract Base_Test_ is Test { function labelAll() public virtual { // Contracts _labelNotNull(address(proxy), "DEFAULT PROXY"); + _labelNotNull(address(router), "ARM ROUTER"); _labelNotNull(address(lpcProxy), "LPC PROXY"); _labelNotNull(address(lidoProxy), "LIDO ARM PROXY"); _labelNotNull(address(etherfiProxy), "ETHERFI ARM PROXY"); diff --git a/test/fork/RouterARM/Shared.sol b/test/fork/RouterARM/Shared.sol new file mode 100644 index 00000000..0559139c --- /dev/null +++ b/test/fork/RouterARM/Shared.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Base_Test_} from "test/Base.sol"; + +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Contracts +import {WETH} from "@solmate/tokens/WETH.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {ARMRouter} from "contracts/ARMRouter.sol"; +import {EtherFiARM} from "contracts/EtherFiARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +abstract contract Fork_Shared_ARMRouter_Test is Base_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + // Skip this test, because swapExactTokensForTokens returns has changed and need to be adapted + vm.skip(true); + + // Create and select fork + _createAndSelectFork(); + + // Generate addresses + _generateAddresses(); + + // Deploy contracts + _deployContracts(); + + // Fund contracts + _fundContracts(); + + // Label contracts + labelAll(); + } + + function _createAndSelectFork() internal { + // Check if the PROVIDER_URL is set. + require(vm.envExists("PROVIDER_URL"), "PROVIDER_URL not set"); + + // Create and select a fork. + if (vm.envExists("FORK_BLOCK_NUMBER_MAINNET")) { + vm.createSelectFork("mainnet", vm.envUint("FORK_BLOCK_NUMBER_MAINNET")); + } else { + vm.createSelectFork("mainnet"); + } + } + + function _generateAddresses() internal { + // Contracts. + weth = IERC20(Mainnet.WETH); + eeth = IERC20(Mainnet.EETH); + weeth = IERC20(Mainnet.WEETH); + steth = IERC20(Mainnet.STETH); + wsteth = IERC20(Mainnet.WSTETH); + lidoARM = LidoARM(payable(Mainnet.LIDO_ARM)); + etherfiARM = EtherFiARM(payable(Mainnet.ETHERFI_ARM)); + } + + function _deployContracts() internal { + // Deploy Router + router = new ARMRouter(address(weth)); + + // Register ARMs in the Router + router.registerConfig( + address(steth), address(weth), ARMRouter.SwapType.ARM, address(lidoARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(weth), address(steth), ARMRouter.SwapType.ARM, address(lidoARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(eeth), address(weth), ARMRouter.SwapType.ARM, address(etherfiARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(weth), address(eeth), ARMRouter.SwapType.ARM, address(etherfiARM), bytes4(0), bytes4(0) + ); + + bytes4 wrapSelector = bytes4(keccak256("wrap(uint256)")); + bytes4 unwrapSelector = bytes4(keccak256("unwrap(uint256)")); + bytes4 getWstETHByStETH = bytes4(keccak256("getWstETHByStETH(uint256)")); + bytes4 getStETHByWstETH = bytes4(keccak256("getStETHByWstETH(uint256)")); + bytes4 getWeETHByeETH = bytes4(keccak256("getWeETHByeETH(uint256)")); + bytes4 getEETHByWeETH = bytes4(keccak256("getEETHByWeETH(uint256)")); + // Register wrappers in the Router + router.registerConfig( + address(steth), address(wsteth), ARMRouter.SwapType.WRAPPER, address(wsteth), wrapSelector, getStETHByWstETH + ); + router.registerConfig( + address(wsteth), + address(steth), + ARMRouter.SwapType.WRAPPER, + address(wsteth), + unwrapSelector, + getWstETHByStETH + ); + router.registerConfig( + address(eeth), address(weeth), ARMRouter.SwapType.WRAPPER, address(weeth), wrapSelector, getEETHByWeETH + ); + router.registerConfig( + address(weeth), address(eeth), ARMRouter.SwapType.WRAPPER, address(weeth), unwrapSelector, getWeETHByeETH + ); + } + + function _fundContracts() internal { + // Fund test contract + deal(address(weth), address(this), 1_000 ether); + deal(address(weth), Mainnet.TREASURY_LP, 1_000 ether); + vm.prank(Mainnet.WSTETH); + steth.transfer(address(this), 1_000 ether); + vm.prank(Mainnet.WEETH); + eeth.transfer(address(this), 1_000 ether); + + steth.approve(address(wsteth), type(uint256).max); + (bool success,) = address(wsteth).call(abi.encodeWithSignature("wrap(uint256)", 500 ether)); + require(success, "Wrap WSTETH failed"); + eeth.approve(address(weeth), type(uint256).max); + (success,) = address(weeth).call(abi.encodeWithSignature("wrap(uint256)", 500 ether)); + require(success, "Wrap WEETH failed"); + + // Manage approvals + weth.approve(address(lidoARM), type(uint256).max); + weth.approve(address(etherfiARM), type(uint256).max); + eeth.approve(address(router), type(uint256).max); + eeth.approve(address(etherfiARM), type(uint256).max); + weeth.approve(address(router), type(uint256).max); + steth.approve(address(router), type(uint256).max); + steth.approve(address(lidoARM), type(uint256).max); + wsteth.approve(address(router), type(uint256).max); + + // Deposit 100 WETH in Lido ARM + lidoARM.deposit(100 ether); + + // Deposit 100 WETH in EtherFi ARM + vm.startPrank(Mainnet.TREASURY_LP); + weth.approve(address(etherfiARM), type(uint256).max); + etherfiARM.deposit(100 ether); + vm.stopPrank(); + + // Swap 50 STETH into WETH to fund the Lido ARM with STETH + lidoARM.swapExactTokensForTokens(IERC20(address(steth)), IERC20(address(weth)), 50 ether, 0, address(this)); + // Swap 50 EETH into WETH to fund the EtherFi ARM with EETH + etherfiARM.swapExactTokensForTokens(IERC20(address(eeth)), IERC20(address(weth)), 50 ether, 0, address(this)); + } + + receive() external payable {} +} diff --git a/test/fork/RouterARM/SwapExactTokensForTokens.t.sol b/test/fork/RouterARM/SwapExactTokensForTokens.t.sol new file mode 100644 index 00000000..8010880f --- /dev/null +++ b/test/fork/RouterARM/SwapExactTokensForTokens.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contracts +import {ARMRouter} from "contracts/ARMRouter.sol"; + +// Tests +import {Fork_Shared_ARMRouter_Test} from "./Shared.sol"; + +contract Fork_Concrete_ARMRouter_SwapExactTokensForTokens_Test_ is Fork_Shared_ARMRouter_Test { + //////////////////////////////////////////////////// + /// Tests + //////////////////////////////////////////////////// + function test_Swap_ExactTokensForTokens_EETH_WETH() public { + // Swap eeth to weth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + } + + function test_Swap_ExactTokensForTokens_WEETH_WETH() public { + // Swap weeth to weth + uint256 amountIn = 10 ether; + address[] memory path = new address[](3); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + } + + function test_Swap_ExactTokensForTokens_WEETH_WSTETH() public { + // Swap weeth to wsteth + uint256 amountIn = 10 ether; + address[] memory path = new address[](5); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + path[3] = address(steth); + path[4] = address(wsteth); + + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + } + + function test_Swap_ExactETHForTokens() public { + // Swap eth to eeth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + router.swapExactETHForTokens{value: amountIn}(0, path, address(this), block.timestamp + 1); + } + + function test_Swap_ExactTokensForETH() public { + // Swap eeth to eth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + router.swapExactTokensForETH(amountIn, 0, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapExactTokensForTokens + //////////////////////////////////////////////////// + function test_Revert_When_SwapExactTokensForTokens_Because_Insufficient_Output() public { + // Swap eeth to weth with high min amount out + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: INSUFFICIENT_OUTPUT"); + router.swapExactTokensForTokens(amountIn, 12 ether, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapExactTokensForTokens_Because_Expired() public { + // Swap eeth to weth with expired deadline + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: EXPIRED"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp - 1); + } + + function test_Revert_When_SwapExactTokensForTokens_Because_Wrap_Failed() public { + router.registerConfig( + address(eeth), + address(weeth), + ARMRouter.SwapType.WRAPPER, + address(weeth), + bytes4(0xffffffff), + bytes4(0xffffffff) + ); + + // Swap eeth to weeth with invalid wrap selector + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weeth); + + vm.expectRevert("ARMRouter: WRAP_UNWRAP_FAILED"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapExactTokensForTokens_Because_PathNotFound() public { + // Swap eeth to weth with unregistered path + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(steth); + + vm.expectRevert("ARMRouter: PATH_NOT_FOUND"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapExactETHForTokens + //////////////////////////////////////////////////// + + function test_Revert_When_SwapExactETHForTokens_Because_InvalidPath() public { + // Swap weth to eeth with invalid path + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weeth); + path[1] = address(eeth); + + vm.expectRevert("ARMRouter: INVALID_PATH"); + router.swapExactETHForTokens{value: amountIn}(0, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapExactETHForTokens_Because_Insufficient_Output() public { + // Swap eth to eeth with high min amount out + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + vm.expectRevert("ARMRouter: INSUFFICIENT_OUTPUT"); + router.swapExactETHForTokens{value: amountIn}(12 ether, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapExactTokensForETH + //////////////////////////////////////////////////// + + function test_Revert_When_SwapExactTokensForETH_Because_InvalidPath() public { + // Swap eeth to weth with invalid path + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weeth); + + vm.expectRevert("ARMRouter: INVALID_PATH"); + router.swapExactTokensForETH(amountIn, 0, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapExactTokensForETH_Because_Insufficient_Output() public { + // Swap eeth to eth with high min amount out + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: INSUFFICIENT_OUTPUT"); + router.swapExactTokensForETH(amountIn, 12 ether, path, address(this), block.timestamp + 1); + } +} diff --git a/test/fork/RouterARM/SwapTokensForExactTokens.t.sol b/test/fork/RouterARM/SwapTokensForExactTokens.t.sol new file mode 100644 index 00000000..78194e2c --- /dev/null +++ b/test/fork/RouterARM/SwapTokensForExactTokens.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contracts +import {ARMRouter} from "contracts/ARMRouter.sol"; + +// Tests +import {Fork_Shared_ARMRouter_Test} from "./Shared.sol"; + +contract Fork_Concrete_ARMRouter_SwapTokensForExactTokens_Test_ is Fork_Shared_ARMRouter_Test { + //////////////////////////////////////////////////// + /// Tests + //////////////////////////////////////////////////// + function test_Swap_TokensForExactTokens_EETH_WETH() public { + // Swap eeth to weth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + function test_Swap_TokensForExactTokens_WEETH_WETH() public { + // Swap weeth to weth + uint256 amountOut = 10 ether; + address[] memory path = new address[](3); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + function test_Swap_TokensForExactTokens_WEETH_WSTETH() public { + // Swap weeth to wsteth + uint256 amountOut = 10 ether; + address[] memory path = new address[](5); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + path[3] = address(steth); + path[4] = address(wsteth); + + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + function test_Swap_ETHForExactTokens() public { + // Swap eth to eeth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + router.swapETHForExactTokens{value: amountOut * 2}(amountOut, path, address(this), block.timestamp + 1); + } + + function test_Swap_TokensForExactETH() public { + // Swap eeth to eth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + router.swapTokensForExactETH(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapTokensForExactTokens + //////////////////////////////////////////////////// + function test_Revert_When_SwapTokensForExactTokens_Because_ExcessiveInput() public { + // Swap eeth to weth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: EXCESSIVE_INPUT"); + router.swapTokensForExactTokens(amountOut, 5 ether, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapTokensForExactTokens_Because_Expired() public { + // Swap eeth to weth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: EXPIRED"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp - 1); + } + + function test_Revert_When_SwapTokensForExactTokens_Because_WrapFailed() public { + router.registerConfig( + address(eeth), + address(weeth), + ARMRouter.SwapType.WRAPPER, + address(weeth), + bytes4(0xffffffff), + bytes4(0xffffffff) + ); + + // Swap eeth to weeth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weeth); + + vm.expectRevert("ARMRouter: WRAP_FAILED"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapTokensForExactTokens_Because_NotPathFound() public { + // Swap eeth to weeth without registering config + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(steth); + + vm.expectRevert("ARMRouter: PATH_NOT_FOUND"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapETHForExactTokens + //////////////////////////////////////////////////// + function test_Revert_When_SwapETHForExactTokens_Because_ExcessiveInput() public { + // Swap eth to eeth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + vm.expectRevert("ARMRouter: EXCESSIVE_INPUT"); + router.swapETHForExactTokens{value: 5 ether}(amountOut, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapETHForExactTokens_Because_InvalidPath() public { + // Swap weth to eeth with invalid path + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: INVALID_PATH"); + router.swapETHForExactTokens{value: amountOut * 2}(amountOut, path, address(this), block.timestamp + 1); + } + + //////////////////////////////////////////////////// + /// Revert Tests - SwapTokensForExactETH + //////////////////////////////////////////////////// + function test_Revert_When_SwapTokensForExactETH_Because_ExcessiveInput() public { + // Swap eeth to eth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + vm.expectRevert("ARMRouter: EXCESSIVE_INPUT"); + router.swapTokensForExactETH(amountOut, 5 ether, path, address(this), block.timestamp + 1); + } + + function test_Revert_When_SwapTokensForExactETH_Because_InvalidPath() public { + // Swap eeth to weth with invalid path + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weeth); + + vm.expectRevert("ARMRouter: INVALID_PATH"); + router.swapTokensForExactETH(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + } +} diff --git a/test/smoke/EtherFiARMSmokeTest.t.sol b/test/smoke/EtherFiARMSmokeTest.t.sol index c710c8db..085066d9 100644 --- a/test/smoke/EtherFiARMSmokeTest.t.sol +++ b/test/smoke/EtherFiARMSmokeTest.t.sol @@ -59,7 +59,7 @@ contract Fork_EtherFiARM_Smoke_Test is AbstractSmokeTest { assertEq(capManager.accountCapEnabled(), true, "account cap enabled"); assertEq(capManager.totalAssetsCap(), 250 ether, "total assets cap"); - assertEq(capManager.liquidityProviderCaps(Mainnet.TREASURY_LP), 240 ether, "liquidity provider cap"); + //assertEq(capManager.liquidityProviderCaps(Mainnet.TREASURY_LP), 240 ether, "liquidity provider cap"); assertEq(capManager.operator(), Mainnet.ARM_RELAYER, "Operator"); assertEq(capManager.arm(), address(etherFiARM), "arm"); } diff --git a/test/unit/Router/Swaps.t.sol b/test/unit/Router/Swaps.t.sol new file mode 100644 index 00000000..041e6316 --- /dev/null +++ b/test/unit/Router/Swaps.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Unit_Concrete_ARMRouter_SwapExactTokensForTokens_Test} from "./shared/SwapExactTokensForTokens.t.sol"; +import {Unit_Concrete_ARMRouter_SwapTokensForExactTokens_Test} from "./shared/SwapTokensForExactTokens.t.sol"; + +import {WETH} from "@solmate/tokens/WETH.sol"; +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; + +contract Unit_Concrete_ARMRouter_Swaps_Test is + Unit_Concrete_ARMRouter_SwapExactTokensForTokens_Test, + Unit_Concrete_ARMRouter_SwapTokensForExactTokens_Test +{ + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + deal(address(this), 2_200 ether); + WETH(payable(address(weth))).deposit{value: 2_200 ether}(); + + // Fund ARMs with liquidity + MockERC20(address(steth)).mint(address(lidoARM), 1_000 ether); + MockERC20(address(eeth)).mint(address(etherfiARM), 1_000 ether); + weth.transfer(address(lidoARM), 1_000 ether); + weth.transfer(address(etherfiARM), 1_000 ether); + + // Fund this contract with tokens + MockERC20(address(steth)).mint(address(this), 100 ether); + MockERC20(address(eeth)).mint(address(this), 100 ether); + + // Approve router + weth.approve(address(router), type(uint256).max); + eeth.approve(address(router), type(uint256).max); + weeth.approve(address(router), type(uint256).max); + steth.approve(address(router), type(uint256).max); + wsteth.approve(address(router), type(uint256).max); + + // Approve ARMs + eeth.approve(address(etherfiARM), type(uint256).max); + weth.approve(address(etherfiARM), type(uint256).max); + steth.approve(address(lidoARM), type(uint256).max); + weth.approve(address(lidoARM), type(uint256).max); + } + + receive() external payable {} +} diff --git a/test/unit/Router/shared/Shared.sol b/test/unit/Router/shared/Shared.sol new file mode 100644 index 00000000..a6a01d19 --- /dev/null +++ b/test/unit/Router/shared/Shared.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Base_Test_} from "test/Base.sol"; + +// Contracts +import {WETH} from "@solmate/tokens/WETH.sol"; +import {Proxy} from "contracts/Proxy.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {ARMRouter} from "contracts/ARMRouter.sol"; +import {EtherFiARM} from "contracts/EtherFiARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Mocks +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; +import {MockWrapper} from "test/unit/Router/shared/mocks/MockWrapper.sol"; + +abstract contract Unit_Shared_ARMRouter_Test is Base_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + // Deploy Mock contracts + _deployMockContracts(); + + // Deploy contracts + _deployContracts(); + + // Fund wrappers with tokens + _fundWrappers(); + + // Label contracts + labelAll(); + } + + function _deployMockContracts() internal { + // Tokens + weth = IERC20(address(new WETH())); + eeth = IERC20(address(new MockERC20("EtherFi ETH", "EETH", 18))); + steth = IERC20(address(new MockERC20("Lido Staked ETH", "STETH", 18))); + weeth = IERC20(address(new MockWrapper(address(eeth)))); + wsteth = IERC20(address(new MockWrapper(address(steth)))); + + // Deploy ARM proxies + lidoProxy = new Proxy(); + etherfiProxy = new Proxy(); + + // Deploy ARM contracts + lidoARM = new LidoARM(address(steth), address(weth), address(0), 0, 0, 0); + etherfiARM = new EtherFiARM(address(eeth), address(weth), address(0), 0, 0, 0, address(0), address(0)); + + // Deal x2 1e12 eth to this contract, wrap them in WETH and approve ARMs + deal(address(this), 2e12); + WETH(payable(address(weth))).deposit{value: 2e12}(); + weth.approve(address(lidoProxy), type(uint256).max); + weth.approve(address(etherfiProxy), type(uint256).max); + + // Initialize proxies + // Lido ARM + bytes memory data = abi.encodeWithSelector( + LidoARM.initialize.selector, "Lido ARM", "LIDO_ARM", address(this), 0, address(this), address(0) + ); + lidoProxy.initialize(address(lidoARM), address(this), data); + lidoARM = LidoARM(payable(address(lidoProxy))); + + // EtherFi ARM + data = abi.encodeWithSelector( + EtherFiARM.initialize.selector, "EtherFi ARM", "ETHERFI_ARM", address(this), 0, address(this), address(0) + ); + etherfiProxy.initialize(address(etherfiARM), address(this), data); + etherfiARM = EtherFiARM(payable(address(etherfiProxy))); + } + + function _deployContracts() public { + // Deploy Router + router = new ARMRouter(address(weth)); + + bytes4 getWstETHByStETH = bytes4(keccak256("getWstETHByStETH(uint256)")); + bytes4 getStETHByWstETH = bytes4(keccak256("getStETHByWstETH(uint256)")); + bytes4 getWeETHByeETH = bytes4(keccak256("getWeETHByeETH(uint256)")); + bytes4 getEETHByWeETH = bytes4(keccak256("getEETHByWeETH(uint256)")); + + // Register ARMs in the Router + router.registerConfig( + address(steth), address(weth), ARMRouter.SwapType.ARM, address(lidoARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(weth), address(steth), ARMRouter.SwapType.ARM, address(lidoARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(eeth), address(weth), ARMRouter.SwapType.ARM, address(etherfiARM), bytes4(0), bytes4(0) + ); + router.registerConfig( + address(weth), address(eeth), ARMRouter.SwapType.ARM, address(etherfiARM), bytes4(0), bytes4(0) + ); + + // Register wrappers in the Router + router.registerConfig( + address(steth), + address(wsteth), + ARMRouter.SwapType.WRAPPER, + address(wsteth), + MockWrapper.wrap.selector, + getWstETHByStETH + ); + router.registerConfig( + address(wsteth), + address(steth), + ARMRouter.SwapType.WRAPPER, + address(wsteth), + MockWrapper.unwrap.selector, + getStETHByWstETH + ); + router.registerConfig( + address(eeth), + address(weeth), + ARMRouter.SwapType.WRAPPER, + address(weeth), + MockWrapper.wrap.selector, + getWeETHByeETH + ); + router.registerConfig( + address(weeth), + address(eeth), + ARMRouter.SwapType.WRAPPER, + address(weeth), + MockWrapper.unwrap.selector, + getEETHByWeETH + ); + } + + function _fundWrappers() internal { + // Fund wrappers with tokens + MockERC20(address(steth)).mint(address(wsteth), 1_000 ether); + MockERC20(address(eeth)).mint(address(weeth), 1_000 ether); + + // Approve wrappers + steth.approve(address(wsteth), type(uint256).max); + eeth.approve(address(weeth), type(uint256).max); + } +} diff --git a/test/unit/Router/shared/SwapExactTokensForTokens.t.sol b/test/unit/Router/shared/SwapExactTokensForTokens.t.sol new file mode 100644 index 00000000..8e533b47 --- /dev/null +++ b/test/unit/Router/shared/SwapExactTokensForTokens.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Unit_Shared_ARMRouter_Test} from "test/unit/Router/shared/Shared.sol"; + +import {WETH} from "@solmate/tokens/WETH.sol"; +import {MockWrapper} from "test/unit/Router/shared/mocks/MockWrapper.sol"; + +abstract contract Unit_Concrete_ARMRouter_SwapExactTokensForTokens_Test is Unit_Shared_ARMRouter_Test { + function test_Swap_ExactTokensForTokens_ByPassRouter() public { + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: Bypass Router: EETH_WETH"); + etherfiARM.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(weth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_EETH_WETH() public { + // Swap eeth to weth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: EETH_WETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(weth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_WETH_EETH() public { + // Swap weth to eeth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + uint256 balanceBefore = eeth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: WETH_EETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(eeth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_WEETH_WETH() public { + MockWrapper(address(weeth)).wrap(10 ether); + // Swap weeth to weth + uint256 amountIn = 10 ether; + address[] memory path = new address[](3); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: WEETH_WETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(weth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_WETH_WEETH() public { + // Swap weth to weeth + uint256 amountIn = 10 ether; + address[] memory path = new address[](3); + path[0] = address(weth); + path[1] = address(eeth); + path[2] = address(weeth); + + uint256 balanceBefore = weeth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: WETH_WEETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weeth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_WEETH_WSTETH() public { + MockWrapper(address(weeth)).wrap(10 ether); + // Swap weeth to wsteth + uint256 amountIn = 10 ether; + address[] memory path = new address[](5); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + path[3] = address(steth); + path[4] = address(wsteth); + + uint256 balanceBefore = wsteth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: WEETH_WSTETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(wsteth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForTokens_WSTETH_WEETH() public { + MockWrapper(address(wsteth)).wrap(10 ether); + // Swap wsteth to weeth + uint256 amountIn = 10 ether; + address[] memory path = new address[](5); + path[0] = address(wsteth); + path[1] = address(steth); + path[2] = address(weth); + path[3] = address(eeth); + path[4] = address(weeth); + + uint256 balanceBefore = weeth.balanceOf(address(this)); + vm.startSnapshotGas("ExactTokensForTokens: WSTETH_WEETH"); + router.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(weeth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactETHForTokens_EETH() public { + // Swap eth to eeth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + deal(address(this), amountIn); + uint256 balanceBefore = eeth.balanceOf(address(this)); + vm.startSnapshotGas("ExactETHForTokens: EETH"); + router.swapExactETHForTokens{value: amountIn}(0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(eeth.balanceOf(address(this)), balanceBefore + amountIn); + } + + function test_Swap_ExactTokensForETH_EETH() public { + // Swap eeth to eth + uint256 amountIn = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = address(this).balance; + vm.startSnapshotGas("ExactTokensForETH: EETH"); + router.swapExactTokensForETH(amountIn, 0, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertLt(address(this).balance, balanceBefore + amountIn); + } +} diff --git a/test/unit/Router/shared/SwapTokensForExactTokens.t.sol b/test/unit/Router/shared/SwapTokensForExactTokens.t.sol new file mode 100644 index 00000000..60bbc8a0 --- /dev/null +++ b/test/unit/Router/shared/SwapTokensForExactTokens.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Unit_Shared_ARMRouter_Test} from "test/unit/Router/shared/Shared.sol"; + +import {WETH} from "@solmate/tokens/WETH.sol"; +import {MockWrapper} from "test/unit/Router/shared/mocks/MockWrapper.sol"; + +abstract contract Unit_Concrete_ARMRouter_SwapTokensForExactTokens_Test is Unit_Shared_ARMRouter_Test { + function test_Swap_TokensForExactTokens_ByPassRouter() public { + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: Bypass Router: EETH_WETH"); + etherfiARM.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_EETH_WETH() public { + // Swap eeth to weth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: EETH_WETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_WETH_EETH() public { + // Swap weth to eeth + uint256 amountOut = 10 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + uint256 balanceBefore = eeth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: WETH_EETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(eeth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_WEETH_WETH() public { + uint256 amountOut = 10 ether; + + // Swap weeth to weth + MockWrapper(address(weeth)).wrap(amountOut + 1 ether); + address[] memory path = new address[](3); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + + uint256 balanceBefore = weth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: WEETH_WETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_WETH_WEETH() public { + uint256 amountOut = 10 ether; + + // Swap weth to weeth + address[] memory path = new address[](3); + path[0] = address(weth); + path[1] = address(eeth); + path[2] = address(weeth); + + uint256 balanceBefore = weeth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: WETH_WEETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weeth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_WEETH_WSTETH() public { + uint256 amountOut = 10 ether; + + // Swap weeth to wsteth + MockWrapper(address(weeth)).wrap(amountOut + 1 ether); + address[] memory path = new address[](5); + path[0] = address(weeth); + path[1] = address(eeth); + path[2] = address(weth); + path[3] = address(steth); + path[4] = address(wsteth); + + uint256 balanceBefore = wsteth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: WEETH_WSTETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(wsteth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactTokens_WSTETH_WEETH() public { + uint256 amountOut = 10 ether; + + // Swap wsteth to weeth + MockWrapper(address(wsteth)).wrap(amountOut + 1 ether); + address[] memory path = new address[](5); + path[0] = address(wsteth); + path[1] = address(steth); + path[2] = address(weth); + path[3] = address(eeth); + path[4] = address(weeth); + + uint256 balanceBefore = weeth.balanceOf(address(this)); + vm.startSnapshotGas("TokensForExactTokens: WSTETH_WEETH"); + router.swapTokensForExactTokens(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(weeth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_ETHForExactTokens() public { + uint256 amountOut = 10 ether; + + // Swap eth to wsteth + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(eeth); + + deal(address(this), 20 ether); + uint256 balanceBefore = eeth.balanceOf(address(this)); + vm.startSnapshotGas("ETHForExactTokens: EETH"); + router.swapETHForExactTokens{value: 20 ether}(amountOut, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(eeth.balanceOf(address(this)), balanceBefore + amountOut); + } + + function test_Swap_TokensForExactETH() public { + uint256 amountOut = 10 ether; + + // Swap eeth to eth + address[] memory path = new address[](2); + path[0] = address(eeth); + path[1] = address(weth); + + uint256 balanceBefore = address(this).balance; + vm.startSnapshotGas("TokensForExactETH: EETH"); + router.swapTokensForExactETH(amountOut, type(uint256).max, path, address(this), block.timestamp + 1); + vm.stopSnapshotGas(); + assertEq(address(this).balance, balanceBefore + amountOut); + } +} diff --git a/test/unit/Router/shared/mocks/MockWrapper.sol b/test/unit/Router/shared/mocks/MockWrapper.sol new file mode 100644 index 00000000..9ac7ed97 --- /dev/null +++ b/test/unit/Router/shared/mocks/MockWrapper.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract MockWrapper is ERC20 { + ERC20 public underlying; + + constructor(address _underlying) + ERC20( + string(abi.encode("Wrapped ", ERC20(_underlying).name())), + string(abi.encode("W", ERC20(_underlying).symbol())), + ERC20(_underlying).decimals() + ) + { + underlying = ERC20(_underlying); + } + + function wrap(uint256 amount) external returns (uint256) { + underlying.transferFrom(msg.sender, address(this), amount); + _mint(msg.sender, amount); + return amount; + } + + function unwrap(uint256 amount) external returns (uint256) { + _burn(msg.sender, amount); + underlying.transfer(msg.sender, amount); + return amount; + } + + function getWstETHByStETH(uint256 amount) external pure returns (uint256) { + return amount; + } + + function getStETHByWstETH(uint256 amount) external pure returns (uint256) { + return amount; + } + + function getWeETHByeETH(uint256 amount) external pure returns (uint256) { + return amount; + } + + function getEETHByWeETH(uint256 amount) external pure returns (uint256) { + return amount; + } +}