From 4bd6e3094812883464bbfa6467262123e3c72e50 Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Mon, 27 May 2024 10:44:46 +0300 Subject: [PATCH] feat: DAI-like permit and live tests for zappers (#124) In this PR: * add DAI-like permit to zappers * move `ZapperRegister` from periphery * add generic live tests for all zappers * fix CI --- .github/workflows/pr.yml | 5 +- .../external/IERC20PermitAllowed.sol | 27 +++ .../zappers/IERC20ZapperDeposits.sol | 21 +++ contracts/interfaces/zappers/IZapper.sol | 10 ++ .../interfaces/zappers/IZapperRegister.sol | 20 +++ .../test/live/adapters/AdapterTestHelper.sol | 80 --------- .../test/live/zappers/AllZappers.live.t.sol | 160 +++++++++++++++++ .../test/suites/ZapperLiveTestHelper.sol | 162 ++++++++++++++++++ .../test/unit/zappers/ZapperBase.unit.t.sol | 41 ++++- contracts/zappers/ERC20ZapperBase.sol | 39 ++++- contracts/zappers/ZapperBase.sol | 31 +++- contracts/zappers/ZapperRegister.sol | 48 ++++++ 12 files changed, 549 insertions(+), 95 deletions(-) create mode 100644 contracts/integrations/external/IERC20PermitAllowed.sol create mode 100644 contracts/interfaces/zappers/IZapperRegister.sol delete mode 100644 contracts/test/live/adapters/AdapterTestHelper.sol create mode 100644 contracts/test/live/zappers/AllZappers.live.t.sol create mode 100644 contracts/test/suites/ZapperLiveTestHelper.sol create mode 100644 contracts/zappers/ZapperRegister.sol diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 65560a87..28c888b5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,7 @@ on: env: HUSKY: 0 CI: true + ATTACH_ADDRESS_PROVIDER: "0x9ea7b04Da02a5373317D745c1571c84aaD03321D" jobs: checks: @@ -37,10 +38,10 @@ jobs: run: forge install - name: Run forge unit tests - run: forge test --match-test test_U + run: forge test --match-test test_U -vv - name: Run forge integration tests - run: forge test --match-test _live_ --fork-url ${{ secrets.MAINNET_TESTS_FORK }} --chain-id 1337 + run: forge test --match-test _live_ --fork-url ${{ secrets.MAINNET_TESTS_FORK }} --chain-id 1337 -vv - name: Perform checks run: | diff --git a/contracts/integrations/external/IERC20PermitAllowed.sol b/contracts/integrations/external/IERC20PermitAllowed.sol new file mode 100644 index 00000000..a817e1cf --- /dev/null +++ b/contracts/integrations/external/IERC20PermitAllowed.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Interface for permit +/// @notice Interface used by DAI/CHAI for permit +interface IERC20PermitAllowed { + /// @notice Approve the spender to spend some tokens via the holder signature + /// @dev This is the permit interface used by DAI and CHAI + /// @param holder The address of the token holder, the token owner + /// @param spender The address of the token spender + /// @param nonce The holder's nonce, increases at each call to permit + /// @param expiry The timestamp at which the permit is no longer valid + /// @param allowed Boolean that sets approval amount, true for type(uint256).max and false for 0 + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function permit( + address holder, + address spender, + uint256 nonce, + uint256 expiry, + bool allowed, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} diff --git a/contracts/interfaces/zappers/IERC20ZapperDeposits.sol b/contracts/interfaces/zappers/IERC20ZapperDeposits.sol index dc47cd75..aa0eb004 100644 --- a/contracts/interfaces/zappers/IERC20ZapperDeposits.sol +++ b/contracts/interfaces/zappers/IERC20ZapperDeposits.sol @@ -10,6 +10,16 @@ interface IERC20ZapperDeposits { external returns (uint256 tokenOutAmount); + function depositWithPermitAllowed( + uint256 tokenInAmount, + address receiver, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenOutAmount); + function depositWithReferral(uint256 tokenInAmount, address receiver, uint256 referralCode) external returns (uint256 tokenOutAmount); @@ -23,4 +33,15 @@ interface IERC20ZapperDeposits { bytes32 r, bytes32 s ) external returns (uint256 tokenOutAmount); + + function depositWithReferralAndPermitAllowed( + uint256 tokenInAmount, + address receiver, + uint256 referralCode, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenOutAmount); } diff --git a/contracts/interfaces/zappers/IZapper.sol b/contracts/interfaces/zappers/IZapper.sol index 3c912adc..f8c2ecf9 100644 --- a/contracts/interfaces/zappers/IZapper.sol +++ b/contracts/interfaces/zappers/IZapper.sol @@ -21,4 +21,14 @@ interface IZapper { function redeemWithPermit(uint256 tokenOutAmount, address receiver, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external returns (uint256 tokenInAmount); + + function redeemWithPermitAllowed( + uint256 tokenOutAmount, + address receiver, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenInAmount); } diff --git a/contracts/interfaces/zappers/IZapperRegister.sol b/contracts/interfaces/zappers/IZapperRegister.sol new file mode 100644 index 00000000..fdb8b701 --- /dev/null +++ b/contracts/interfaces/zappers/IZapperRegister.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; + +interface IZapperRegisterEvents { + event AddZapper(address); + + event RemoveZapper(address); +} + +interface IZapperRegister is IVersion, IZapperRegisterEvents { + function zappers(address pool) external view returns (address[] memory); + + function addZapper(address zapper) external; + + function removeZapper(address zapper) external; +} diff --git a/contracts/test/live/adapters/AdapterTestHelper.sol b/contracts/test/live/adapters/AdapterTestHelper.sol deleted file mode 100644 index b7682c47..00000000 --- a/contracts/test/live/adapters/AdapterTestHelper.sol +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// Gearbox Protocol. Generalized leverage for DeFi protocols -// (c) Gearbox Foundation, 2023. -pragma solidity ^0.8.17; - -import { - ICreditManagerV3, - ICreditManagerV3Events -} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditManagerV3.sol"; -import {ICreditFacadeV3Events} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; - -// TEST -import "../../lib/constants.sol"; - -// SUITES -import {TokensTestSuite} from "@gearbox-protocol/core-v3/contracts/test/suites/TokensTestSuite.sol"; -import {Tokens} from "@gearbox-protocol/sdk-gov/contracts/Tokens.sol"; - -// import {BalanceHelper} from "../../helpers/BalanceHelper.sol"; -// import {CreditFacadeTestHelper} from "../../helpers/CreditFacadeTestHelper.sol"; -import {IntegrationTestHelper} from "@gearbox-protocol/core-v3/contracts/test/helpers/IntegrationTestHelper.sol"; -// import {CreditConfig} from "../../config/CreditConfig.sol"; - -contract AdapterTestHelper is Test, ICreditManagerV3Events, ICreditFacadeV3Events, IntegrationTestHelper { - function _setUp() internal { - _setUp(Tokens.DAI); - } - - function _setUp(Tokens t) internal { - require(t == Tokens.DAI || t == Tokens.WETH || t == Tokens.STETH, "Unsupported token"); - - tokenTestSuite = new TokensTestSuite(); - tokenTestSuite.topUpWETH{value: 100 * WAD}(); - - // CreditConfig creditConfig = new CreditConfig(tokenTestSuite, t); - - // cft = new CreditFacadeV3TestSuite(creditConfig); - - // underlying = cft.underlying(); - - // CreditManagerV3 = cft.CreditManagerV3(); - // creditFacade = cft.creditFacade(); - // CreditConfiguratorV3 = cft.CreditConfiguratorV3(); - } - - function _getUniswapDeadline() internal view returns (uint256) { - return block.timestamp + 1; - } - - function expectMulticallStackCalls( - address creditAccount, - address borrower, - address targetContract, - bytes memory callData, - address tokenIn, - bool allowTokenIn - ) internal { - vm.expectEmit(true, true, false, false); - emit StartMultiCall(creditAccount, borrower); - - if (allowTokenIn) { - vm.expectCall( - address(creditManager), - abi.encodeCall(ICreditManagerV3.approveCreditAccount, (tokenIn, type(uint256).max)) - ); - } - - vm.expectCall(address(creditManager), abi.encodeCall(ICreditManagerV3.execute, (callData))); - - vm.expectEmit(true, false, false, false); - emit Execute(creditAccount, targetContract); - - if (allowTokenIn) { - vm.expectCall(address(creditManager), abi.encodeCall(ICreditManagerV3.approveCreditAccount, (tokenIn, 1))); - } - - vm.expectEmit(false, false, false, false); - emit FinishMultiCall(); - } -} diff --git a/contracts/test/live/zappers/AllZappers.live.t.sol b/contracts/test/live/zappers/AllZappers.live.t.sol new file mode 100644 index 00000000..774b2761 --- /dev/null +++ b/contracts/test/live/zappers/AllZappers.live.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {SafeERC20} from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IZapper} from "../../../interfaces/zappers/IZapper.sol"; +import {ETH_ADDRESS, IETHZapperDeposits} from "../../../interfaces/zappers/IETHZapperDeposits.sol"; +import {IERC20ZapperDeposits} from "../../../interfaces/zappers/IERC20ZapperDeposits.sol"; + +import {ZapperLiveTestHelper} from "../../suites/ZapperLiveTestHelper.sol"; + +/// @notice Generic test for all deployed zappers. +/// @dev Deposits and redeems might revert for various natural reasons not necessarily related to zapper's correctness, +/// which can't be handled in the general case and must be dealt with in specialized tests. This test simply wraps +/// all reverts to avoid unnecessary false negatives, and only ensures that *non-reverting* zappers work properly. +contract AllZappersLiveTest is ZapperLiveTestHelper { + using SafeERC20 for ERC20; + + address user = makeAddr("user"); + address receiver = makeAddr("receiver"); + + function test_live_all_zappers() public attachOrLiveZapperTest { + emit log_string(""); + emit log_named_address("Pool", address(pool)); + address[] memory zappers = zapperRegister.zappers(address(pool)); + for (uint256 i; i < zappers.length; ++i) { + emit log_string(""); + emit log_named_address("Zapper", zappers[i]); + + address tokenIn = IZapper(zappers[i]).tokenIn(); + address tokenOut = IZapper(zappers[i]).tokenOut(); + emit log_named_address("Input token", tokenIn); + emit log_named_address("Output token", tokenOut); + + uint256 snapshot = vm.snapshot(); + _test_deposit(zappers[i], tokenIn, tokenOut); + vm.revertTo(snapshot); + _test_redeem(zappers[i], tokenIn, tokenOut); + vm.revertTo(snapshot); + } + } + + function _test_deposit(address zapper, address tokenIn, address tokenOut) internal { + uint256 tokenInDecimals = (tokenIn == ETH_ADDRESS ? 18 : ERC20(tokenIn).decimals()); + uint256 tokenOutDecimals = ERC20(tokenOut).decimals(); + + uint256 tokenInAmount = 10 ** tokenInDecimals; + try IZapper(zapper).previewDeposit(tokenInAmount) returns (uint256 previewAmountOut) { + assertGt(previewAmountOut, 0, "Deposit preview returns 0"); + uint256 tokenOutBalanceBefore = ERC20(tokenOut).balanceOf(receiver); + + (uint256 tokenOutAmount, bool success, bytes memory reason) = tokenIn == ETH_ADDRESS + ? _depositETH(zapper, tokenInAmount) + : _depositERC20(zapper, tokenIn, tokenInAmount); + if (!success) { + emit log_string(string.concat("Deposit failed, reason: ", vm.toString(reason))); + return; + } + + assertGe(tokenOutAmount, previewAmountOut, "previewDeposit overestimates"); + uint256 tokenOutBalanceAfter = ERC20(tokenOut).balanceOf(receiver); + assertEq(tokenOutBalanceAfter, tokenOutBalanceBefore + tokenOutAmount, "Incorrect amount received"); + + emit log_named_decimal_uint("Deposited", tokenInAmount, tokenInDecimals); + emit log_named_decimal_uint("Received", tokenOutAmount, tokenOutDecimals); + } catch (bytes memory reason) { + if (_isNotImplementedException(reason)) { + emit log_string("Deposit is not supported"); + } else { + emit log_string(string.concat("Deposit preview failed, reason: ", vm.toString(reason))); + } + } + } + + function _test_redeem(address zapper, address tokenIn, address tokenOut) internal { + uint256 tokenInDecimals = tokenIn == ETH_ADDRESS ? 18 : ERC20(tokenIn).decimals(); + uint256 tokenOutDecimals = ERC20(tokenOut).decimals(); + + uint256 tokenOutAmount = 10 ** tokenOutDecimals; + try IZapper(zapper).previewRedeem(tokenOutAmount) returns (uint256 previewAmountIn) { + assertGt(previewAmountIn, 0, "Redeem preview returns 0"); + uint256 tokenInBalanceBefore = + tokenIn == ETH_ADDRESS ? address(receiver).balance : ERC20(tokenIn).balanceOf(receiver); + + (uint256 tokenInAmount, bool success, bytes memory reason) = _redeem(zapper, tokenOut, tokenOutAmount); + if (!success) { + emit log_string(string.concat("Redeem failed, reason: ", vm.toString(reason))); + return; + } + + assertGe(tokenInAmount, previewAmountIn, "Redeem preview overestimates"); + uint256 tokenInBalanceAfter = + tokenIn == ETH_ADDRESS ? address(receiver).balance : ERC20(tokenIn).balanceOf(receiver); + assertEq(tokenInBalanceAfter, tokenInBalanceBefore + tokenInAmount, "Incorrect amount received"); + + emit log_named_decimal_uint("Redeemed", tokenOutAmount, tokenOutDecimals); + emit log_named_decimal_uint("Received", tokenInAmount, tokenInDecimals); + } catch (bytes memory reason) { + if (_isNotImplementedException(reason)) { + emit log_string("Redeem is not supported"); + } else { + emit log_string(string.concat("Redeem preview failed, reason: ", vm.toString(reason))); + } + } + } + + function _depositETH(address zapper, uint256 tokenInAmount) + internal + returns (uint256 tokenOutAmount, bool success, bytes memory revertReason) + { + deal(user, tokenInAmount); + vm.prank(user); + try IETHZapperDeposits(zapper).deposit{value: tokenInAmount}(receiver) returns (uint256 value) { + tokenOutAmount = value; + success = true; + } catch (bytes memory reason) { + revertReason = reason; + } + } + + function _depositERC20(address zapper, address tokenIn, uint256 tokenInAmount) + internal + returns (uint256 tokenOutAmount, bool success, bytes memory revertReason) + { + deal(tokenIn, user, tokenInAmount); + vm.startPrank(user); + ERC20(tokenIn).forceApprove(zapper, tokenInAmount); + try IERC20ZapperDeposits(zapper).deposit(tokenInAmount, receiver) returns (uint256 value) { + tokenOutAmount = value; + success = true; + } catch (bytes memory reason) { + revertReason = reason; + } + vm.stopPrank(); + } + + function _redeem(address zapper, address tokenOut, uint256 tokenOutAmount) + internal + returns (uint256 tokenInAmount, bool success, bytes memory revertReason) + { + deal(tokenOut, user, tokenOutAmount); + vm.startPrank(user); + ERC20(tokenOut).forceApprove(zapper, tokenOutAmount); + try IZapper(zapper).redeem(tokenOutAmount, receiver) returns (uint256 value) { + tokenInAmount = value; + success = true; + } catch (bytes memory reason) { + revertReason = reason; + } + vm.stopPrank(); + } + + function _isNotImplementedException(bytes memory reason) internal pure returns (bool) { + // bytes4(keccak256(bytes("NotImplementedException()"))) + return reason.length == 4 && bytes4(reason) == 0x24e46f70; + } +} diff --git a/contracts/test/suites/ZapperLiveTestHelper.sol b/contracts/test/suites/ZapperLiveTestHelper.sol new file mode 100644 index 00000000..4214685c --- /dev/null +++ b/contracts/test/suites/ZapperLiveTestHelper.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +import {PoolV3} from "@gearbox-protocol/core-v3/contracts/pool/PoolV3.sol"; +import {Tokens} from "@gearbox-protocol/sdk-gov/contracts/Tokens.sol"; + +import {IZapper} from "../../interfaces/zappers/IZapper.sol"; +import {IZapperRegister} from "../../interfaces/zappers/IZapperRegister.sol"; + +import {DTokenDepositZapper} from "../../zappers/DTokenDepositZapper.sol"; +import {DTokenFarmingZapper} from "../../zappers/DTokenFarmingZapper.sol"; +import {UnderlyingDepositZapper} from "../../zappers/UnderlyingDepositZapper.sol"; +import {UnderlyingFarmingZapper} from "../../zappers/UnderlyingFarmingZapper.sol"; +import {WETHDepositZapper} from "../../zappers/WETHDepositZapper.sol"; +import {WETHFarmingZapper} from "../../zappers/WETHFarmingZapper.sol"; +import {WstETHDepositZapper} from "../../zappers/WstETHDepositZapper.sol"; +import {WstETHFarmingZapper} from "../../zappers/WstETHFarmingZapper.sol"; +import {ZapperRegister} from "../../zappers/ZapperRegister.sol"; + +import {LiveTestHelper} from "./LiveTestHelper.sol"; + +contract ZapperLiveTestHelper is LiveTestHelper { + IZapperRegister zapperRegister; + mapping(address => address) farmingPools; + mapping(address => address) legacyPools; + + modifier attachOrLiveZapperTest() { + // Setting `--chain-id` to 1337 or 31337 will make `NetworkDetector` try to deduce chain id + // by calling known contracts on various networks, and set `chainId` to the deduced value. + // If it fails, the value stays equal to the passed one, and the test can be skipped. + if (chainId == 1337 || chainId == 31337) return; + + // Either `ATTACH_ADDRESS_PROVIDER` or `LIVE_TEST_CONFIG` must be specified. + // The former allows to test zappers on existing pools, while the latter allows to create an arbitrary one. + address attachedAddressProvider = vm.envOr("ATTACH_ADDRESS_PROVIDER", address(0)); + if (attachedAddressProvider != address(0)) { + _attachCore(); + _attachState(); + + // By default, attach tests are run for already deployed zappers. + // To test the ones that are not deployed yet, set `REDEPLOY_ZAPPERS` to `true`. + bool redeployZappers = vm.envOr("REDEPLOY_ZAPPERS", false); + if (redeployZappers) { + zapperRegister = new ZapperRegister(address(addressProvider)); + } else { + uint256 version = vm.envOr("ATTACH_ZAPPER_REGISTER_VERSION", uint256(300)); + zapperRegister = IZapperRegister(addressProvider.getAddressOrRevert("ZAPPER_REGISTER", version)); + } + + // If `ATTACH_POOL` is specified, the tests are executed only for this pool's zappers. + // Otherwise, they are run for all v3 pools. + address attachedPool = vm.envOr("ATTACH_POOL", address(0)); + if (attachedPool != address(0)) { + _attachPool(attachedPool); + if (redeployZappers) _deployZappers(address(pool)); + _; + } else { + address[] memory pools = cr.getPools(); + for (uint256 i; i < pools.length; ++i) { + if (PoolV3(pools[i]).version() >= 3_00 && !PoolV3(pools[i]).paused()) { + _attachPool(pools[i]); + if (redeployZappers) _deployZappers(pools[i]); + _; + } + } + } + } else { + // Deploy the system from scratch using given config. + _setupCore(); + _attachState(); + zapperRegister = new ZapperRegister(address(addressProvider)); + _deployPool(getDeployConfig(vm.envString("LIVE_TEST_CONFIG"))); + _deployZappers(address(pool)); + _; + } + } + + function _getZapper(address pool, address tokenIn, address tokenOut) internal view returns (address) { + // TODO: Assumes that (tokenIn, tokenOut) uniquely identify a zapper, which is not necessarily true. + address[] memory zappers = zapperRegister.zappers(pool); + for (uint256 i; i < zappers.length; ++i) { + if (IZapper(zappers[i]).tokenIn() == tokenIn && IZapper(zappers[i]).tokenOut() == tokenOut) { + return zappers[i]; + } + } + return address(0); + } + + function _deployZappers(address pool) internal { + address underlying = PoolV3(pool).underlyingToken(); + address farmingPool = farmingPools[pool]; + + // Underlying zapper + try IERC20Permit(underlying).DOMAIN_SEPARATOR() returns (bytes32) { + zapperRegister.addZapper(address(new UnderlyingDepositZapper(pool))); + } catch {} + if (farmingPool != address(0)) { + zapperRegister.addZapper(address(new UnderlyingFarmingZapper(pool, farmingPool))); + } + + // dToken zapper + address legacyPool = legacyPools[underlying]; + if (legacyPool != address(0)) { + zapperRegister.addZapper(address(new DTokenDepositZapper(pool, legacyPool))); + if (farmingPool != address(0)) { + zapperRegister.addZapper(address(new DTokenFarmingZapper(pool, legacyPool, farmingPool))); + } + } + + // WETH zapper + if (underlying == tokenTestSuite.addressOf(Tokens.WETH)) { + zapperRegister.addZapper(address(new WETHDepositZapper(pool))); + if (farmingPool != address(0)) { + zapperRegister.addZapper(address(new WETHFarmingZapper(pool, farmingPool))); + } + } + + // wstETH zapper + if ( + underlying == tokenTestSuite.addressOf(Tokens.wstETH) + && tokenTestSuite.addressOf(Tokens.STETH) != address(0) + ) { + zapperRegister.addZapper(address(new WstETHDepositZapper(pool))); + if (farmingPool != address(0)) { + zapperRegister.addZapper(address(new WstETHFarmingZapper(pool, farmingPool))); + } + } + } + + function _attachState() internal { + // TODO: Would be nice to have this information stored in sdk-gov instead. + // Not exactly clear how to test in case farming pool is not deployed yet. + farmingPools[tokenTestSuite.addressOf(Tokens.dWETHV3)] = tokenTestSuite.addressOf(Tokens.sdWETHV3); + farmingPools[tokenTestSuite.addressOf(Tokens.dWBTCV3)] = tokenTestSuite.addressOf(Tokens.sdWBTCV3); + farmingPools[tokenTestSuite.addressOf(Tokens.dUSDCV3)] = tokenTestSuite.addressOf(Tokens.sdUSDCV3); + farmingPools[tokenTestSuite.addressOf(Tokens.dUSDTV3)] = tokenTestSuite.addressOf(Tokens.sdUSDTV3); + farmingPools[tokenTestSuite.addressOf(Tokens.dDAIV3)] = tokenTestSuite.addressOf(Tokens.sdDAIV3); + farmingPools[tokenTestSuite.addressOf(Tokens.dGHOV3)] = tokenTestSuite.addressOf(Tokens.sdGHOV3); + + legacyPools[tokenTestSuite.addressOf(Tokens.DAI)] = _getLegacyPool(Tokens.DAI); + legacyPools[tokenTestSuite.addressOf(Tokens.WETH)] = _getLegacyPool(Tokens.WETH); + legacyPools[tokenTestSuite.addressOf(Tokens.WBTC)] = _getLegacyPool(Tokens.WBTC); + legacyPools[tokenTestSuite.addressOf(Tokens.USDC)] = _getLegacyPool(Tokens.USDC); + legacyPools[tokenTestSuite.addressOf(Tokens.FRAX)] = _getLegacyPool(Tokens.FRAX); + legacyPools[tokenTestSuite.addressOf(Tokens.wstETH)] = _getLegacyPool(Tokens.wstETH); + } + + function _getLegacyPool(Tokens t) internal view returns (address) { + address token = tokenTestSuite.addressOf(t); + if (token == address(0)) return address(0); + (bool success, bytes memory result) = token.staticcall(abi.encodeWithSignature("owner()")); + if (!success) { + (success, result) = token.staticcall(abi.encodeWithSignature("poolService()")); + if (!success) return address(0); + } + return abi.decode(result, (address)); + } +} diff --git a/contracts/test/unit/zappers/ZapperBase.unit.t.sol b/contracts/test/unit/zappers/ZapperBase.unit.t.sol index 7c43ee56..4152e2a9 100644 --- a/contracts/test/unit/zappers/ZapperBase.unit.t.sol +++ b/contracts/test/unit/zappers/ZapperBase.unit.t.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.17; import {Test} from "forge-std/Test.sol"; import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {ERC20Mock} from "@gearbox-protocol/core-v3/contracts/test/mocks/token/ERC20Mock.sol"; +import {IERC20PermitAllowed} from "../../../integrations/external/IERC20PermitAllowed.sol"; import {PoolV3Mock} from "../../mocks/pool/PoolV3Mock.sol"; import {ZapperBaseHarness} from "./ZapperBase.harness.sol"; @@ -299,6 +300,12 @@ contract ZapperBaseUnitTest is Test { address expectedAssetsReceiver; } + enum PermitType { + No, + EIP2612, + DAILike + } + /// @notice U:[ZB-5]: `redeem` works as expected function test_U_ZB_05_redeem_works_as_expected() public { RedeemTestCase[4] memory cases = [ @@ -358,8 +365,8 @@ contract ZapperBaseUnitTest is Test { zapper.hackTokenInExchangeRate(cases[i].tokenInExchangeRate); zapper.hackTokenOutExchangeRate(cases[i].tokenOutExchangeRate); - for (uint256 j; j < 2; ++j) { - bool withPermit = j == 1; + for (uint256 j; j < 3; ++j) { + PermitType permitType = PermitType(j); if (cases[i].tokenOut != address(pool)) { vm.expectEmit(false, false, false, true); @@ -379,7 +386,7 @@ contract ZapperBaseUnitTest is Test { emit ConvertUnderlyingToTokenIn(cases[i].expectedAssets, cases[i].expectedTokenInAmount, receiver); } - if (withPermit) { + if (permitType == PermitType.EIP2612) { vm.mockCall( cases[i].tokenOut, abi.encodeCall( @@ -395,17 +402,39 @@ contract ZapperBaseUnitTest is Test { (owner, address(zapper), cases[i].tokenOutAmount, 0, 0, bytes32(0), bytes32(0)) ) ); + } else if (permitType == PermitType.DAILike) { + vm.mockCall( + cases[i].tokenOut, + abi.encodeCall( + IERC20PermitAllowed.permit, (owner, address(zapper), 0, 0, true, 0, bytes32(0), bytes32(0)) + ), + bytes("") + ); + vm.expectCall( + cases[i].tokenOut, + abi.encodeCall( + IERC20PermitAllowed.permit, (owner, address(zapper), 0, 0, true, 0, bytes32(0), bytes32(0)) + ) + ); } vm.prank(owner); - uint256 tokenInAmount = withPermit + uint256 tokenInAmount = permitType == PermitType.EIP2612 ? zapper.redeemWithPermit(cases[i].tokenOutAmount, receiver, 0, 0, bytes32(0), bytes32(0)) - : zapper.redeem(cases[i].tokenOutAmount, receiver); + : permitType == PermitType.DAILike + ? zapper.redeemWithPermitAllowed(cases[i].tokenOutAmount, receiver, 0, 0, 0, bytes32(0), bytes32(0)) + : zapper.redeem(cases[i].tokenOutAmount, receiver); assertEq( tokenInAmount, cases[i].expectedTokenInAmount, - string.concat("case #", vm.toString(i), withPermit ? " (with permit)" : "") + string.concat( + "case #", + vm.toString(i), + permitType == PermitType.EIP2612 + ? " (with permit)" + : permitType == PermitType.DAILike ? " (with DAI permit)" : "" + ) ); } } diff --git a/contracts/zappers/ERC20ZapperBase.sol b/contracts/zappers/ERC20ZapperBase.sol index e3769196..3e68548a 100644 --- a/contracts/zappers/ERC20ZapperBase.sol +++ b/contracts/zappers/ERC20ZapperBase.sol @@ -3,7 +3,6 @@ // (c) Gearbox Foundation, 2023. pragma solidity ^0.8.17; -import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {ZapperBase} from "./ZapperBase.sol"; import {IERC20ZapperDeposits} from "../interfaces/zappers/IERC20ZapperDeposits.sol"; @@ -28,7 +27,25 @@ abstract contract ERC20ZapperBase is ZapperBase, IERC20ZapperDeposits { external returns (uint256 tokenOutAmount) { - _permitTokenIn(tokenInAmount, deadline, v, r, s); + _permit(tokenIn(), tokenInAmount, deadline, v, r, s); + tokenOutAmount = _deposit(tokenInAmount, receiver, false, 0); + } + + /// @notice Performs deposit zap using signed DAI-like permit message for zapper's input token: + /// - receives `tokenInAmount` of `tokenIn` from `msg.sender` and converts it to `underlying` + /// - deposits `underlying` into `pool` + /// - converts `pool`'s shares to `tokenOutAmount` of `tokenOut` and sends it to `receiver` + /// @dev `v`, `r`, `s` must be a valid signature of the permit message from `msg.sender` for `tokenIn` to this contract + function depositWithPermitAllowed( + uint256 tokenInAmount, + address receiver, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenOutAmount) { + _permitAllowed(tokenIn(), nonce, expiry, v, r, s); tokenOutAmount = _deposit(tokenInAmount, receiver, false, 0); } @@ -50,12 +67,22 @@ abstract contract ERC20ZapperBase is ZapperBase, IERC20ZapperDeposits { bytes32 r, bytes32 s ) external returns (uint256 tokenOutAmount) { - _permitTokenIn(tokenInAmount, deadline, v, r, s); + _permit(tokenIn(), tokenInAmount, deadline, v, r, s); tokenOutAmount = _deposit(tokenInAmount, receiver, true, referralCode); } - /// @dev Executes `tokenIn` permit from `msg.sender` to this contract - function _permitTokenIn(uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) internal { - try IERC20Permit(tokenIn()).permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {} + /// @notice Same as `depositWithPermitAllowed` but allows specifying the `referralCode` when depositing into the pool + function depositWithReferralAndPermitAllowed( + uint256 tokenInAmount, + address receiver, + uint256 referralCode, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenOutAmount) { + _permitAllowed(tokenIn(), nonce, expiry, v, r, s); + tokenOutAmount = _deposit(tokenInAmount, receiver, false, referralCode); } } diff --git a/contracts/zappers/ZapperBase.sol b/contracts/zappers/ZapperBase.sol index 2563dad2..636519cf 100644 --- a/contracts/zappers/ZapperBase.sol +++ b/contracts/zappers/ZapperBase.sol @@ -7,6 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {SafeERC20} from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol"; import {IPoolV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IPoolV3.sol"; +import {IERC20PermitAllowed} from "../integrations/external/IERC20PermitAllowed.sol"; import {IZapper} from "../interfaces/zappers/IZapper.sol"; /// @title Zapper base @@ -87,7 +88,25 @@ abstract contract ZapperBase is IZapper { external returns (uint256 tokenInAmount) { - try IERC20Permit(tokenOut()).permit(msg.sender, address(this), tokenOutAmount, deadline, v, r, s) {} catch {} // U:[ZB-5] + _permit(tokenOut(), tokenOutAmount, deadline, v, r, s); // U:[ZB-5] + tokenInAmount = _redeem(tokenOutAmount, receiver, msg.sender); + } + + /// @notice Performs redeem zap using signed DAI-like permit message for zapper's output token: + /// - receives `tokenOut` from `msg.sender` and converts it to `pool`'s shares + /// - redeems `pool`'s shares for `underlying` + /// - converts `underlying` to `tokenIn` and sends it to `receiver` + /// @dev `v`, `r`, `s` must be a valid signature of the permit message from `msg.sender` for `tokenOut` to this contract + function redeemWithPermitAllowed( + uint256 tokenOutAmount, + address receiver, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 tokenInAmount) { + _permitAllowed(tokenOut(), nonce, expiry, v, r, s); // U:[ZB-5] tokenInAmount = _redeem(tokenOutAmount, receiver, msg.sender); } @@ -145,4 +164,14 @@ abstract contract ZapperBase is IZapper { function _resetAllowance(address token, address spender) internal { IERC20(token).forceApprove(spender, type(uint256).max); } + + /// @dev Executes EIP-2612 permit for `token` from `msg.sender` to this contract + function _permit(address token, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) internal { + try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {} + } + + /// @dev Executes DAI-like permit for `token` from `msg.sender` to this contract + function _permitAllowed(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) internal { + try IERC20PermitAllowed(token).permit(msg.sender, address(this), nonce, expiry, true, v, r, s) {} catch {} + } } diff --git a/contracts/zappers/ZapperRegister.sol b/contracts/zappers/ZapperRegister.sol new file mode 100644 index 00000000..d61e30eb --- /dev/null +++ b/contracts/zappers/ZapperRegister.sol @@ -0,0 +1,48 @@ +// 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 {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {ACLNonReentrantTrait} from "@gearbox-protocol/core-v3/contracts/traits/ACLNonReentrantTrait.sol"; +import {ContractsRegisterTrait} from "@gearbox-protocol/core-v3/contracts/traits/ContractsRegisterTrait.sol"; + +import {IZapper} from "../interfaces/zappers/IZapper.sol"; +import {IZapperRegister} from "../interfaces/zappers/IZapperRegister.sol"; + +contract ZapperRegister is ACLNonReentrantTrait, ContractsRegisterTrait, IZapperRegister { + using EnumerableSet for EnumerableSet.AddressSet; + + uint256 public constant override version = 3_00; + + mapping(address => EnumerableSet.AddressSet) internal _zappersMap; + + constructor(address addressProvider) + ACLNonReentrantTrait(addressProvider) + ContractsRegisterTrait(addressProvider) + {} + + function zappers(address pool) external view override returns (address[] memory) { + return _zappersMap[pool].values(); + } + + function addZapper(address zapper) external override nonZeroAddress(zapper) controllerOnly { + address pool = IZapper(zapper).pool(); + _ensureRegisteredPool(pool); + + EnumerableSet.AddressSet storage zapperSet = _zappersMap[pool]; + if (!zapperSet.contains(zapper)) { + zapperSet.add(zapper); + emit AddZapper(zapper); + } + } + + function removeZapper(address zapper) external override nonZeroAddress(zapper) controllerOnly { + EnumerableSet.AddressSet storage zapperSet = _zappersMap[IZapper(zapper).pool()]; + if (zapperSet.contains(zapper)) { + zapperSet.remove(zapper); + emit RemoveZapper(zapper); + } + } +}