diff --git a/src/Constants.sol b/src/Constants.sol index bb61368d..a208d042 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -110,3 +110,9 @@ IUniswapV3Pool constant BASE_WSTETH_WETH_UNISWAP = IUniswapV3Pool(0x20E068D76f9E IChainlink constant BASE_SEQUENCER_UPTIME_FEED = IChainlink(0xBCF85224fc0756B9Fa45aA7892530B47e10b6433); IERC20 constant BASE_WEETH = IERC20(0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A); IWETH9 constant BASE_WETH = IWETH9(0x4200000000000000000000000000000000000006); + +// Renzo +IERC20 constant BASE_EZETH = IERC20(0x2416092f143378750bb29b79eD961ab195CcEea5); +IUniswapV3Pool constant BASE_EZETH_WETH_AERODROME = IUniswapV3Pool(0xDC7EAd706795eDa3FEDa08Ad519d9452BAdF2C0d); +IChainlink constant BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK = IChainlink(0xC4300B7CF0646F0Fe4C5B2ACFCCC4dCA1346f5d8); +IChainlink constant BASE_EZETH_ETH_PRICE_CHAINLINK = IChainlink(0x960BDD1dFD20d7c98fa482D793C3dedD73A113a3); diff --git a/src/flash/lrt/base/BaseEzEthWethHandler.sol b/src/flash/lrt/base/BaseEzEthWethHandler.sol new file mode 100644 index 00000000..54e73441 --- /dev/null +++ b/src/flash/lrt/base/BaseEzEthWethHandler.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +import { IonPool } from "./../../../IonPool.sol"; +import { IonPool } from "./../../../IonPool.sol"; +import { GemJoin } from "./../../../join/GemJoin.sol"; +import { Whitelist } from "./../../../Whitelist.sol"; +import { IonHandlerBase } from "./../../IonHandlerBase.sol"; +import { IWETH9 } from "./../../../interfaces/IWETH9.sol"; +import { UniswapFlashswapHandler } from "./../../UniswapFlashswapHandler.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +/** + * @notice Handler for the ezETH collateral in the ezETH/WETH market on Base. + * + * @custom:security-contact security@molecularlabs.io + */ +contract BaseEzEthWethHandler is UniswapFlashswapHandler { + /** + * @notice Creates a new `EzEthWethHandler` instance. + * @param _ilkIndex Ilk index of the pool. + * @param _ionPool address. + * @param _gemJoin address. + * @param _whitelist address. + * @param _pool address of the ezETH/WETH Aerodrome pool. + * @param _wethIsToken0 Whether WETH is token0 or token1. + * @param _weth The WETH address of this chain. + */ + constructor( + uint8 _ilkIndex, + IonPool _ionPool, + GemJoin _gemJoin, + Whitelist _whitelist, + IUniswapV3Pool _pool, + bool _wethIsToken0, + IWETH9 _weth + ) + IonHandlerBase(_ilkIndex, _ionPool, _gemJoin, _whitelist, _weth) + UniswapFlashswapHandler(_pool, _wethIsToken0) + { } +} diff --git a/src/oracles/reserve/lrt/base/BaseEzEthWethReserveOracle.sol b/src/oracles/reserve/lrt/base/BaseEzEthWethReserveOracle.sol new file mode 100644 index 00000000..91e52967 --- /dev/null +++ b/src/oracles/reserve/lrt/base/BaseEzEthWethReserveOracle.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { WadRayMath } from "../../../../libraries/math/WadRayMath.sol"; +import { ReserveOracle } from "../../ReserveOracle.sol"; +import { BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK, BASE_SEQUENCER_UPTIME_FEED } from "../../../../Constants.sol"; +import { SafeCast } from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + +/** + * @notice Reserve Oracle for ezETH denominated in WETH. + * + * @custom:security-contact security@molecularlabs.io + */ +contract BaseEzEthWethReserveOracle is ReserveOracle { + using WadRayMath for uint256; + using SafeCast for int256; + + error SequencerDown(); + error GracePeriodNotOver(); + error MaxTimeFromLastUpdateExceeded(uint256, uint256); + + uint256 public immutable MAX_TIME_FROM_LAST_UPDATE; // seconds + uint256 public immutable GRACE_PERIOD; + + /** + * @notice Creates a new `BaseEzEthWethReserveOracle` instance. Provides + * the amount of WETH equal to one ezETH (ETH / ezETH). + * @dev The value of ezETH denominated in WETH by Chainlink. + * @param _feeds List of alternative data sources for the WETH/ezETH exchange rate. + * @param _quorum The amount of alternative data sources to aggregate. + * @param _maxChange Maximum percent change between exchange rate updates. [RAY] + */ + constructor( + uint8 _ilkIndex, + address[] memory _feeds, + uint8 _quorum, + uint256 _maxChange, + uint256 _maxTimeFromLastUpdate, + uint256 _gracePeriod + ) + ReserveOracle(_ilkIndex, _feeds, _quorum, _maxChange) + { + MAX_TIME_FROM_LAST_UPDATE = _maxTimeFromLastUpdate; + GRACE_PERIOD = _gracePeriod; + _initializeExchangeRate(); + } + + function _getProtocolExchangeRate() internal view override returns (uint256) { + ( + /*uint80 roundID*/ + , + int256 answer, + uint256 startedAt, + /*uint256 updatedAt*/ + , + /*uint80 answeredInRound*/ + ) = BASE_SEQUENCER_UPTIME_FEED.latestRoundData(); + + if (answer == 1) revert SequencerDown(); + if (block.timestamp - startedAt <= GRACE_PERIOD) revert GracePeriodNotOver(); + + (, int256 ethPerEzEth,, uint256 ethPerEzEthUpdatedAt,) = + BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + + if (block.timestamp - ethPerEzEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE) { + revert MaxTimeFromLastUpdateExceeded(block.timestamp - ethPerEzEthUpdatedAt, MAX_TIME_FROM_LAST_UPDATE); + } else { + return ethPerEzEth.toUint256(); + } + } +} diff --git a/src/oracles/spot/lrt/base/BaseEzEthWethSpotOracle.sol b/src/oracles/spot/lrt/base/BaseEzEthWethSpotOracle.sol new file mode 100644 index 00000000..012c9619 --- /dev/null +++ b/src/oracles/spot/lrt/base/BaseEzEthWethSpotOracle.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.21; + +import { SpotOracle } from "../../../../oracles/spot/SpotOracle.sol"; +import { WadRayMath } from "../../../../libraries/math/WadRayMath.sol"; +import { BASE_SEQUENCER_UPTIME_FEED, BASE_EZETH_ETH_PRICE_CHAINLINK } from "../../../../Constants.sol"; + +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +/** + * @notice The ezETH spot oracle denominated in WETH on Base. + * + * @custom:security-contact security@molecularlabs.io + */ +contract BaseEzEthWethSpotOracle is SpotOracle { + using WadRayMath for uint256; + using SafeCast for int256; + + error SequencerDown(); + error GracePeriodNotOver(); + + /** + * @notice The maximum delay for the oracle update in seconds before the + * data is considered stale. + */ + uint256 public immutable MAX_TIME_FROM_LAST_UPDATE; // seconds + + /** + * @notice Amount of time to wait after the sequencer restarts. + */ + uint256 public immutable GRACE_PERIOD; + + /** + * @notice Creates a new `WeEthWethSpotOracle` instance. + * @param _ltv The loan to value ratio for the weETH/WETH market. + * @param _reserveOracle The associated reserve oracle. + * @param _maxTimeFromLastUpdate The maximum delay for the oracle update in seconds + */ + constructor( + uint256 _ltv, + address _reserveOracle, + uint256 _maxTimeFromLastUpdate, + uint256 _gracePeriod + ) + SpotOracle(_ltv, _reserveOracle) + { + MAX_TIME_FROM_LAST_UPDATE = _maxTimeFromLastUpdate; + GRACE_PERIOD = _gracePeriod; + } + + /** + * @notice Gets the price of weETH in WETH. + * @dev Redstone oracle returns ETH per weETH with 8 decimals. This + * @return wethPerWeEth price of weETH in WETH. [WAD] + */ + function getPrice() public view override returns (uint256) { + ( + /*uint80 roundID*/ + , + int256 answer, + uint256 startedAt, + /*uint256 updatedAt*/ + , + /*uint80 answeredInRound*/ + ) = BASE_SEQUENCER_UPTIME_FEED.latestRoundData(); + + if (answer == 1) revert SequencerDown(); + if (block.timestamp - startedAt <= GRACE_PERIOD) revert GracePeriodNotOver(); + + ( + /*uint80 roundID*/ + , + int256 ethPerEzEth, + /*uint startedAt*/ + , + uint256 ethPerEzEthUpdatedAt, + /*uint80 answeredInRound*/ + ) = BASE_EZETH_ETH_PRICE_CHAINLINK.latestRoundData(); // [WAD] + + if (block.timestamp - ethPerEzEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE) { + return 0; // collateral valuation is zero if oracle data is stale + } else { + return ethPerEzEth.toUint256(); // [wad] + } + } +} diff --git a/test/fork/concrete/handlers-base/LrtUniswapFlashswapHandler.t.sol b/test/fork/concrete/handlers-base/LrtUniswapFlashswapHandler.t.sol new file mode 100644 index 00000000..3a1a615c --- /dev/null +++ b/test/fork/concrete/handlers-base/LrtUniswapFlashswapHandler.t.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { WadRayMath, RAY, WAD } from "../../../../src/libraries/math/WadRayMath.sol"; +import { UniswapFlashswapHandler } from "../../../../src/flash/UniswapFlashswapHandler.sol"; +import { IonHandlerBase } from "../../../../src/flash/IonHandlerBase.sol"; +import { Whitelist } from "../../../../src/Whitelist.sol"; + +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +import { Vm } from "forge-std/Vm.sol"; +import { console2 } from "forge-std/console2.sol"; + +using WadRayMath for uint256; + +abstract contract UniswapFlashswapHandler_Test is LrtHandler_ForkBase { + uint160 sqrtPriceLimitX96; + + function _getUniswapPools() internal pure virtual returns (address[] memory uniswapPools); + + function testFork_FlashswapLeverage() external { + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = 6e18; // In weth + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.expectRevert(abi.encodeWithSelector(IonHandlerBase.TransactionDeadlineReached.selector, block.timestamp)); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp, + borrowerWhitelistProof + ); + + if (Whitelist(whitelist).borrowersRoot(0) != 0) { + vm.expectRevert(abi.encodeWithSelector(Whitelist.NotWhitelistedBorrower.selector, 0, address(this))); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + } + + uint256 gasBefore = gasleft(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + borrowerWhitelistProof + ); + uint256 gasAfter = gasleft(); + if (vm.envOr("SHOW_GAS", uint256(0)) == 1) console2.log("Gas used: %d", gasBefore - gasAfter); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingAdditionalCollateral); + assertEq(IERC20(ionPool.getIlkAddress(_getIlkIndex())).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + } + + function testFork_FlashswapDeleveragePartial() external { + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = type(uint256).max; + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.recordLogs(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + borrowerWhitelistProof + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + uint256 normalizedDebtCreated; + for (uint256 i = 0; i < entries.length; i++) { + // keccak256("Borrow(uint8,address,address,uint256,uint256,uint256)") + if (entries[i].topics[0] != 0xe3e92e977f830d2a0b92c58e8866694b5dc929a35e2b95846f427de0f0bb412f) continue; + normalizedDebtCreated = abi.decode(entries[i].data, (uint256)); + } + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingAdditionalCollateral); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), normalizedDebtCreated); + + vm.warp(block.timestamp + 3 hours); + + uint256 slippageAndFeeTolerance = 1.01e18; // 1% + // Want to completely deleverage position and only leave initial capital + // in vault + uint256 maxCollateralToRemove = (resultingAdditionalCollateral - initialDeposit) * slippageAndFeeTolerance / WAD; + // Remove all debt + uint256 normalizedDebtToRemove = ionPool.normalizedDebt(_getIlkIndex(), address(this)); + + // Round up otherwise can leave 1 wei of dust in debt left + uint256 debtToRemove = normalizedDebtToRemove.rayMulUp(ionPool.rate(_getIlkIndex())); + + vm.expectRevert(abi.encodeWithSelector(IonHandlerBase.TransactionDeadlineReached.selector, block.timestamp)); + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp); + + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp + 1); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertGe( + ionPool.collateral(_getIlkIndex(), address(this)), resultingAdditionalCollateral - maxCollateralToRemove + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), 0); + assertEq(IERC20(ionPool.getIlkAddress(_getIlkIndex())).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + } + + function testFork_FlashswapDeleverageFull() external { + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = type(uint256).max; + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.recordLogs(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + borrowerWhitelistProof + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + uint256 normalizedDebtCreated; + for (uint256 i = 0; i < entries.length; i++) { + // keccak256("Borrow(uint8,address,address,uint256,uint256,uint256)") + if (entries[i].topics[0] != 0xe3e92e977f830d2a0b92c58e8866694b5dc929a35e2b95846f427de0f0bb412f) continue; + normalizedDebtCreated = abi.decode(entries[i].data, (uint256)); + } + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingAdditionalCollateral); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), normalizedDebtCreated); + + uint256 slippageAndFeeTolerance = 1.01e18; // 1% + // Want to completely deleverage position and only leave initial capital + // in vault + uint256 maxCollateralToRemove = (resultingAdditionalCollateral - initialDeposit) * slippageAndFeeTolerance / WAD; + + // Remove all debt + uint256 debtToRemove = type(uint256).max; + + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp + 1); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertGe( + ionPool.collateral(_getIlkIndex(), address(this)), resultingAdditionalCollateral - maxCollateralToRemove + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), 0); + assertEq(IERC20(ionPool.getIlkAddress(_getIlkIndex())).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + } + + function testFork_RevertWhen_UntrustedCallerCallsFlashswapCallback() external { + vm.skip(borrowerWhitelistProof.length > 0); + + vm.expectRevert( + abi.encodeWithSelector(UniswapFlashswapHandler.CallbackOnlyCallableByPool.selector, address(this)) + ); + _getTypedUFHandler().uniswapV3SwapCallback(1, 1, ""); + } + + function testFork_RevertWhen_TradingInZeroLiquidityRegion() external { + vm.skip(borrowerWhitelistProof.length > 0); + + vm.prank(address(_getUniswapPools()[_getIlkIndex()])); + vm.expectRevert(UniswapFlashswapHandler.InvalidZeroLiquidityRegionSwap.selector); + _getTypedUFHandler().uniswapV3SwapCallback(0, 0, ""); + } + + function testFork_RevertWhen_FlashswapLeverageCreatesMoreDebtThanUserIsWilling() external { + vm.skip(borrowerWhitelistProof.length > 0); + + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = 3e18; // In weth + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.expectRevert(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + } + + function testFork_RevertWhen_FlashswapDeleverageSellsMoreCollateralThanUserIsWilling() external { + vm.skip(borrowerWhitelistProof.length > 0); + + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = type(uint256).max; + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + + uint256 slippageAndFeeTolerance = 1.0e18; // 0% + // Want to completely deleverage position and only leave initial capital + // in vault + uint256 maxCollateralToRemove = (resultingAdditionalCollateral - initialDeposit) * slippageAndFeeTolerance / WAD; + // Remove all debt + uint256 normalizedDebtToRemove = ionPool.normalizedDebt(_getIlkIndex(), address(this)); + + // Round up otherwise can leave 1 wei of dust in debt left + uint256 debtToRemove = normalizedDebtToRemove.rayMulUp(ionPool.rate(_getIlkIndex())); + + vm.expectRevert(); + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp + 1); + } + + function _getTypedUFHandler() private view returns (UniswapFlashswapHandler) { + return UniswapFlashswapHandler(payable(_getHandler())); + } +} diff --git a/test/fork/concrete/lrt/ReserveOracle.t.sol b/test/fork/concrete/lrt/ReserveOracle.t.sol index f7aa41c3..0eca78fb 100644 --- a/test/fork/concrete/lrt/ReserveOracle.t.sol +++ b/test/fork/concrete/lrt/ReserveOracle.t.sol @@ -7,6 +7,7 @@ import { RsEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/Rs import { RswEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/RswEthWstEthReserveOracle.sol"; import { EzEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/EzEthWethReserveOracle.sol"; import { WeEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/WeEthWethReserveOracle.sol"; +import { BaseEzEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/base/BaseEzEthWethReserveOracle.sol"; import { WadRayMath } from "../../../../src/libraries/math/WadRayMath.sol"; import { UPDATE_COOLDOWN } from "../../../../src/oracles/reserve/ReserveOracle.sol"; import { @@ -20,7 +21,8 @@ import { RENZO_RESTAKE_MANAGER, BASE_WEETH_ETH_EXCHANGE_RATE_CHAINLINK, ETHER_FI_LIQUIDITY_POOL_ADDRESS, - WEETH_ADDRESS + WEETH_ADDRESS, + BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK } from "../../../../src/Constants.sol"; import { ReserveOracleSharedSetup } from "../../../helpers/ReserveOracleSharedSetup.sol"; import { StdStorage, stdStorage } from "../../../../lib/forge-safe/lib/forge-std/src/StdStorage.sol"; @@ -488,3 +490,68 @@ contract WeEthWethReserveOracle_ForkTest is ReserveOracle_ForkTest { require(newExchangeRate < prevExchangeRate, "price should decrease"); } } + +contract BaseEzEthWethReserveOracle_ForkTest is ReserveOracle_ForkTest { + using SafeCast for int256; + + error MaxTimeFromLastUpdateExceeded(uint256, uint256); + + uint256 public immutable MAX_TIME_FROM_LAST_UPDATE = 87_000; // seconds + uint256 public immutable GRACE_PERIOD = 3600; + + function setUp() public override { + super.setUp(); + reserveOracle = new BaseEzEthWethReserveOracle( + ILK_INDEX, emptyFeeds, QUORUM, MAX_CHANGE, MAX_TIME_FROM_LAST_UPDATE, GRACE_PERIOD + ); + } + + function _getForkRpc() internal override returns (string memory) { + return vm.envString("BASE_MAINNET_RPC_URL"); + } + + function _convertToEth(uint256 amt) internal view override returns (uint256) { + return amt; + } + + function _getProtocolExchangeRate() internal view override returns (uint256) { + (, int256 ethPerEzEth,, uint256 ethPerEzEthUpdatedAt,) = + BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + if (block.timestamp - ethPerEzEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE) { + revert MaxTimeFromLastUpdateExceeded(block.timestamp, ethPerEzEthUpdatedAt); + } else { + return ethPerEzEth.toUint256(); // [WAD] + } + } + + // --- Slashing Scenario --- + function _increaseExchangeRate() internal override returns (uint256 newPrice) { + // Replace the Chainlink contract that returns the exchange rate with a + // new dummy contract that returns a higher exchange rate. + (, int256 prevExchangeRate,,,) = BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + + MockChainlink chainlink = new MockChainlink(); + + vm.etch(address(BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK), address(chainlink).code); + + MockChainlink(address(BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK)).setExchangeRate(1.8e18); + + (, int256 newExchangeRate,,,) = BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + + require(newExchangeRate > prevExchangeRate, "price should increase"); + } + + function _decreaseExchangeRate() internal override returns (uint256 newPrice) { + (, int256 prevExchangeRate,,,) = BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + + MockChainlink chainlink = new MockChainlink(); + + vm.etch(address(BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK), address(chainlink).code); + + MockChainlink(address(BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK)).setExchangeRate(0.5e18); + + (, int256 newExchangeRate,,,) = BASE_EZETH_ETH_EXCHANGE_RATE_CHAINLINK.latestRoundData(); + + require(newExchangeRate < prevExchangeRate, "price should decrease"); + } +} diff --git a/test/fork/concrete/lrt/SpotOracle.t.sol b/test/fork/concrete/lrt/SpotOracle.t.sol index c28f96f4..d910f28e 100644 --- a/test/fork/concrete/lrt/SpotOracle.t.sol +++ b/test/fork/concrete/lrt/SpotOracle.t.sol @@ -16,7 +16,8 @@ import { EzEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/Ez import { EzEthWethSpotOracle } from "./../../../../src/oracles/spot/lrt/EzEthWethSpotOracle.sol"; import { WeEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/WeEthWethReserveOracle.sol"; import { WeEthWethSpotOracle } from "./../../../../src/oracles/spot/lrt/WeEthWethSpotOracle.sol"; - +import { BaseEzEthWethReserveOracle } from "./../../../../src/oracles/reserve/lrt/base/BaseEzEthWethReserveOracle.sol"; +import { BaseEzEthWethSpotOracle } from "./../../../../src/oracles/spot/lrt/base/BaseEzEthWethSpotOracle.sol"; import { WadRayMath } from "../../../../src/libraries/math/WadRayMath.sol"; import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; @@ -158,3 +159,22 @@ contract WeEthWethSpotOracle_ForkTest is SpotOracle_ForkTest { return vm.envString("BASE_MAINNET_RPC_URL"); } } + +contract BaseEzEthWethSpotOracle_ForkTest is SpotOracle_ForkTest { + uint256 constant GRACE_PERIOD = 3600; + uint256 constant MAX_TIME_FROM_LAST_UPDATE = 87_000; + uint256 constant MAX_LTV = 0.8e27; + + function setUp() public override { + super.setUp(); + reserveOracle = new BaseEzEthWethReserveOracle( + ILK_INDEX, emptyFeeds, QUORUM, DEFAULT_MAX_CHANGE, MAX_TIME_FROM_LAST_UPDATE, GRACE_PERIOD + ); + spotOracle = + new BaseEzEthWethSpotOracle(MAX_LTV, address(reserveOracle), MAX_TIME_FROM_LAST_UPDATE, GRACE_PERIOD); + } + + function _getForkRpc() internal override returns (string memory) { + return vm.envString("BASE_MAINNET_RPC_URL"); + } +} diff --git a/test/fork/concrete/lrt/base/BaseEzEthWethHandler.t.sol b/test/fork/concrete/lrt/base/BaseEzEthWethHandler.t.sol new file mode 100644 index 00000000..c8ee30c8 --- /dev/null +++ b/test/fork/concrete/lrt/base/BaseEzEthWethHandler.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +import { UniswapFlashswapHandler_Test } from "../../handlers-base/LrtUniswapFlashswapHandler.t.sol"; +import { BaseEzEthWethHandler } from "./../../../../../src/flash/lrt/base/BaseEzEthWethHandler.sol"; +import { + BASE_WETH, + BASE_EZETH, + BASE_EZETH_WETH_AERODROME, + BASE_EZETH_ETH_PRICE_CHAINLINK +} from "./../../../../../src/Constants.sol"; +import { Whitelist } from "./../../../../../src/Whitelist.sol"; +import { IProviderLibraryExposed } from "./../../../../helpers/IProviderLibraryExposed.sol"; + +import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { SafeCast } from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + +contract BaseEzEthWethHandler_ForkTest is UniswapFlashswapHandler_Test { + using SafeCast for int256; + + BaseEzEthWethHandler handler; + uint8 constant ILK_INDEX = 0; + + function setUp() public virtual override { + super.setUp(); + + handler = new BaseEzEthWethHandler( + ILK_INDEX, + ionPool, + gemJoins[ILK_INDEX], + Whitelist(whitelist), + BASE_EZETH_WETH_AERODROME, + false, // _wethIsToken0 + BASE_WETH + ); + + BASE_EZETH.approve(address(handler), type(uint256).max); + + deal(address(BASE_EZETH), address(this), INITIAL_BORROWER_COLLATERAL_BALANCE); + } + + function _getUnderlying() internal pure override returns (address) { + return address(BASE_WETH); + } + + function _getCollaterals() internal view override returns (IERC20[] memory _collaterals) { + _collaterals = new IERC20[](1); + _collaterals[0] = BASE_EZETH; + } + + // Only used for constructing IonRegistry which is now deprecated + function _getDepositContracts() internal view override returns (address[] memory) { + address[] memory _depositContracts = new address[](1); + _depositContracts[0] = address(handler); + return _depositContracts; + } + + function _getIlkIndex() internal view override returns (uint8) { + return ILK_INDEX; + } + + function _getHandler() internal view override returns (address) { + return address(handler); + } + + function _getForkRpc() internal view override returns (string memory) { + return vm.envString("BASE_MAINNET_RPC_URL"); + } + + // Should be unused + function _getProviderLibrary() internal view override returns (IProviderLibraryExposed) { + return IProviderLibraryExposed(address(0)); + } + + function _getInitialSpotPrice() internal view override returns (uint256) { + ( + /*uint80 roundID*/ + , + int256 ethPerEzEth, + /*uint startedAt*/ + , + uint256 ethPerEzEthUpdatedAt, + /*uint80 answeredInRound*/ + ) = BASE_EZETH_ETH_PRICE_CHAINLINK.latestRoundData(); // [WAD] + + return ethPerEzEth.toUint256(); + } + + // These are aerodrome pools that are just forks of UniV3. + function _getUniswapPools() internal pure override returns (address[] memory uniswapPools) { + uniswapPools = new address[](1); + uniswapPools[0] = address(BASE_EZETH_WETH_AERODROME); + } +} diff --git a/test/fork/fuzz/handlers-base/LrtUniswapFlashswapHandler.t.sol b/test/fork/fuzz/handlers-base/LrtUniswapFlashswapHandler.t.sol new file mode 100644 index 00000000..31e4063d --- /dev/null +++ b/test/fork/fuzz/handlers-base/LrtUniswapFlashswapHandler.t.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { WadRayMath, RAY, WAD } from "../../../../src/libraries/math/WadRayMath.sol"; +import { UniswapFlashswapHandler } from "../../../../src/flash/UniswapFlashswapHandler.sol"; + +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +import { Vm } from "forge-std/Vm.sol"; + +using WadRayMath for uint256; + +struct Config { + uint256 initialDepositLowerBound; +} + +abstract contract UniswapFlashswapHandler_FuzzTest is LrtHandler_ForkBase { + uint160 sqrtPriceLimitX96; + Config ufConfig; + + function testForkFuzz_FlashswapLeverage(uint256 initialDeposit, uint256 resultingCollateralMultiplier) public { + initialDeposit = bound(initialDeposit, ufConfig.initialDepositLowerBound, INITIAL_THIS_UNDERLYING_BALANCE); + uint256 resultingCollateral = initialDeposit * bound(resultingCollateralMultiplier, 1, 5); + uint256 maxResultingDebt = resultingCollateral; // in weth. This is technically subject to slippage but we will + // skip protecting for this in the test + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral); + assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + } + + function testForkFuzz_FlashswapDeleverage(uint256 initialDeposit, uint256 resultingCollateralMultiplier) public { + initialDeposit = bound(initialDeposit, ufConfig.initialDepositLowerBound, INITIAL_THIS_UNDERLYING_BALANCE); + uint256 resultingCollateral = initialDeposit * bound(resultingCollateralMultiplier, 1, 5); + uint256 maxResultingDebt = resultingCollateral; // in weth. This is technically subject to slippage but we will + // skip protecting for this in the test + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.recordLogs(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + uint256 normalizedDebtCreated; + for (uint256 i = 0; i < entries.length; i++) { + // keccak256("Borrow(uint8,address,address,uint256,uint256,uint256)") + if (entries[i].topics[0] != 0xe3e92e977f830d2a0b92c58e8866694b5dc929a35e2b95846f427de0f0bb412f) continue; + normalizedDebtCreated = abi.decode(entries[i].data, (uint256)); + } + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), normalizedDebtCreated); + + vm.warp(block.timestamp + 3 hours); + + uint256 slippageAndFeeTolerance = 1.01e18; // 1% + // Want to completely deleverage position and only leave initial capital + // in vault + uint256 maxCollateralToRemove = (resultingCollateral - initialDeposit) * slippageAndFeeTolerance / WAD; + // Remove all debt + uint256 normalizedDebtToRemove = ionPool.normalizedDebt(_getIlkIndex(), address(this)); + + // Round up otherwise can leave 1 wei of dust in debt left + uint256 debtToRemove = normalizedDebtToRemove.rayMulUp(ionPool.rate(_getIlkIndex())); + + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp + 1); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertGe(ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral - maxCollateralToRemove); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), 0); + assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + } + + function testForkFuzz_FlashswapDeleverageFull( + uint256 initialDeposit, + uint256 resultingCollateralMultiplier + ) + public + { + initialDeposit = bound(initialDeposit, ufConfig.initialDepositLowerBound, INITIAL_THIS_UNDERLYING_BALANCE); + uint256 resultingCollateral = initialDeposit * bound(resultingCollateralMultiplier, 1, 5); + uint256 maxResultingDebt = resultingCollateral; // in weth. This is technically subject to slippage but we will + // skip protecting for this in the test + + weth.approve(address(_getTypedUFHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFHandler())); + + vm.recordLogs(); + _getTypedUFHandler().flashswapLeverage( + initialDeposit, + resultingCollateral, + maxResultingDebt, + sqrtPriceLimitX96, + block.timestamp + 1, + new bytes32[](0) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + uint256 normalizedDebtCreated; + for (uint256 i = 0; i < entries.length; i++) { + // keccak256("Borrow(uint8,address,address,uint256,uint256,uint256)") + if (entries[i].topics[0] != 0xe3e92e977f830d2a0b92c58e8866694b5dc929a35e2b95846f427de0f0bb412f) continue; + normalizedDebtCreated = abi.decode(entries[i].data, (uint256)); + } + + assertEq(ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral); + assertLt( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + ); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), normalizedDebtCreated); + + uint256 slippageAndFeeTolerance = 1.01e18; // 1% + // Want to completely deleverage position and only leave initial capital + // in vault + uint256 maxCollateralToRemove = (resultingCollateral - initialDeposit) * slippageAndFeeTolerance / WAD; + uint256 normalizedDebtCurrent = ionPool.normalizedDebt(_getIlkIndex(), address(this)); + + // Remove all debt if any + uint256 debtToRemove = normalizedDebtCurrent == 0 ? 0 : type(uint256).max; + + _getTypedUFHandler().flashswapDeleverage(maxCollateralToRemove, debtToRemove, 0, block.timestamp + 1); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + + assertGe(ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral - maxCollateralToRemove); + assertEq(ionPool.normalizedDebt(_getIlkIndex(), address(this)), 0); + assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFHandler())), 0); + assertLe(weth.balanceOf(address(_getTypedUFHandler())), roundingError); + } + + function _getTypedUFHandler() private view returns (UniswapFlashswapHandler) { + return UniswapFlashswapHandler(payable(_getHandler())); + } +} + +abstract contract UniswapFlashswapHandler_WithRateChange_FuzzTest is UniswapFlashswapHandler_FuzzTest { + function testForkFuzz_WithRateChange_FlashswapLeverage( + uint256 initialDeposit, + uint256 resultingCollateralMultiplier, + uint104 rate + ) + external + { + rate = uint104(bound(rate, 1e27, 10e27)); + ionPool.setRate(_getIlkIndex(), rate); + super.testForkFuzz_FlashswapLeverage(initialDeposit, resultingCollateralMultiplier); + } + + function testForkFuzz_WithRateChange_FlashswapDeleverage( + uint256 initialDeposit, + uint256 resultingCollateralMultiplier, + uint104 rate + ) + external + { + rate = uint104(bound(rate, 1e27, 10e27)); + ionPool.setRate(_getIlkIndex(), rate); + super.testForkFuzz_FlashswapDeleverage(initialDeposit, resultingCollateralMultiplier); + } + + function testForkFuzz_WithRateChange_FlashswapDeleverageFull( + uint256 initialDeposit, + uint256 resultingCollateralMultiplier, + uint104 rate + ) + external + { + rate = uint104(bound(rate, 1e27, 10e27)); + ionPool.setRate(_getIlkIndex(), rate); + super.testForkFuzz_FlashswapDeleverageFull(initialDeposit, resultingCollateralMultiplier); + } +} diff --git a/test/fork/fuzz/lrt/base/BaseEzEthWethHandler.t.sol b/test/fork/fuzz/lrt/base/BaseEzEthWethHandler.t.sol new file mode 100644 index 00000000..ff27ba90 --- /dev/null +++ b/test/fork/fuzz/lrt/base/BaseEzEthWethHandler.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +import { UniswapFlashswapHandler_WithRateChange_FuzzTest } from "../../handlers-base/LrtUniswapFlashswapHandler.t.sol"; +import { BaseEzEthWethHandler } from "./../../../../../src/flash/lrt/base/BaseEzEthWethHandler.sol"; +import { + BASE_WETH, + BASE_EZETH, + BASE_EZETH_WETH_AERODROME, + BASE_EZETH_ETH_PRICE_CHAINLINK +} from "./../../../../../src/Constants.sol"; +import { Whitelist } from "./../../../../../src/Whitelist.sol"; +import { IProviderLibraryExposed } from "./../../../../helpers/IProviderLibraryExposed.sol"; + +import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { SafeCast } from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + +contract BaseEzEthWethHandler_ForkFuzzTest is UniswapFlashswapHandler_WithRateChange_FuzzTest { + using SafeCast for int256; + + BaseEzEthWethHandler handler; + uint8 constant ILK_INDEX = 0; + + function setUp() public virtual override { + super.setUp(); + + ufConfig.initialDepositLowerBound = 1 ether; + + handler = new BaseEzEthWethHandler( + ILK_INDEX, + ionPool, + gemJoins[ILK_INDEX], + Whitelist(whitelist), + BASE_EZETH_WETH_AERODROME, + false, // _wethIsToken0 + BASE_WETH + ); + + BASE_EZETH.approve(address(handler), type(uint256).max); + + deal(address(BASE_EZETH), address(this), INITIAL_BORROWER_COLLATERAL_BALANCE); + } + + function _getUnderlying() internal pure override returns (address) { + return address(BASE_WETH); + } + + function _getCollaterals() internal view override returns (IERC20[] memory _collaterals) { + _collaterals = new IERC20[](1); + _collaterals[0] = BASE_EZETH; + } + + // Only used for constructing IonRegistry which is now deprecated + function _getDepositContracts() internal view override returns (address[] memory) { + address[] memory _depositContracts = new address[](1); + _depositContracts[0] = address(handler); + return _depositContracts; + } + + function _getIlkIndex() internal view override returns (uint8) { + return ILK_INDEX; + } + + function _getHandler() internal view override returns (address) { + return address(handler); + } + + function _getForkRpc() internal view override returns (string memory) { + return vm.envString("BASE_MAINNET_RPC_URL"); + } + + // Should be unused + function _getProviderLibrary() internal view override returns (IProviderLibraryExposed) { + return IProviderLibraryExposed(address(0)); + } + + function _getInitialSpotPrice() internal view override returns (uint256) { + ( + /*uint80 roundID*/ + , + int256 ethPerEzEth, + /*uint startedAt*/ + , + uint256 ethPerEzEthUpdatedAt, + /*uint80 answeredInRound*/ + ) = BASE_EZETH_ETH_PRICE_CHAINLINK.latestRoundData(); // [WAD] + + return ethPerEzEth.toUint256(); + } +}