From 799c30c366f0c03950c47b25fb32972449e94f58 Mon Sep 17 00:00:00 2001 From: Van0k Date: Tue, 3 Dec 2024 13:36:41 +0400 Subject: [PATCH] feat: equalizer router adapter --- .../equalizer/EqualizerRouterAdapter.sol | 188 +++++++++++++++ .../equalizer/IEqualizerRouter.sol | 31 +++ .../equalizer/IEqualizerRouterAdapter.sol | 58 +++++ .../EqualizerRouterAdapter.harness.sol | 18 ++ .../EqualizerRouterAdapter.unit.t.sol | 221 ++++++++++++++++++ package.json | 2 +- yarn.lock | 8 +- 7 files changed, 521 insertions(+), 5 deletions(-) create mode 100644 contracts/adapters/equalizer/EqualizerRouterAdapter.sol create mode 100644 contracts/integrations/equalizer/IEqualizerRouter.sol create mode 100644 contracts/interfaces/equalizer/IEqualizerRouterAdapter.sol create mode 100644 contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.harness.sol create mode 100644 contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.unit.t.sol diff --git a/contracts/adapters/equalizer/EqualizerRouterAdapter.sol b/contracts/adapters/equalizer/EqualizerRouterAdapter.sol new file mode 100644 index 0000000..95e1499 --- /dev/null +++ b/contracts/adapters/equalizer/EqualizerRouterAdapter.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {RAY} from "@gearbox-protocol/core-v2/contracts/libraries/Constants.sol"; + +import {AbstractAdapter} from "../AbstractAdapter.sol"; +import {AdapterType} from "@gearbox-protocol/sdk-gov/contracts/AdapterType.sol"; + +import {IEqualizerRouter, Route} from "../../integrations/equalizer/IEqualizerRouter.sol"; +import { + IEqualizerRouterAdapter, + EqualizerPoolStatus, + EqualizerPool +} from "../../interfaces/equalizer/IEqualizerRouterAdapter.sol"; + +/// @title Equalizer Router adapter +/// @notice Implements logic allowing CAs to perform swaps via Equalizer +contract EqualizerRouterAdapter is AbstractAdapter, IEqualizerRouterAdapter { + using EnumerableSet for EnumerableSet.Bytes32Set; + + AdapterType public constant override _gearboxAdapterType = AdapterType.EQUALIZER_ROUTER; + uint16 public constant override _gearboxAdapterVersion = 3_00; + + /// @dev Mapping from hash(token0, token1, stable) to whether the pool can be traded through the adapter + mapping(address => mapping(address => mapping(bool => bool))) internal _poolStatus; + + /// @dev Mapping from hash(token0, token1, stable) to respective tuple + mapping(bytes32 => EqualizerPool) internal _hashToPool; + + /// @dev Set of hashes of (token0, token1, stable) for all supported pools + EnumerableSet.Bytes32Set internal _supportedPoolHashes; + + /// @notice Constructor + /// @param _creditManager Credit manager address + /// @param _router Equalizer Router address + constructor(address _creditManager, address _router) AbstractAdapter(_creditManager, _router) {} + + /// @notice Swap given amount of input token to output token + /// @param amountIn Amount of input token to spend + /// @param amountOutMin Minumum amount of output token to receive + /// @param routes Array of Route structs representing a swap path, must have at most 3 elements + /// @param deadline Maximum timestamp until which the transaction is valid + /// @dev Parameter `to` is ignored since swap recipient can only be the credit account + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address, + uint256 deadline + ) external override creditFacadeOnly returns (uint256 tokensToEnable, uint256 tokensToDisable) { + address creditAccount = _creditAccount(); + + (bool valid, address tokenIn, address tokenOut) = _validatePath(routes); + if (!valid) revert InvalidPathException(); + + (tokensToEnable, tokensToDisable,) = _executeSwapSafeApprove( + tokenIn, + tokenOut, + abi.encodeCall( + IEqualizerRouter.swapExactTokensForTokens, (amountIn, amountOutMin, routes, creditAccount, deadline) + ), + false + ); + } + + /// @notice Swap the entire balance of input token to output token, except the specified amount + /// @param leftoverAmount Amount of tokenIn to keep on the account + /// @param rateMinRAY Minimum exchange rate between input and output tokens, scaled by 1e27 + /// @param routes Array of Route structs representing a swap path, must have at most 3 elements + /// @param deadline Maximum timestamp until which the transaction is valid + function swapDiffTokensForTokens( + uint256 leftoverAmount, + uint256 rateMinRAY, + Route[] calldata routes, + uint256 deadline + ) external override creditFacadeOnly returns (uint256 tokensToEnable, uint256 tokensToDisable) { + address creditAccount = _creditAccount(); + + address tokenIn; + address tokenOut; + + { + bool valid; + (valid, tokenIn, tokenOut) = _validatePath(routes); + if (!valid) revert InvalidPathException(); + } + + uint256 amount = IERC20(tokenIn).balanceOf(creditAccount); + if (amount <= leftoverAmount) return (0, 0); + + unchecked { + amount -= leftoverAmount; + } + + (tokensToEnable, tokensToDisable,) = _executeSwapSafeApprove( + tokenIn, + tokenOut, + abi.encodeCall( + IEqualizerRouter.swapExactTokensForTokens, + (amount, (amount * rateMinRAY) / RAY, routes, creditAccount, deadline) + ), + leftoverAmount <= 1 + ); + } + + // ------------- // + // CONFIGURATION // + // ------------- // + + /// @notice Returns whether the (token0, token1) pair is allowed to be traded through the adapter + function isPoolAllowed(address token0, address token1, bool stable) public view override returns (bool) { + (token0, token1) = _sortTokens(token0, token1); + return _poolStatus[token0][token1][stable]; + } + + function supportedPools() public view returns (EqualizerPool[] memory pools) { + bytes32[] memory poolHashes = _supportedPoolHashes.values(); + uint256 len = poolHashes.length; + pools = new EqualizerPool[](len); + for (uint256 i = 0; i < len; ++i) { + pools[i] = _hashToPool[poolHashes[i]]; + } + } + + /// @notice Sets status for a batch of pools + /// @param pools Array of `EqualizerPoolStatus` objects + function setPoolStatusBatch(EqualizerPoolStatus[] calldata pools) external override configuratorOnly { + uint256 len = pools.length; + unchecked { + for (uint256 i; i < len; ++i) { + (address token0, address token1) = _sortTokens(pools[i].token0, pools[i].token1); + _poolStatus[token0][token1][pools[i].stable] = pools[i].allowed; + + bytes32 poolHash = keccak256(abi.encode(token0, token1, pools[i].stable)); + if (pools[i].allowed) { + _supportedPoolHashes.add(poolHash); + _hashToPool[poolHash] = EqualizerPool({token0: token0, token1: token1, stable: pools[i].stable}); + } else { + _supportedPoolHashes.remove(poolHash); + delete _hashToPool[poolHash]; + } + + emit SetPoolStatus(token0, token1, pools[i].stable, pools[i].allowed); + } + } + } + + // ------- // + // HELPERS // + // ------- // + + /// @dev Performs sanity check on a swap path, if path is valid also returns input and output tokens + /// - Path length must be no more than 4 (i.e., at most 3 hops) + /// - Each swap must be through an allowed pool + function _validatePath(Route[] memory routes) + internal + view + returns (bool valid, address tokenIn, address tokenOut) + { + uint256 len = routes.length; + if (len < 1 || len > 3) return (false, tokenIn, tokenOut); + + tokenIn = routes[0].from; + tokenOut = routes[len - 1].to; + valid = isPoolAllowed(routes[0].from, routes[0].to, routes[0].stable); + if (valid && len > 1) { + valid = isPoolAllowed(routes[1].from, routes[1].to, routes[1].stable) && (routes[0].to == routes[1].from); + if (valid && len > 2) { + valid = + isPoolAllowed(routes[2].from, routes[2].to, routes[2].stable) && (routes[1].to == routes[2].from); + } + } + } + + /// @dev Sorts two token addresses + function _sortTokens(address token0, address token1) internal pure returns (address, address) { + if (uint160(token0) < uint160(token1)) { + return (token0, token1); + } else { + return (token1, token0); + } + } +} diff --git a/contracts/integrations/equalizer/IEqualizerRouter.sol b/contracts/integrations/equalizer/IEqualizerRouter.sol new file mode 100644 index 0000000..835d613 --- /dev/null +++ b/contracts/integrations/equalizer/IEqualizerRouter.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +struct Route { + address from; + address to; + bool stable; +} + +interface IEqualizerRouter { + /// @notice Address of the factory + function factory() external view returns (address); + + /// @notice Perform chained getAmountOut calculations on any number of pools + function getAmountsOut(uint256 amountIn, Route[] memory routes) external view returns (uint256[] memory amounts); + + /// @notice Swap one token for another + /// @param amountIn Amount of token in + /// @param amountOutMin Minimum amount of desired token received + /// @param routes Array of trade routes used in the swap + /// @param to Recipient of the tokens received + /// @param deadline Deadline to receive tokens + /// @return amounts Array of amounts returned per route + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); +} diff --git a/contracts/interfaces/equalizer/IEqualizerRouterAdapter.sol b/contracts/interfaces/equalizer/IEqualizerRouterAdapter.sol new file mode 100644 index 0000000..c79bcba --- /dev/null +++ b/contracts/interfaces/equalizer/IEqualizerRouterAdapter.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IAdapter} from "@gearbox-protocol/core-v2/contracts/interfaces/IAdapter.sol"; +import {Route} from "../../integrations/equalizer/IEqualizerRouter.sol"; + +struct EqualizerPool { + address token0; + address token1; + bool stable; +} + +struct EqualizerPoolStatus { + address token0; + address token1; + bool stable; + bool allowed; +} + +interface IEqualizerRouterAdapterEvents { + /// @notice Emited when new status is set for a pair + event SetPoolStatus(address indexed token0, address indexed token1, bool stable, bool allowed); +} + +interface IEqualizerRouterAdapterExceptions { + /// @notice Thrown when sanity checks on a swap path fail + error InvalidPathException(); +} + +/// @title Equalizer Router adapter interface +interface IEqualizerRouterAdapter is IAdapter, IEqualizerRouterAdapterEvents, IEqualizerRouterAdapterExceptions { + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address, + uint256 deadline + ) external returns (uint256 tokensToEnable, uint256 tokensToDisable); + + function swapDiffTokensForTokens( + uint256 leftoverAmount, + uint256 rateMinRAY, + Route[] calldata routes, + uint256 deadline + ) external returns (uint256 tokensToEnable, uint256 tokensToDisable); + + // ------------- // + // CONFIGURATION // + // ------------- // + + function isPoolAllowed(address token0, address token1, bool stable) external view returns (bool); + + function setPoolStatusBatch(EqualizerPoolStatus[] calldata pools) external; + + function supportedPools() external view returns (EqualizerPool[] memory pools); +} diff --git a/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.harness.sol b/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.harness.sol new file mode 100644 index 0000000..5a116d4 --- /dev/null +++ b/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.harness.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {EqualizerRouterAdapter, Route} from "../../../../adapters/equalizer/EqualizerRouterAdapter.sol"; + +contract EqualizerRouterAdapterHarness is EqualizerRouterAdapter { + constructor(address creditManager, address router) EqualizerRouterAdapter(creditManager, router) {} + + function validatePath(Route[] memory routes) + external + view + returns (bool valid, address tokenIn, address tokenOut) + { + return _validatePath(routes); + } +} diff --git a/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.unit.t.sol b/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.unit.t.sol new file mode 100644 index 0000000..1168072 --- /dev/null +++ b/contracts/test/unit/adapters/equalizer/EqualizerRouterAdapter.unit.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IEqualizerRouter, Route} from "../../../../integrations/equalizer/IEqualizerRouter.sol"; +import { + IEqualizerRouterAdapterEvents, + IEqualizerRouterAdapterExceptions, + EqualizerPoolStatus +} from "../../../../interfaces/equalizer/IEqualizerRouterAdapter.sol"; +import {AdapterUnitTestHelper} from "../AdapterUnitTestHelper.sol"; +import {EqualizerRouterAdapterHarness} from "./EqualizerRouterAdapter.harness.sol"; + +/// @title Equalizer adapter unit test +/// @notice U:[EQLZ]: Unit tests for Equalizer swap router adapter +contract EqualizerRouterAdapterUnitTest is + AdapterUnitTestHelper, + IEqualizerRouterAdapterEvents, + IEqualizerRouterAdapterExceptions +{ + EqualizerRouterAdapterHarness adapter; + address router; + + function setUp() public { + _setUp(); + + router = makeAddr("ROUTER"); + adapter = new EqualizerRouterAdapterHarness(address(creditManager), router); + + _setPoolsStatus(3, 7); + } + + /// @notice U:[EQLZ-1]: Constructor works as expected + function test_U_EQLZ_01_constructor_works_as_expected() public { + assertEq(adapter.creditManager(), address(creditManager), "Incorrect creditManager"); + assertEq(adapter.addressProvider(), address(addressProvider), "Incorrect addressProvider"); + assertEq(adapter.targetContract(), router, "Incorrect targetContract"); + } + + /// @notice U:[EQLZ-2]: Wrapper functions revert on wrong caller + function test_U_EQLZ_02_wrapper_functions_revert_on_wrong_caller() public { + Route[] memory emptyPath; + + _revertsOnNonFacadeCaller(); + adapter.swapExactTokensForTokens(0, 0, emptyPath, address(0), 0); + + _revertsOnNonFacadeCaller(); + adapter.swapDiffTokensForTokens(0, 0, emptyPath, 0); + } + + /// @notice U:[EQLZ-3]: `swapExactTokensForTokens` works as expected + function test_U_EQLZ_03_swapExactTokensForTokens_works_as_expected() public { + Route[] memory routes = _makePath(0); + vm.expectRevert(InvalidPathException.selector); + vm.prank(creditFacade); + adapter.swapExactTokensForTokens(123, 456, routes, address(0), 789); + + routes = _makePath(2); + _readsActiveAccount(); + _executesSwap({ + tokenIn: tokens[0], + tokenOut: tokens[1], + callData: abi.encodeCall(IEqualizerRouter.swapExactTokensForTokens, (123, 456, routes, creditAccount, 789)), + requiresApproval: true, + validatesTokens: true + }); + + vm.prank(creditFacade); + (uint256 tokensToEnable, uint256 tokensToDisable) = + adapter.swapExactTokensForTokens(123, 456, routes, address(0), 789); + + assertEq(tokensToEnable, 2, "Incorrect tokensToEnable"); + assertEq(tokensToDisable, 0, "Incorrect tokensToDisable"); + } + + /// @notice U:[EQLZ-4]: `swapDiffTokensForTokens` works as expected + function test_U_EQLZ_04_swapDiffTokensForTokens_works_as_expected() public diffTestCases { + deal({token: tokens[0], to: creditAccount, give: diffMintedAmount}); + + Route[] memory routes = _makePath(0); + vm.expectRevert(InvalidPathException.selector); + vm.prank(creditFacade); + adapter.swapDiffTokensForTokens(diffInputAmount, 0.5e27, routes, 789); + + routes = _makePath(2); + _readsActiveAccount(); + _executesSwap({ + tokenIn: tokens[0], + tokenOut: tokens[1], + callData: abi.encodeCall( + IEqualizerRouter.swapExactTokensForTokens, + (diffInputAmount, diffInputAmount / 2, routes, creditAccount, 789) + ), + requiresApproval: true, + validatesTokens: true + }); + + vm.prank(creditFacade); + (uint256 tokensToEnable, uint256 tokensToDisable) = + adapter.swapDiffTokensForTokens(diffLeftoverAmount, 0.5e27, routes, 789); + + assertEq(tokensToEnable, 2, "Incorrect tokensToEnable"); + assertEq(tokensToDisable, diffDisableTokenIn ? 1 : 0, "Incorrect tokensToDisable"); + } + + /// @notice U:[EQLZ-5]: `setPoolStatusBatch` works as expected + function test_U_EQLZ_05_setPoolStatusBatch_works_as_expected() public { + EqualizerPoolStatus[] memory pools; + + _revertsOnNonConfiguratorCaller(); + adapter.setPoolStatusBatch(pools); + + pools = new EqualizerPoolStatus[](2); + pools[0] = EqualizerPoolStatus(tokens[0], tokens[1], false, false); + pools[1] = EqualizerPoolStatus(tokens[1], tokens[2], true, true); + + vm.expectEmit(true, true, false, true); + emit SetPoolStatus(_min(tokens[0], tokens[1]), _max(tokens[0], tokens[1]), false, false); + + vm.expectEmit(true, true, false, true); + emit SetPoolStatus(_min(tokens[1], tokens[2]), _max(tokens[1], tokens[2]), true, true); + + vm.prank(configurator); + adapter.setPoolStatusBatch(pools); + + assertFalse(adapter.isPoolAllowed(tokens[0], tokens[1], false), "First pair incorrectly allowed"); + assertTrue(adapter.isPoolAllowed(tokens[1], tokens[2], true), "Second pair incorrectly not allowed"); + } + + /// @notice U:[EQLZ-6]: `_validatePath` works as expected + function test_U_EQLZ_06_validatePath_works_as_expected() public { + bool isValid; + address tokenIn; + address tokenOut; + Route[] memory routes; + + // insane paths + (isValid,,) = adapter.validatePath(new Route[](0)); + assertFalse(isValid, "Empty path incorrectly valid"); + + (isValid,,) = adapter.validatePath(new Route[](4)); + assertFalse(isValid, "Long path incorrectly valid"); + + // exhaustive search + for (uint256 pathLen = 2; pathLen <= 4; ++pathLen) { + routes = _makePath(pathLen); + + uint256 numCases = 1 << (pathLen - 1); + for (uint256 mask; mask < numCases; ++mask) { + _setPoolsStatus(pathLen - 1, mask); + (isValid, tokenIn, tokenOut) = adapter.validatePath(routes); + + if (mask == numCases - 1) { + assertTrue(isValid, "Path incorrectly invalid"); + assertEq(tokenIn, tokens[0], "Incorrect tokenIn"); + assertEq(tokenOut, tokens[pathLen - 1], "Incorrect tokenOut"); + } else { + assertFalse(isValid, "Path incorrectly valid"); + } + } + } + } + + /// @notice U:[EQLZ-7]: `_validatePath` works as expected + function test_U_EQLZ_07_validatePath_filters_disjunct_paths() public { + bool isValid; + address tokenIn; + address tokenOut; + Route[] memory routes; + + EqualizerPoolStatus[] memory pools = new EqualizerPoolStatus[](2); + pools[0] = EqualizerPoolStatus(tokens[0], tokens[1], false, true); + pools[1] = EqualizerPoolStatus(tokens[2], tokens[3], false, true); + vm.prank(configurator); + adapter.setPoolStatusBatch(pools); + + routes = new Route[](2); + routes[0] = Route({from: tokens[0], to: tokens[1], stable: false}); + routes[1] = Route({from: tokens[2], to: tokens[3], stable: false}); + + (isValid, tokenIn, tokenOut) = adapter.validatePath(routes); + + assertFalse(isValid, "Path incorrectly valid"); + } + + // ------- // + // HELPERS // + // ------- // + + /// @dev Returns swap path of `len` consecutive `tokens` + function _makePath(uint256 len) internal view returns (Route[] memory routes) { + if (len == 0 || len == 1) return new Route[](0); + + routes = new Route[](len - 1); + for (uint256 i; i < len - 1; ++i) { + routes[i] = Route({from: tokens[i], to: tokens[i + 1], stable: false}); + } + } + + /// @dev Sets statuses for `len` consecutive pairs of `tokens` based on `allowedPoolsMask` + function _setPoolsStatus(uint256 len, uint256 allowedPoolsMask) internal { + EqualizerPoolStatus[] memory pools = new EqualizerPoolStatus[](len); + for (uint256 i; i < len; ++i) { + uint256 mask = 1 << i; + pools[i] = EqualizerPoolStatus(tokens[i], tokens[i + 1], false, allowedPoolsMask & mask != 0); + } + vm.prank(configurator); + adapter.setPoolStatusBatch(pools); + } + + /// @dev Returns smaller of two addresses + function _min(address token0, address token1) internal pure returns (address) { + return token0 < token1 ? token0 : token1; + } + + /// @dev Returns larger of two addresses + function _max(address token0, address token1) internal pure returns (address) { + return token0 < token1 ? token1 : token0; + } +} diff --git a/package.json b/package.json index 007b709..0969b2b 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@gearbox-protocol/eslint-config": "^1.6.1", "@gearbox-protocol/oracles-v3": "^1.12.1", "@gearbox-protocol/prettier-config": "^1.5.0", - "@gearbox-protocol/sdk-gov": "^2.28.0", + "@gearbox-protocol/sdk-gov": "^2.32.0", "@openzeppelin/contracts": "4.9.3", "@redstone-finance/evm-connector": "0.2.5", "@typechain/ethers-v5": "^10.1.0", diff --git a/yarn.lock b/yarn.lock index 25b05d0..7ff2871 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1063,10 +1063,10 @@ resolved "https://registry.yarnpkg.com/@gearbox-protocol/prettier-config/-/prettier-config-1.5.0.tgz#4df8e9fd2305fee6ab8c1417a02e31343836932a" integrity sha512-FUoprSsBdZyBjgxXCKL6mTkbeUJytaLzPJqIOoQpDmBRTX0seCc2o5I9PI9tySoRIlNnd/XXnKCXq1xHDEGbxw== -"@gearbox-protocol/sdk-gov@^2.28.0": - version "2.28.0" - resolved "https://registry.yarnpkg.com/@gearbox-protocol/sdk-gov/-/sdk-gov-2.28.0.tgz#f858222b0d4bf2661e7213d250f96e112aee8ce3" - integrity sha512-qwvXZALpqYRAc77B6ZC0OSIDk6uEX8Z1B10yfN10ETBvftnDvMwJ6XVhVt/RyRgeW0vqh0W6T4F/y2PFiiW0bw== +"@gearbox-protocol/sdk-gov@^2.32.0": + version "2.32.0" + resolved "https://registry.yarnpkg.com/@gearbox-protocol/sdk-gov/-/sdk-gov-2.32.0.tgz#40f16de97139d162996723f7440209b012f51814" + integrity sha512-rmot5Lf4bLLl0YICH5hKzXVd3eyc33PTFgMNHGilm1tNHIXdF0eHkauytTEXwMoKol6fB6oZTkVxB9SY9XEowg== dependencies: ethers "6.12.1" humanize-duration-ts "^2.1.1"