From 6cfc1a8a23a5b11b8f58fcafd91601810c51c789 Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Sun, 10 Mar 2024 23:52:20 -0400 Subject: [PATCH 01/13] feat: RenzoLibrary --- src/Constants.sol | 8 +- src/interfaces/ProviderInterfaces.sol | 55 ++++++ src/libraries/lrt/KelpDaoLibrary.sol | 23 ++- src/libraries/lrt/RenzoLibrary.sol | 246 ++++++++++++++++++++++++++ test/fork/fuzz/lrt/RenzoLibrary.t.sol | 57 ++++++ 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 src/libraries/lrt/RenzoLibrary.sol create mode 100644 test/fork/fuzz/lrt/RenzoLibrary.t.sol diff --git a/src/Constants.sol b/src/Constants.sol index b6b818f7..48b6bbf3 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -15,7 +15,10 @@ import { ILRTOracle, ILRTConfig, IEtherFiLiquidityPool, - ILRTDepositPool + ILRTDepositPool, + IEzEth, + IRenzoOracle, + IRestakeManager } from "./interfaces/ProviderInterfaces.sol"; import { IRedstonePriceFeed } from "./interfaces/IRedstone.sol"; import { IChainlink } from "./interfaces/IChainlink.sol"; @@ -64,6 +67,9 @@ IRswEth constant RSWETH = IRswEth(0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0); // ezETH IRedstonePriceFeed constant REDSTONE_EZETH_ETH_PRICE_FEED = IRedstonePriceFeed(0xF4a3e183F59D2599ee3DF213ff78b1B3b1923696); +IEzEth constant EZETH = IEzEth(0xbf5495Efe5DB9ce00f80364C8B423567e58d2110); +IRenzoOracle constant RENZO_ORACLE = IRenzoOracle(0x5a12796f7e7EBbbc8a402667d266d2e65A814042); +IRestakeManager constant RENZO_RESTAKE_MANAGER = IRestakeManager(0x74a09653A083691711cF8215a6ab074BB4e99ef5); // Chainlink IChainlink constant ETH_PER_STETH_CHAINLINK = IChainlink(0x86392dC19c0b719886221c78AB11eb8Cf5c52812); diff --git a/src/interfaces/ProviderInterfaces.sol b/src/interfaces/ProviderInterfaces.sol index 97a2b78d..a8959d9f 100644 --- a/src/interfaces/ProviderInterfaces.sol +++ b/src/interfaces/ProviderInterfaces.sol @@ -159,3 +159,58 @@ interface ILRTConfig { function depositLimitByAsset(address asset) external view returns (uint256); } + +// Renzo + +interface IEzEth is IERC20 { } + +interface IRenzoOracle { + function lookupTokenValue(IERC20 _token, uint256 _balance) external view returns (uint256); + function lookupTokenAmountFromValue(IERC20 _token, uint256 _value) external view returns (uint256); + function lookupTokenValues(IERC20[] memory _tokens, uint256[] memory _balances) external view returns (uint256); + function calculateMintAmount( + uint256 _currentValueInProtocol, + uint256 _newValueAdded, + uint256 _existingEzETHSupply + ) + external + pure + returns (uint256); + function calculateRedeemAmount( + uint256 _ezETHBeingBurned, + uint256 _existingEzETHSupply, + uint256 _currentValueInProtocol + ) + external + pure + returns (uint256); +} + +interface IOperatorDelegator { + function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256); + + function deposit(IERC20 _token, uint256 _tokenAmount) external returns (uint256 shares); + + function startWithdrawal(IERC20 _token, uint256 _tokenAmount) external returns (bytes32); + + function getStakedETHBalance() external view returns (uint256); + + function stakeEth(bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot) external payable; + + function pendingUnstakedDelayedWithdrawalAmount() external view returns (uint256); +} + +interface IRestakeManager { + function stakeEthInOperatorDelegator( + IOperatorDelegator operatorDelegator, + bytes calldata pubkey, + bytes calldata signature, + bytes32 depositDataRoot + ) + external + payable; + function depositTokenRewardsFromProtocol(IERC20 _token, uint256 _amount) external; + + function calculateTVLs() external view returns (uint256[][] memory, uint256[] memory, uint256); + function depositETH(uint256 _referralId) external payable; +} diff --git a/src/libraries/lrt/KelpDaoLibrary.sol b/src/libraries/lrt/KelpDaoLibrary.sol index ce9cca73..7e0c08a0 100644 --- a/src/libraries/lrt/KelpDaoLibrary.sol +++ b/src/libraries/lrt/KelpDaoLibrary.sol @@ -10,17 +10,33 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; using WadRayMath for uint256; using Math for uint256; +/** + * @title KelpDaoLibrary + * + * @notice A helper library for KelpDao-related conversions. + */ library KelpDaoLibrary { /** * @notice Deposits a given amount of ETH into the rsETH Deposit Pool. + * @dev Care should be taken to handle slippage in the calling function + * since this function sets NO slippage controls. + * * @param ethAmount Amount of ETH to deposit. [WAD] * @return rsEthAmountToMint Amount of rsETH that was obtained. [WAD] */ function depositForLrt(IRsEth, uint256 ethAmount) internal returns (uint256 rsEthAmountToMint) { rsEthAmountToMint = RSETH_LRT_DEPOSIT_POOL.getRsETHAmountToMint(ETH_ADDRESS, ethAmount); - RSETH_LRT_DEPOSIT_POOL.depositETH{ value: ethAmount }(0, ""); // TODO: slippage tolerance on mint + + // Intentionally skip the slippage check and allow it to be handled by + // function caller. This function is meant to be used in the handler + // which has its own slippage check through `maxResultingDebt`. + RSETH_LRT_DEPOSIT_POOL.depositETH{ value: ethAmount }(0, ""); } + /** + * @notice Returns the amount of ETH required to mint a given amount of rsETH. + * @param amountOut Desired output amount of rsETH + */ function getEthAmountInForLstAmountOut(IRsEth, uint256 amountOut) internal view returns (uint256) { // getRsEthAmountToMint // rsEthAmountToMint = floor(amount * assetPrice / rsETHPrice) @@ -31,6 +47,11 @@ library KelpDaoLibrary { return amountOut.mulDiv(RSETH_LRT_ORACLE.rsETHPrice(), WAD, Math.Rounding.Ceil); } + /** + * @notice Calculates the amount of rsETH that will be minted for a given amount of ETH. + * @param ethAmount Amount of ETH to use to mint rsETH + * @return Amount of outputted rsETH for a given amount of ETH + */ function getLstAmountOutForEthAmountIn(IRsEth, uint256 ethAmount) internal view returns (uint256) { return RSETH_LRT_DEPOSIT_POOL.getRsETHAmountToMint(ETH_ADDRESS, ethAmount); } diff --git a/src/libraries/lrt/RenzoLibrary.sol b/src/libraries/lrt/RenzoLibrary.sol new file mode 100644 index 00000000..7e1104b1 --- /dev/null +++ b/src/libraries/lrt/RenzoLibrary.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { RENZO_ORACLE, RENZO_RESTAKE_MANAGER, EZETH } from "../../Constants.sol"; +import { IEzEth } from "../../interfaces/ProviderInterfaces.sol"; +import { WadRayMath, WAD, RAY } from "../math/WadRayMath.sol"; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { safeconsole as console } from "forge-std/safeconsole.sol"; + +using Math for uint256; +using WadRayMath for uint256; + +/** + * @title RenzoLibrary + * + * @notice A helper library for Renzo-related conversions. + * + * @dev The behaviour in minting ezETH is quite strange, so for the sake of the + * maintanence of this code, we document the behaviour at block 19387902. + * + * The following function is invoked to calculate the amount of ezETH to mint + * given an `ethAmount` to deposit: + * `calculateMintAmount(totalTVL, ethAmount, totalSupply)`. + * + * ```solidity + * function calculateMintAmount(uint256 _currentValueInProtocol, uint256 _newValueAdded, uint256 _existingEzETHSupply) + * external pure returns (uint256) { + * + * ... + * + * // Calculate the percentage of value after the deposit + * uint256 inflationPercentaage = SCALE_FACTOR * _newValueAdded / (_currentValueInProtocol + _newValueAdded); + * + * // Calculate the new supply + * uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - inflationPercentaage); + * + * // Subtract the old supply from the new supply to get the amount to mint + * uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; + * + * if(mintAmount == 0) revert InvalidTokenAmount(); + * ... + * } + * ``` + * + * The first thing to note here is the increments by which you can mint ezETH. + * To mint a non-zero amount of ezETH, `newEzETHSupply` must not be equal to + * `_existingEzETHSupply`. For this to happen, `inflationPercentage` must be + * non-zero. + * + * At block 19387902, the `totalTVL` or (`_currentValueInProtocol`) is + * `227527390751192406096375`. So the smallest value for `_newValueAdded` (or + * the ETH deposited) that will produce an `inflationPercentage` of 1 is + * `227528`. Any deposit amount less than this will not mint any ezETH (in fact, + * it will revert). This is the first piece of strange behaviour; the minimum + * amount to deposit to mint any ezETH is `227528` wei. This will mint `226219` + * ezETH. + * + * The second piece of strange behaviour can be noted when increasing the + * deposit. If it is increased from `227528` to `227529`, the amount of ezETH + * minted REMAINS `226219`. This is the case all the way until `455054`. This + * means that if a user deposits anywhere between `226219` and `455054` wei, + * they will mint `226219` ezETH. This is because the `inflationPercentage` + * remains at 1. At `455055` wei, the `inflationPercentage` finally increases to + * 2, and the amount of ezETH minted increases to `452438`. This also means that + * it is impossible to mint an ezETH value between `226219` and `452438` wei + * even though the transfer granularity remains 1 wei. + * + * One side effect of this second behaviour is the cost of acquisition can be + * optimized. It's a really small difference but to acquire `226219` ezETH a + * user should pay `227528` wei instead of any other value between `227528` and + * `455054` wei. + * + * We will call a mintable amount of ezETH a "mint amount" (recall that at block + * 19387902, a user cannot mint between `226219` and `452438` ezETH). So + * `226219` and `452438` are mint amounts. + * + * We will call the range of values that produce the same amount of ezETH a + * "mint range". The mint range for `0` ezETH is `0` to `227527` wei and the mint + * range for `226219` ezETH is `227528` to `455054` wei. + */ +library RenzoLibrary { + error InvalidAmountOut(uint256 trueAmountOut, uint256 expectedAmountOut); + + /** + * @notice Returns the amount of ETH required to mint at least + * `minAmountOut` ezETH and the actual amount of ezETH minted when + * depositing that amount of ETH. + * + * @dev The goal here is to mint at least `minAmountOut` ezETH. So first, we + * must find the "mint amount" right above `minAmountOut`. This ensures that + * we mint at least `minAmountOut`. Then we find the minimum amount of ETH + * required to mint that "mint amount". Essentialy, we want to find the + * lower bound of the "mint range" of the "mint amount" right above + * `minAmountOut`. + * + * @param minAmountOut Minimum amount of ezETH to mint + * @return ethAmountIn Amount of ETH required to mint the desired amount of + * ezETH + * @return amountOut Actual output amount of ezETH + */ + function getEthAmountInForLstAmountOut(uint256 minAmountOut) + internal + view + returns (uint256 ethAmountIn, uint256 amountOut) + { + if (minAmountOut == 0) return (0, 0); + + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + + // Each `inflationPercentage` maps to a "mint amount" and, therefore, a + // "mint range". We need to first calculate the `inflationPercentage` + // that maps to the "mint range" of the `minAmountOut`. (Technically, + // the `minAmountOut` is unlikely to have its own "mint range" since it + // probably won't be a "mint amount," but we can find the mint range of + // the "mint amount" below `minAmountOut".) + uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1); + if (ethAmount == 0) return (0, 0); + uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); + + // Once we have the `inflationPercentage` mapping to the "mint amount" + // below `minAmountOut`, we increment it to find the + // `inflationPercentage` mapping to the "mint amount" above + // `minAmountOut". + ++inflationPercentage; + + // Then we go on to calculate the ezETH amount and optimal eth deposit + // mapping to that `inflationPercentage`. + uint256 totalSupply = EZETH.totalSupply(); + + // Calculate the new supply + uint256 newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); + + amountOut = newEzETHSupply - totalSupply; + + ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); + } + + /** + * @notice Returns the amount of ezETH that will be minted with the provided + * `ethAmount` and the optimal amount of ETH to acquire the same amount of + * ezETH. + * + * @param ethAmount amount of eth to use to mint + * @return amount of ezETH minted + * @return optimalAmount optimal amount of ETH required to mint (at the bottom + * of the mint range) + */ + function getLstAmountOutForEthAmountIn(uint256 ethAmount) + internal + view + returns (uint256 amount, uint256 optimalAmount) + { + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + + amount = _calculateMintAmount(totalTVL, ethAmount, EZETH.totalSupply()); + optimalAmount = _calculateDepositAmount(totalTVL, amount); + } + + function depositForLrt(uint256 ethAmount) internal returns (uint256 ezEthAmountToMint) { + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + + ezEthAmountToMint = _calculateMintAmount(totalTVL, ethAmount, EZETH.totalSupply()); + RENZO_RESTAKE_MANAGER.depositETH{ value: ethAmount }(0); + } + + /** + * @notice Returns the amount of ETH required to mint amountOut ezETH. + * + * @dev This function does NOT account for the rounding errors in the ezETH. + * It simply performs the minting calculation in reverse. To use this + * function properly, `amountOut` should be a "mint amount" (an amount of + * ezETH that is actually possible to mint). + * + * @param totalTVL Total TVL in the system. + * @param amountOut Desired amount of ezETH to mint. + */ + function _calculateDepositAmount(uint256 totalTVL, uint256 amountOut) private view returns (uint256) { + if (amountOut == 0) return 0; + + // uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; + // + // Solve for newEzETHSupply + uint256 newEzEthSupply = (amountOut + EZETH.totalSupply()); + uint256 newEzEthSupplyRay = newEzEthSupply.scaleUpToRay(18); + + // uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - + // inflationPercentage); + // + // Solve for inflationPercentage + uint256 intem = EZETH.totalSupply().scaleUpToRay(18).mulDiv(RAY, newEzEthSupplyRay); + uint256 inflationPercentage = RAY - intem; + + // uint256 inflationPercentage = SCALE_FACTOR * _newValueAdded / (_currentValueInProtocol + + // _newValueAdded); + // + // Solve for _newValueAdded + uint256 ethAmountRay = inflationPercentage.mulDiv(totalTVL.scaleUpToRay(18), RAY - inflationPercentage); + + // Truncate from RAY to WAD with roundingUp plus one extra + // The one extra to get into the next mint range + uint256 ethAmount = ethAmountRay / 1e9 + 1; + if (ethAmountRay % 1e9 != 0) ++ethAmount; + + return ethAmount; + } + + /** + * @notice Calculates the amount of ezETH that will be minted. + * + * @dev This function emulates the calculations in the Renzo contract + * (including rounding errors). + * + * @param _currentValueInProtocol The TVL in the protocol (in ETH terms). + * @param _newValueAdded The amount of ETH to deposit. + * @param _existingEzETHSupply The current supply of ezETH. + * @return The amount of ezETH that will be minted. + */ + function _calculateMintAmount( + uint256 _currentValueInProtocol, + uint256 _newValueAdded, + uint256 _existingEzETHSupply + ) + private + pure + returns (uint256) + { + // For first mint, just return the new value added. + // Checking both current value and existing supply to guard against gaming the initial mint + if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) { + return _newValueAdded; // value is priced in base units, so divide by scale factor + } + + // Calculate the percentage of value after the deposit + uint256 inflationPercentage = WAD * _newValueAdded / (_currentValueInProtocol + _newValueAdded); + + // Calculate the new supply + uint256 newEzETHSupply = (_existingEzETHSupply * WAD) / (WAD - inflationPercentage); + + // Subtract the old supply from the new supply to get the amount to mint + uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; + + return mintAmount; + } +} diff --git a/test/fork/fuzz/lrt/RenzoLibrary.t.sol b/test/fork/fuzz/lrt/RenzoLibrary.t.sol new file mode 100644 index 00000000..815c8fb7 --- /dev/null +++ b/test/fork/fuzz/lrt/RenzoLibrary.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.21; + +import { RenzoLibrary } from "../../../../src/libraries/lrt/RenzoLibrary.sol"; +import { EZETH, RENZO_RESTAKE_MANAGER } from "../../../../src/Constants.sol"; + +import { Test } from "forge-std/Test.sol"; + +import { safeconsole as console } from "forge-std/safeconsole.sol"; + +uint256 constant SCALE_FACTOR = 1e18; + +contract RenzoLibrary_FuzzTest is Test { + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_387_902); + // vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); + } + + function testForkFuzz_GetEthAmountInForLstAmountOut(uint128 minLrtAmount) external { + (uint256 ethAmount, uint256 actualLrtAmount) = RenzoLibrary.getEthAmountInForLstAmountOut(minLrtAmount); + assertGe(actualLrtAmount, minLrtAmount, "actualLrtAmount"); + + uint256 mintAmount = _calculateMintAmount(ethAmount); + + vm.assume(mintAmount != 0); + + vm.deal(address(this), ethAmount); + RenzoLibrary.depositForLrt(ethAmount); + assertEq(EZETH.balanceOf(address(this)), actualLrtAmount, "ezETH balance"); + } + + function testForkFuzz_GetLstAmountOutForEthAmountIn(uint128 ethAmount) external { + uint256 mintAmount = _calculateMintAmount(ethAmount); + + // Prevent revert + vm.assume(mintAmount != 0); + + (uint256 lrtAmountOut, uint256 minEthIn) = RenzoLibrary.getLstAmountOutForEthAmountIn(ethAmount); + + assertEq(lrtAmountOut, mintAmount, "ethAmount"); + + (lrtAmountOut,) = RenzoLibrary.getLstAmountOutForEthAmountIn(minEthIn); + + assertEq(lrtAmountOut, mintAmount, "minEthIn"); + + vm.deal(address(this), ethAmount); + RenzoLibrary.depositForLrt(ethAmount); + assertEq(EZETH.balanceOf(address(this)), lrtAmountOut); + } + + function _calculateMintAmount(uint256 ethAmount) internal view returns (uint256 mintAmount) { + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 inflationPercentage = SCALE_FACTOR * ethAmount / (totalTVL + ethAmount); + uint256 newEzETHSupply = (EZETH.totalSupply() * SCALE_FACTOR) / (SCALE_FACTOR - inflationPercentage); + mintAmount = newEzETHSupply - EZETH.totalSupply(); + } +} From 5798ab2c56f9fdd898cc5448cab2ea30df79b116 Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Mon, 11 Mar 2024 12:22:13 -0400 Subject: [PATCH 02/13] feat: EzEth oracles (untested) --- .../reserve/lrt/EzEthWstEthReserveOracle.sol | 42 +++++++++++++++++++ .../spot/lrt/EzEthWstEthSpotOracle.sol | 33 +++++++++++++++ .../spot/lrt/RsEthWstEthSpotOracle.sol | 2 +- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol create mode 100644 src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol diff --git a/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol b/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol new file mode 100644 index 00000000..75ee84fd --- /dev/null +++ b/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { WadRayMath, WAD } from "../../../libraries/math/WadRayMath.sol"; +import { ReserveOracle } from "../ReserveOracle.sol"; +import { RENZO_RESTAKE_MANAGER, EZETH, WSTETH_ADDRESS } from "../../../Constants.sol"; + +/** + * @notice Reserve Oracle for ezETH + * + * @custom:security-contact security@molecularlabs.io + */ +contract EzEthWstEthReserveOracle is ReserveOracle { + using WadRayMath for uint256; + + /** + * @notice Creates a new `ezEthWstEthReserveOracle` instance. Provides + * the amount of wstETH equal to one ezETH. + * wstETH / ezETH = ETH / ezETH * wstETH / ETH. + * @dev The value of ezETH denominated in wstETH by the provider. + * @param _feeds List of alternative data sources for the 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 + ) + ReserveOracle(_ilkIndex, _feeds, _quorum, _maxChange) + { + _initializeExchangeRate(); + } + + function _getProtocolExchangeRate() internal view override returns (uint256) { + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 totalSupply = EZETH.totalSupply(); + uint256 exchangeRateInEth = totalTVL.wadDivDown(totalSupply); + return exchangeRateInEth.wadMulDown(WSTETH_ADDRESS.tokensPerStEth()); + } +} diff --git a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol new file mode 100644 index 00000000..0a6dd93c --- /dev/null +++ b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { SpotOracle } from "../SpotOracle.sol"; +import { WadRayMath } from "../../../libraries/math/WadRayMath.sol"; + +/** + * @notice The ezETH spot oracle denominated in wstETH + * + * @custom:security-contact security@molecularlabs.io + */ +contract EzEthWstEthSpotOracle is SpotOracle { + using WadRayMath for uint256; + + uint256 public immutable MAX_TIME_FROM_LAST_UPDATE; // seconds + + /** + * @notice Creates a new `EzEthWstEthSpotOracle` instance. + * @param _ltv The loan to value ratio for ezETH <> wstETH + * @param _reserveOracle The associated reserve oracle. + */ + constructor( + uint256 _ltv, + address _reserveOracle, + uint256 _maxTimeFromLastUpdate + ) + SpotOracle(_ltv, _reserveOracle) + { + MAX_TIME_FROM_LAST_UPDATE = _maxTimeFromLastUpdate; + } + + function getPrice() public view override returns (uint256) { } +} diff --git a/src/oracles/spot/lrt/RsEthWstEthSpotOracle.sol b/src/oracles/spot/lrt/RsEthWstEthSpotOracle.sol index a7bf6913..eae3f62e 100644 --- a/src/oracles/spot/lrt/RsEthWstEthSpotOracle.sol +++ b/src/oracles/spot/lrt/RsEthWstEthSpotOracle.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; -import { SpotOracle } from "../../../oracles/spot/SpotOracle.sol"; +import { SpotOracle } from "../SpotOracle.sol"; import { WadRayMath } from "../../../libraries/math/WadRayMath.sol"; import { WSTETH_ADDRESS, From cd1f2a9bb7dd24502eef021f3986374e8cf0a560 Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Mon, 11 Mar 2024 12:35:37 -0400 Subject: [PATCH 03/13] test: TestFlashLeverage for rsETH --- test/live/TestFlashLeverage.t.sol | 46 ++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/test/live/TestFlashLeverage.t.sol b/test/live/TestFlashLeverage.t.sol index 6bbabaf3..e4dbe904 100644 --- a/test/live/TestFlashLeverage.t.sol +++ b/test/live/TestFlashLeverage.t.sol @@ -7,8 +7,10 @@ import { RswEthHandler } from "../../src/flash/lrt/RswEthHandler.sol"; import { LidoLibrary } from "../../src/libraries/lst/LidoLibrary.sol"; import { RestakedSwellLibrary } from "../../src/libraries/lrt/RestakedSwellLibrary.sol"; import { EtherFiLibrary } from "../../src/libraries/lrt/EtherFiLibrary.sol"; +import { KelpDaoLibrary } from "../../src/libraries/lrt/KelpDaoLibrary.sol"; import { WeEthHandler } from "../../src/flash/lrt/WeEthHandler.sol"; -import { IWstEth, IWeEth, IRswEth } from "../../src/interfaces/ProviderInterfaces.sol"; +import { RsEthHandler } from "../../src/flash/lrt/RsEthHandler.sol"; +import { IWstEth, IWeEth, IRsEth, IRswEth } from "../../src/interfaces/ProviderInterfaces.sol"; import { Whitelist } from "../../src/Whitelist.sol"; import { Test } from "forge-std/Test.sol"; @@ -16,6 +18,7 @@ import { Test } from "forge-std/Test.sol"; using LidoLibrary for IWstEth; using RestakedSwellLibrary for IRswEth; using EtherFiLibrary for IWeEth; +using KelpDaoLibrary for IRsEth; contract TestFlashLeverage is Test { IonPool weEthPool; @@ -23,6 +26,10 @@ contract TestFlashLeverage is Test { IonPool rswEthPool; RswEthHandler rswEthHandler; + + IonPool rsEthPool; + RsEthHandler rsEthHandler; + Whitelist whitelist; uint256 initialDeposit = 4 ether; // in collateral terms @@ -30,6 +37,7 @@ contract TestFlashLeverage is Test { uint256 maxResultingDebt = 15 ether; function setUp() public { + vm.pauseGasMetering(); vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); weEthPool = IonPool(0x0000000000eaEbd95dAfcA37A39fd09745739b78); weEthHandler = WeEthHandler(payable(0xAB3c6236327FF77159B37f18EF85e8AC58034479)); @@ -37,6 +45,9 @@ contract TestFlashLeverage is Test { rswEthPool = IonPool(0x00000000007C8105548f9d0eE081987378a6bE93); rswEthHandler = RswEthHandler(payable(0x5039eEe75BA0cC3Ca41c654864303951798ff0D4)); + rsEthPool = IonPool(0x0000000000E33e35EE6052fae87bfcFac61b1da9); + rsEthHandler = RsEthHandler(payable(0x335FBFf118829Aa5ef0ac91196C164538A21a45A)); + whitelist = Whitelist(0x7E317f99aA313669AaCDd8dB3927ff3aCB562dAD); vm.startPrank(whitelist.owner()); @@ -60,6 +71,10 @@ contract TestFlashLeverage is Test { WSTETH_ADDRESS.depositForLst(500 ether); WSTETH_ADDRESS.approve(address(rswEthPool), type(uint256).max); rswEthPool.supply(address(this), WSTETH_ADDRESS.balanceOf(address(this)), new bytes32[](0)); + + _setupPool(weEthPool); + _setupPool(rsEthPool); + _setupPool(rswEthPool); } function testWeEthHandler() public { @@ -69,6 +84,7 @@ contract TestFlashLeverage is Test { EETH_ADDRESS.approve(address(WEETH_ADDRESS), type(uint256).max); WEETH_ADDRESS.depositForLrt(initialDeposit * 2); + vm.resumeGasMetering(); weEthHandler.flashswapAndMint( initialDeposit, resultingAdditionalCollateral, @@ -84,6 +100,7 @@ contract TestFlashLeverage is Test { RSWETH.approve(address(rswEthHandler), type(uint256).max); RSWETH.depositForLrt(initialDeposit * 2); + vm.resumeGasMetering(); rswEthHandler.flashswapAndMint( initialDeposit, resultingAdditionalCollateral, @@ -92,4 +109,31 @@ contract TestFlashLeverage is Test { new bytes32[](0) ); } + + function testRsEthHandler() public { + rsEthPool.addOperator(address(rsEthHandler)); + + RSETH.approve(address(rsEthHandler), type(uint256).max); + EETH_ADDRESS.approve(address(RSETH), type(uint256).max); + RSETH.depositForLrt(initialDeposit * 2); + + vm.resumeGasMetering(); + rsEthHandler.flashswapAndMint( + initialDeposit, + resultingAdditionalCollateral, + maxResultingDebt, + block.timestamp + 1_000_000_000_000, + new bytes32[](0) + ); + } + + function _setupPool(IonPool pool) internal { + vm.prank(pool.owner()); + pool.updateSupplyCap(1_000_000 ether); + vm.prank(pool.owner()); + pool.updateIlkDebtCeiling(0, 1_000_000e45); + WSTETH_ADDRESS.depositForLst(500 ether); + WSTETH_ADDRESS.approve(address(pool), type(uint256).max); + pool.supply(address(this), WSTETH_ADDRESS.balanceOf(address(this)), new bytes32[](0)); + } } From beaf50879cce2316a54a7cdcf10de85d03c815a4 Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Mon, 11 Mar 2024 12:36:24 -0400 Subject: [PATCH 04/13] docs: explain minAmountOut - 1 in RenzoLibrary --- src/libraries/lrt/RenzoLibrary.sol | 25 ++++++++++++++++++------- test/fork/fuzz/lrt/RenzoLibrary.t.sol | 4 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/libraries/lrt/RenzoLibrary.sol b/src/libraries/lrt/RenzoLibrary.sol index 7e1104b1..8f116eb6 100644 --- a/src/libraries/lrt/RenzoLibrary.sol +++ b/src/libraries/lrt/RenzoLibrary.sol @@ -1,14 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; -import { RENZO_ORACLE, RENZO_RESTAKE_MANAGER, EZETH } from "../../Constants.sol"; -import { IEzEth } from "../../interfaces/ProviderInterfaces.sol"; +import { RENZO_RESTAKE_MANAGER, EZETH } from "../../Constants.sol"; import { WadRayMath, WAD, RAY } from "../math/WadRayMath.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -import { safeconsole as console } from "forge-std/safeconsole.sol"; - using Math for uint256; using WadRayMath for uint256; @@ -18,7 +15,7 @@ using WadRayMath for uint256; * @notice A helper library for Renzo-related conversions. * * @dev The behaviour in minting ezETH is quite strange, so for the sake of the - * maintanence of this code, we document the behaviour at block 19387902. + * maintenence of this code, we document the behaviour at block 19387902. * * The following function is invoked to calculate the amount of ezETH to mint * given an `ethAmount` to deposit: @@ -91,7 +88,7 @@ library RenzoLibrary { * @dev The goal here is to mint at least `minAmountOut` ezETH. So first, we * must find the "mint amount" right above `minAmountOut`. This ensures that * we mint at least `minAmountOut`. Then we find the minimum amount of ETH - * required to mint that "mint amount". Essentialy, we want to find the + * required to mint that "mint amount". Essentially, we want to find the * lower bound of the "mint range" of the "mint amount" right above * `minAmountOut`. * @@ -114,7 +111,21 @@ library RenzoLibrary { // that maps to the "mint range" of the `minAmountOut`. (Technically, // the `minAmountOut` is unlikely to have its own "mint range" since it // probably won't be a "mint amount," but we can find the mint range of - // the "mint amount" below `minAmountOut".) + // the "mint amount" below `minAmountOut`".) + // + // To understand the reason for using `minAmountOut - 1`, consider the + // case where `minAmountOut` is a "mint amount". Continuing with the + // example from block 19387902, if `minAmountOut` is `226218`, the + // `inflationPercentage` below would be 0. It would then be incremented + // to 1 and then when deriving the the true `amountOut` from the + // incremented `inflationPercentage`, it would get `amountOut = 226219`. + // However, if `minAmountOut` is `226219`, the `inflationPercentage` + // below would be 1 and it would be incremented to 2. Then, true + // `amountOut` would then be `452438` which is unecessarily minting more + // when the initial "mint amount" was perfect. So we map "mint amount"s + // to "mint range"s below themselves by substracting 1. Underflow + // avoided by the check for `minAmountOut == 0` at the start of the + // function. uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1); if (ethAmount == 0) return (0, 0); uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); diff --git a/test/fork/fuzz/lrt/RenzoLibrary.t.sol b/test/fork/fuzz/lrt/RenzoLibrary.t.sol index 815c8fb7..3e98722c 100644 --- a/test/fork/fuzz/lrt/RenzoLibrary.t.sol +++ b/test/fork/fuzz/lrt/RenzoLibrary.t.sol @@ -12,8 +12,8 @@ uint256 constant SCALE_FACTOR = 1e18; contract RenzoLibrary_FuzzTest is Test { function setUp() public { - vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_387_902); - // vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); + // vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19387902); + vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); } function testForkFuzz_GetEthAmountInForLstAmountOut(uint128 minLrtAmount) external { From c6e40c61947298be445bb485f1802eab0a82299b Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Mon, 11 Mar 2024 12:37:06 -0400 Subject: [PATCH 05/13] chore: remove unused var --- src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol b/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol index 75ee84fd..d95fd3e6 100644 --- a/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol +++ b/src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; -import { WadRayMath, WAD } from "../../../libraries/math/WadRayMath.sol"; +import { WadRayMath } from "../../../libraries/math/WadRayMath.sol"; import { ReserveOracle } from "../ReserveOracle.sol"; import { RENZO_RESTAKE_MANAGER, EZETH, WSTETH_ADDRESS } from "../../../Constants.sol"; From c9fc6a8840966fee517dab339f3cd3f48f46f6ad Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Thu, 14 Mar 2024 14:33:45 -0400 Subject: [PATCH 06/13] feat: UFDM with dust contract, ezETH reserve oracle and spot oracles --- ...swapFlashswapDirectMintHandlerWithDust.sol | 239 ++++++++++++++++++ src/flash/lrt/EzEthHandler.sol | 58 +++++ .../spot/lrt/EzEthWstEthSpotOracle.sol | 35 ++- ...apFlashswapDirectMintHandlerWithDust.t.sol | 89 +++++++ test/fork/concrete/lrt/EzEthHandler.t.sol | 115 +++++++++ test/fork/concrete/lrt/ReserveOracle.t.sol | 56 +++- test/fork/concrete/lrt/SpotOracle.t.sol | 13 + ...apFlashswapDirectMintHandlerWithDust.t.sol | 88 +++++++ test/fork/fuzz/lrt/EzEthHandler.t.sol | 41 +++ 9 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol create mode 100644 src/flash/lrt/EzEthHandler.sol create mode 100644 test/fork/concrete/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol create mode 100644 test/fork/concrete/lrt/EzEthHandler.t.sol create mode 100644 test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol create mode 100644 test/fork/fuzz/lrt/EzEthHandler.t.sol diff --git a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol new file mode 100644 index 00000000..4faf0344 --- /dev/null +++ b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +import { IonHandlerBase } from "./IonHandlerBase.sol"; +import { IWETH9 } from "../interfaces/IWETH9.sol"; + +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import { IUniswapV3SwapCallback } from "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol"; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { console2 } from "forge-std/console2.sol"; +/** + * @notice This contract is forked off of the UniswapFlashswapDirectMintHandler, + * with one distinction that it handles potential dust collateral amounts that + * can accrue when the contract ends up minting more collateral than originally intended. + * This situation can occur when the user has a desired leverage amount and thus an exact + * resulting collateral amount, but due to rounding errors in the minting contract, the handler + * is forced to mint a dust amount more than the desired collateral amount. + * In this contract, the dust is added to the total final deposit amount and ends up in the + * user's vault as additional collateral. + * + * The key difference between this contract and `UniswapFlashswapDirectMintHandler1 is a + * relaxed bound in comparing the sum of initiail user deposit and additionally minted collateral + * to the caller's requested resulting additional collateral amount. + * + * This contract allows for easy creation of leverge positions through a + * Uniswap flashswap and direct mint of the collateral from the provider. This + * will be used when the collateral cannot be minted directly with the base + * asset but can be directly minted by a token that the base asset has a + * UniswapV3 pool with. + * + * This contract is to be used when there exists a UniswapV3 pool between the + * base asset and the mint asset. + * + * @custom:security-contact security@molecularlabs.io + */ + +abstract contract UniswapFlashswapDirectMintHandlerWithDust is IonHandlerBase, IUniswapV3SwapCallback { + using SafeERC20 for IERC20; + using SafeERC20 for IWETH9; + using SafeCast for uint256; + + error InvalidUniswapPool(); + error InvalidZeroLiquidityRegionSwap(); + error CallbackOnlyCallableByPool(address unauthorizedCaller); + error OutputAmountNotReceived(uint256 amountReceived, uint256 amountRequired); + + /// @dev The minimum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MIN_TICK) + uint160 internal constant MIN_SQRT_RATIO = 4_295_128_739; + /// @dev The maximum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MAX_TICK) + uint160 internal constant MAX_SQRT_RATIO = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_342; + + IUniswapV3Pool public immutable UNISWAP_POOL; + IERC20 public immutable MINT_ASSET; + bool private immutable MINT_IS_TOKEN0; + + /** + * @notice Creates a new `UniswapFlashswapDirectMintHandler` instance. + * @param _uniswapPool Pool to perform the flashswap on. + * @param _mintAsset The asset used to mint the collateral. + */ + constructor(IUniswapV3Pool _uniswapPool, IERC20 _mintAsset) { + if (address(_uniswapPool) == address(0)) revert InvalidUniswapPool(); + + MINT_ASSET = _mintAsset; + + address token0 = _uniswapPool.token0(); + address token1 = _uniswapPool.token1(); + + if (token0 != address(MINT_ASSET) && token1 != address(MINT_ASSET)) { + revert InvalidUniswapPool(); + } + if (token0 == address(MINT_ASSET) && token1 == address(MINT_ASSET)) { + revert InvalidUniswapPool(); + } + + UNISWAP_POOL = _uniswapPool; + MINT_IS_TOKEN0 = token0 == address(MINT_ASSET) ? true : false; + + address baseAsset = MINT_IS_TOKEN0 ? token1 : token0; + + if (baseAsset != address(BASE)) revert InvalidUniswapPool(); + } + + /** + * @notice Transfer collateral from user -> Initiate flashswap between from + * base asset to mint asset -> Use the mint asset to mint the collateral -> + * Deposit all collateral into `IonPool` -> Borrow the base asset -> Close + * the flashswap by sending the base asset to the Uniswap pool. + * @param initialDeposit in collateral terms. [WAD] + * @param resultingAdditionalCollateral in collateral terms. [WAD] + * @param maxResultingDebt in base asset terms. [WAD] + * @param proof used to validate the user is whitelisted. + */ + function flashswapAndMint( + uint256 initialDeposit, + uint256 resultingAdditionalCollateral, + uint256 maxResultingDebt, + uint256 deadline, + bytes32[] memory proof + ) + external + onlyWhitelistedBorrowers(proof) + checkDeadline(deadline) + { + LST_TOKEN.safeTransferFrom(msg.sender, address(this), initialDeposit); + _flashswapAndMint(initialDeposit, resultingAdditionalCollateral, maxResultingDebt); + } + + function _flashswapAndMint( + uint256 initialDeposit, + uint256 resultingAdditionalCollateral, + uint256 maxResultingDebt + ) + internal + { + uint256 amountLrt = resultingAdditionalCollateral - initialDeposit; // in collateral terms + console2.log("amountLrt: ", amountLrt); + uint256 amountWethToFlashloan = _getAmountInForCollateralAmountOut(amountLrt); + console2.log("amountWethToFlashloan: ", amountWethToFlashloan); + if (amountWethToFlashloan == 0) { + // AmountToBorrow.IS_MAX because we don't want to create any new debt here + _depositAndBorrow(msg.sender, address(this), resultingAdditionalCollateral, 0, AmountToBorrow.IS_MAX); + return; + } + + // We want to swap for ETH here + bool zeroForOne = MINT_IS_TOKEN0 ? false : true; + uint256 baseAssetSwappedIn = _initiateFlashSwap({ + zeroForOne: zeroForOne, + amountOut: amountWethToFlashloan, + recipient: address(this), + data: abi.encode(msg.sender, resultingAdditionalCollateral, initialDeposit) + }); + console2.log("baseAssetSwappedIn: ", baseAssetSwappedIn); + + if (baseAssetSwappedIn > maxResultingDebt) { + revert FlashloanRepaymentTooExpensive(amountWethToFlashloan, maxResultingDebt); + } + } + + /** + * @notice Handles swap initiation logic. This function can only initiate + * exact output swaps. + * @param zeroForOne Direction of the swap. + * @param amountOut Desired amount of output. + * @param recipient of output tokens. + * @param data Arbitrary data to be passed through swap callback. + */ + function _initiateFlashSwap( + bool zeroForOne, + uint256 amountOut, + address recipient, + bytes memory data + ) + private + returns (uint256 amountIn) + { + (int256 amount0Delta, int256 amount1Delta) = UNISWAP_POOL.swap( + recipient, zeroForOne, -amountOut.toInt256(), zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1, data + ); + + uint256 amountOutReceived; + (amountIn, amountOutReceived) = zeroForOne + ? (uint256(amount0Delta), uint256(-amount1Delta)) + : (uint256(amount1Delta), uint256(-amount0Delta)); + + // it's technically possible to not receive the full output amount, + if (amountOutReceived != amountOut) revert OutputAmountNotReceived(amountOutReceived, amountOut); + } + + /** + * @notice From the perspective of the pool i.e. Negative amount means pool is + * sending. This function is intended to never be called directly. It should + * only be called by the Uniswap pool during a swap initiated by this + * contract. + * + * @dev One thing to note from a security perspective is that the pool only calls + * the callback on `msg.sender`. So a theoretical attacker cannot call this + * function by directing where to call the callback. + * + * @param amount0Delta change in token0 + * @param amount1Delta change in token1 + * @param _data arbitrary data + */ + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata _data) external override { + if (msg.sender != address(UNISWAP_POOL)) revert CallbackOnlyCallableByPool(msg.sender); + + // swaps entirely within 0-liquidity regions are not supported + if (amount0Delta == 0 && amount1Delta == 0) revert InvalidZeroLiquidityRegionSwap(); + (address user, uint256 resultingAdditionalCollateral, uint256 initialDeposit) = + abi.decode(_data, (address, uint256, uint256)); + + // Code below this if statement will always assume token0 is MINT_ASSET. If it + // is not actually the case, we will flip the vars + if (!MINT_IS_TOKEN0) { + (amount0Delta, amount1Delta) = (amount1Delta, amount0Delta); + } + + address tokenIn = address(BASE); + + // Sanity check that Uniswap is sending MINT_ASSET + assert(amount0Delta < 0 && amount1Delta > 0); + + // MINT_ASSET needs to be converted into collateral asset + uint256 collateralFromDeposit = _mintCollateralAsset(uint256(-amount0Delta)); + + // Sanity check + // Greater than and not equal to if extra dust collateral was minted due to + // rounding errors. Guarantees minimum bound for the leveraged collateral amount. + uint256 depositAmount = initialDeposit + collateralFromDeposit; + assert(depositAmount >= resultingAdditionalCollateral); + + // AmountToBorrow.IS_MIN because we want to make sure enough is borrowed + // to cover the amount owed back to Uniswap + _depositAndBorrow(user, address(this), depositAmount, uint256(amount1Delta), AmountToBorrow.IS_MIN); + + IERC20(tokenIn).safeTransfer(msg.sender, uint256(amount1Delta)); + } + + /** + * @notice Deposits the mint asset into the provider's collateral-asset + * deposit contract. + * @param amountMintAsset amount of "mint asset" to deposit. [WAD] + * @return Amount of collateral asset received. [WAD] + */ + function _mintCollateralAsset(uint256 amountMintAsset) internal virtual returns (uint256); + + /** + * @notice Calculates the amount of mint asset required to receive + * `amountLrt`. + * @dev Calculates the amount of mint asset required to receive `amountLrt`. + * @param amountLrt Desired output amount. [WAD] + * @return Amount mint asset required for desired output. [WAD] + */ + function _getAmountInForCollateralAmountOut(uint256 amountLrt) internal view virtual returns (uint256); +} diff --git a/src/flash/lrt/EzEthHandler.sol b/src/flash/lrt/EzEthHandler.sol new file mode 100644 index 00000000..44656d9b --- /dev/null +++ b/src/flash/lrt/EzEthHandler.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +import { IonPool } from "../../IonPool.sol"; +import { GemJoin } from "../../join/GemJoin.sol"; +import { Whitelist } from "../../Whitelist.sol"; +import { UniswapFlashswapDirectMintHandlerWithDust } from "./../UniswapFlashswapDirectMintHandlerWithDust.sol"; +import { IonHandlerBase } from "../IonHandlerBase.sol"; +import { RenzoLibrary } from "./../../libraries/lrt/RenzoLibrary.sol"; +import { EZETH, WETH_ADDRESS } from "../../Constants.sol"; + +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +/** + * @notice Handler for the ezETH collateral. + * + * @custom:security-contact security@molecularlabs.io + */ +contract EzEthHandler is UniswapFlashswapDirectMintHandlerWithDust { + /** + * @notice Creates a new `EzEthHandler` instance. + * @param _ilkIndex Ilk index of the pool. + * @param _ionPool address. + * @param _gemJoin address. + * @param _whitelist address. + * @param _wstEthUniswapPool address of the wstETH/WETH Uniswap pool (0.01% fee). + */ + constructor( + uint8 _ilkIndex, + IonPool _ionPool, + GemJoin _gemJoin, + Whitelist _whitelist, + IUniswapV3Pool _wstEthUniswapPool + ) + IonHandlerBase(_ilkIndex, _ionPool, _gemJoin, _whitelist) + UniswapFlashswapDirectMintHandlerWithDust(_wstEthUniswapPool, WETH_ADDRESS) + { } + + /** + * @inheritdoc UniswapFlashswapDirectMintHandlerWithDust + */ + function _mintCollateralAsset(uint256 amountWeth) internal override returns (uint256) { + WETH.withdraw(amountWeth); + return RenzoLibrary.depositForLrt(amountWeth); + } + + /** + * @inheritdoc UniswapFlashswapDirectMintHandlerWithDust + */ + function _getAmountInForCollateralAmountOut(uint256 amountOut) + internal + view + override + returns (uint256 ethAmountIn) + { + (ethAmountIn,) = RenzoLibrary.getEthAmountInForLstAmountOut(amountOut); + } +} diff --git a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol index 0a6dd93c..a0b60cee 100644 --- a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol +++ b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol @@ -3,6 +3,14 @@ pragma solidity 0.8.21; import { SpotOracle } from "../SpotOracle.sol"; import { WadRayMath } from "../../../libraries/math/WadRayMath.sol"; +import { + WSTETH_ADDRESS, + REDSTONE_EZETH_ETH_PRICE_FEED, + ETH_PER_STETH_CHAINLINK, + REDSTONE_DECIMALS +} from "../../../Constants.sol"; +import { IWstEth } from "../../../interfaces/ProviderInterfaces.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; /** * @notice The ezETH spot oracle denominated in wstETH @@ -11,6 +19,7 @@ import { WadRayMath } from "../../../libraries/math/WadRayMath.sol"; */ contract EzEthWstEthSpotOracle is SpotOracle { using WadRayMath for uint256; + using SafeCast for int256; uint256 public immutable MAX_TIME_FROM_LAST_UPDATE; // seconds @@ -29,5 +38,29 @@ contract EzEthWstEthSpotOracle is SpotOracle { MAX_TIME_FROM_LAST_UPDATE = _maxTimeFromLastUpdate; } - function getPrice() public view override returns (uint256) { } + /** + * @notice Gets the price of ezETH in wstETH + * (ETH / ezETH) / (ETH / stETH) * (wstETH / stETH) = wstETH / ezETH + */ + function getPrice() public view override returns (uint256) { + // ETH / ezETH [8 decimals] + (, int256 ethPerEzEth,, uint256 ethPerEzEthUpdatedAt,) = REDSTONE_EZETH_ETH_PRICE_FEED.latestRoundData(); + // ETH / stETH [18 decimals] + (, int256 ethPerStEth,, uint256 ethPerStEthUpdatedAt,) = ETH_PER_STETH_CHAINLINK.latestRoundData(); + + if ( + block.timestamp - ethPerEzEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE + || block.timestamp - ethPerStEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE + ) { + return 0; // collateral valuation is zero if oracle data is stale + } else { + // (ETH / ezETH) / (ETH / ezETH) = stETH / ezETH + uint256 stEthPerEzEth = + ethPerEzEth.toUint256().scaleUpToWad(REDSTONE_DECIMALS).wadDivDown(ethPerStEth.toUint256()); // [wad] + + uint256 wstEthPerStEth = IWstEth(WSTETH_ADDRESS).tokensPerStEth(); // [wad] + // (wstETH / ezETH) = (stETH / ezETH) * (wstETH / stETH) + return stEthPerEzEth.wadMulDown(wstEthPerStEth); // [wad] + } + } } diff --git a/test/fork/concrete/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol b/test/fork/concrete/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol new file mode 100644 index 00000000..7dae9fa0 --- /dev/null +++ b/test/fork/concrete/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { UniswapFlashswapDirectMintHandlerWithDust } from + "../../../../src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol"; +import { Whitelist } from "../../../../src/Whitelist.sol"; +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { WadRayMath, RAY } from "../../../../src/libraries/math/WadRayMath.sol"; + +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +import { console2 } from "forge-std/console2.sol"; + +using WadRayMath for uint256; + +abstract contract UniswapFlashswapDirectMintHandlerWithDust_Test is LrtHandler_ForkBase { + function testFork_FlashswapAndMint() public virtual { + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5.239573295673902613e18; + uint256 maxResultingDebt = + _getProviderLibrary().getEthAmountInForLstAmountOut(resultingAdditionalCollateral - initialDeposit); + + weth.approve(address(_getTypedUFDMHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFDMHandler())); + + if (Whitelist(whitelist).borrowersRoot(0) != 0) { + vm.expectRevert(abi.encodeWithSelector(Whitelist.NotWhitelistedBorrower.selector, 0, address(this))); + _getTypedUFDMHandler().flashswapAndMint( + initialDeposit, resultingAdditionalCollateral, maxResultingDebt, block.timestamp + 1, new bytes32[](0) + ); + } + + uint256 gasBefore = gasleft(); + _getTypedUFDMHandler().flashswapAndMint( + initialDeposit, resultingAdditionalCollateral, maxResultingDebt, 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; + if (currentRate % RAY != 0) roundingError++; + + assertLe( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + roundingError, + "max resulting debt upper bound with rounding error" + ); + assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFDMHandler())), 0); + assertLe(IERC20(_getUnderlying()).balanceOf(address(_getTypedUFDMHandler())), roundingError); + // TODO: bound this with a max dust bound + assertGt( + ionPool.collateral(_getIlkIndex(), address(this)), + resultingAdditionalCollateral, + "resulting collateral should be greater than the expected collateral accounting for dust" + ); + } + + function testFork_RevertWhen_UntrustedCallerCallsFlashswapCallback() external { + vm.skip(borrowerWhitelistProof.length > 0); + + vm.expectRevert( + abi.encodeWithSelector( + UniswapFlashswapDirectMintHandlerWithDust.CallbackOnlyCallableByPool.selector, address(this) + ) + ); + _getTypedUFDMHandler().uniswapV3SwapCallback(1, 1, ""); + } + + function testFork_RevertWhen_FlashswapAndMintCreatesMoreDebtThanUserIsWilling() external { + vm.skip(borrowerWhitelistProof.length > 0); + + uint256 initialDeposit = 1e18; + uint256 resultingAdditionalCollateral = 5e18; + uint256 maxResultingDebt = 3e18; // In weth + + weth.approve(address(_getTypedUFDMHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFDMHandler())); + + vm.expectRevert(); + _getTypedUFDMHandler().flashswapAndMint( + initialDeposit, resultingAdditionalCollateral, maxResultingDebt, block.timestamp + 1, new bytes32[](0) + ); + } + + function _getTypedUFDMHandler() internal view returns (UniswapFlashswapDirectMintHandlerWithDust) { + return UniswapFlashswapDirectMintHandlerWithDust(payable(_getHandler())); + } +} diff --git a/test/fork/concrete/lrt/EzEthHandler.t.sol b/test/fork/concrete/lrt/EzEthHandler.t.sol new file mode 100644 index 00000000..290032d3 --- /dev/null +++ b/test/fork/concrete/lrt/EzEthHandler.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { IWeEth } from "../../../../src/interfaces/ProviderInterfaces.sol"; +import { RenzoLibrary } from "./../../../../src/libraries/lrt/RenzoLibrary.sol"; +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { EzEthHandler } from "./../../../../src/flash/lrt/EzEthHandler.sol"; +import { Whitelist } from "../../../../src/Whitelist.sol"; +import { RENZO_RESTAKE_MANAGER, EZETH } from "../../../../src/Constants.sol"; + +import { IProviderLibraryExposed } from "../../../helpers/IProviderLibraryExposed.sol"; +import { UniswapFlashswapDirectMintHandlerWithDust_Test } from + "../handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract RenzoLibraryExposed is IProviderLibraryExposed { + function getEthAmountInForLstAmountOut(uint256 lstAmount) external view returns (uint256 ethAmountIn) { + (ethAmountIn,) = RenzoLibrary.getEthAmountInForLstAmountOut(lstAmount); + } + + function getLstAmountOutForEthAmountIn(uint256 ethAmount) external view returns (uint256 amountOut) { + (amountOut,) = RenzoLibrary.getLstAmountOutForEthAmountIn(ethAmount); + } +} + +abstract contract EzEthHandler_ForkBase is LrtHandler_ForkBase { + uint8 internal constant ilkIndex = 0; + EzEthHandler ezEthHandler; + IProviderLibraryExposed providerLibrary; + + function setUp() public virtual override { + super.setUp(); + ezEthHandler = new EzEthHandler(ilkIndex, ionPool, gemJoins[ilkIndex], Whitelist(whitelist), WSTETH_WETH_POOL); + + EZETH.approve(address(ezEthHandler), type(uint256).max); + + // Remove debt ceiling for this test + for (uint8 i = 0; i < ionPool.ilkCount(); i++) { + ionPool.updateIlkDebtCeiling(i, type(uint256).max); + } + + providerLibrary = new RenzoLibraryExposed(); + + vm.deal(address(this), INITIAL_BORROWER_COLLATERAL_BALANCE); + RenzoLibrary.depositForLrt(INITIAL_BORROWER_COLLATERAL_BALANCE); + } + + function _getIlkIndex() internal pure override returns (uint8) { + return ilkIndex; + } + + function _getProviderLibrary() internal view override returns (IProviderLibraryExposed) { + return providerLibrary; + } + + function _getHandler() internal view override returns (address) { + return address(ezEthHandler); + } +} + +contract EzEthHandler_ForkTest is EzEthHandler_ForkBase, UniswapFlashswapDirectMintHandlerWithDust_Test { + function setUp() public virtual override(EzEthHandler_ForkBase, LrtHandler_ForkBase) { + super.setUp(); + } + + function _getCollaterals() internal pure override returns (IERC20[] memory _collaterals) { + _collaterals = new IERC20[](1); + _collaterals[0] = EZETH; + } + + function _getDepositContracts() internal pure override returns (address[] memory depositContracts) { + depositContracts = new address[](1); + depositContracts[0] = address(RENZO_RESTAKE_MANAGER); + } +} + +contract EzEthHandlerWhitelist_ForkTest is EzEthHandler_ForkTest { + // generate merkle root + // ["0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"], + // ["0x2222222222222222222222222222222222222222"], + // => 0xb51a382d5bcb4cd5fe50a7d4d8abaf056ac1a6961cf654ec4f53a570ab75a30b + + bytes32 borrowerWhitelistRoot = 0x846dfddafc70174f2089edda6408bf9dd643c19ee06ff11643b614f0e277d6e3; + + bytes32[][] borrowerProofs = [ + [bytes32(0x708e7cb9a75ffb24191120fba1c3001faa9078147150c6f2747569edbadee751)], + [bytes32(0xa6e6806303186f9c20e1af933c7efa83d98470acf93a10fb8da8b1d9c2873640)] + ]; + + Whitelist _whitelist; + + function setUp() public override { + super.setUp(); + + bytes32[] memory borrowerRoots = new bytes32[](1); + borrowerRoots[0] = borrowerWhitelistRoot; + + _whitelist = new Whitelist(borrowerRoots, bytes32(0)); + _whitelist.updateBorrowersRoot(ilkIndex, borrowerWhitelistRoot); + _whitelist.approveProtocolWhitelist(address(ezEthHandler)); + + ionPool.updateWhitelist(_whitelist); + + borrowerWhitelistProof = borrowerProofs[0]; + } +} + +contract EzEthHandler_WithRateChange_ForkTest is EzEthHandler_ForkTest { + function setUp() public virtual override { + super.setUp(); + + ionPool.setRate(ilkIndex, 3.5708923502395e27); + } +} diff --git a/test/fork/concrete/lrt/ReserveOracle.t.sol b/test/fork/concrete/lrt/ReserveOracle.t.sol index 216ffcef..ba109d1c 100644 --- a/test/fork/concrete/lrt/ReserveOracle.t.sol +++ b/test/fork/concrete/lrt/ReserveOracle.t.sol @@ -13,13 +13,18 @@ import { WSTETH_ADDRESS, RSETH, ETHX_ADDRESS, - RSWETH + RSWETH, + EZETH, + RENZO_ORACLE, + RENZO_RESTAKE_MANAGER } from "../../../../src/Constants.sol"; import { ReserveOracleSharedSetup } from "../../../helpers/ReserveOracleSharedSetup.sol"; import { StdStorage, stdStorage } from "../../../../lib/forge-safe/lib/forge-std/src/StdStorage.sol"; import { IERC20 } from "../../../../lib/forge-safe/lib/forge-std/src/interfaces/IERC20.sol"; import { RAY } from "../../../../src/libraries/math/WadRayMath.sol"; import { WeEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/WeEthWstEthReserveOracle.sol"; +import { EzEthWstEthReserveOracle } from "./../../../../src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol"; +import { ReserveFeed } from "../../../../src/oracles/reserve/ReserveFeed.sol"; import { ReserveOracle } from "../../../../src/oracles/reserve/ReserveOracle.sol"; import { ReserveOracleSharedSetup } from "../../../helpers/ReserveOracleSharedSetup.sol"; @@ -117,6 +122,9 @@ abstract contract ReserveOracle_ForkTest is ReserveOracleSharedSetup { */ function _convertToEth(uint256 amt) internal virtual returns (uint256); + /** + * @dev The expected protocol exchange rate in lender asset denomination + */ function _getProtocolExchangeRate() internal virtual returns (uint256); } @@ -167,7 +175,51 @@ contract RsEthWstEthReserveOracle_ForkTest is ReserveOracle_ForkTest { } } -contract WeEthWstEthReserveOracle_ForkTest is ReserveOracle_ForkTest { +contract EzEthWstEthReserveOracle_ForkTest is ReserveOracle_ForkTest { + using WadRayMath for uint256; + + bytes32 constant EZETH_TOTAL_SUPPLY_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000035; + + function setUp() public override { + super.setUp(); + reserveOracle = new EzEthWstEthReserveOracle(ILK_INDEX, emptyFeeds, QUORUM, MAX_CHANGE); + } + + function _increaseExchangeRate() internal override returns (uint256) { + uint256 prevExchangeRate = _getProtocolExchangeRate(); + // effectively doubles the exchange rate by halving the total supply of ezETH + uint256 existingEzETHSupply = EZETH.totalSupply(); + uint256 newTotalSupply = existingEzETHSupply / 2; + vm.store(address(EZETH), EZETH_TOTAL_SUPPLY_SLOT, bytes32(newTotalSupply)); + uint256 newExchangeRate = _getProtocolExchangeRate(); + + require(newExchangeRate > prevExchangeRate, "exchange rate should increase"); + } + + function _decreaseExchangeRate() internal override returns (uint256) { + uint256 prevExchangeRate = _getProtocolExchangeRate(); + // effectively halves the exchange rate by doubling the total supply of ezETH + uint256 existingEzETHSupply = EZETH.totalSupply(); + uint256 newTotalSupply = existingEzETHSupply * 2; + vm.store(address(EZETH), EZETH_TOTAL_SUPPLY_SLOT, bytes32(newTotalSupply)); + uint256 newExchangeRate = _getProtocolExchangeRate(); + + require(newExchangeRate < prevExchangeRate, "exchange rate should decrease"); + } + + function _convertToEth(uint256 amt) internal view override returns (uint256) { + return WSTETH_ADDRESS.getStETHByWstETH(amt); + } + + function _getProtocolExchangeRate() internal view override returns (uint256) { + (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 totalSupply = EZETH.totalSupply(); + uint256 exchangeRateInEth = totalTVL.wadDivDown(totalSupply); + return exchangeRateInEth.wadMulDown(WSTETH_ADDRESS.tokensPerStEth()); + } +} + +contract WeEthWstEthReserveOracleForkTest is ReserveOracle_ForkTest { function setUp() public override { // blockNumber = 19_079_925; super.setUp(); diff --git a/test/fork/concrete/lrt/SpotOracle.t.sol b/test/fork/concrete/lrt/SpotOracle.t.sol index d9370cea..4e49dd93 100644 --- a/test/fork/concrete/lrt/SpotOracle.t.sol +++ b/test/fork/concrete/lrt/SpotOracle.t.sol @@ -10,6 +10,8 @@ import { WeEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/We import { WeEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/weEthWstEthSpotOracle.sol"; import { RswEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/RswEthWstEthReserveOracle.sol"; import { RswEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/rswEthWstEthSpotOracle.sol"; +import { EzEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol"; +import { EzEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/ezEthWstEthSpotOracle.sol"; import { WadRayMath } from "../../../../src/libraries/math/WadRayMath.sol"; import { ReserveOracleSharedSetup } from "../../../helpers/ReserveOracleSharedSetup.sol"; @@ -111,3 +113,14 @@ contract RswEthWstEthSpotOracle_ForkTest is SpotOracle_ForkTest { spotOracle = new RswEthWstEthSpotOracle(MAX_LTV, address(reserveOracle), MAX_TIME_FROM_LAST_UPDATE); } } + +contract EzEthWstEthSpotOracle_ForkTest is SpotOracle_ForkTest { + uint256 constant MAX_TIME_FROM_LAST_UPDATE = 87_000; + uint256 constant MAX_LTV = 0.8e27; + + function setUp() public override { + super.setUp(); + reserveOracle = new EzEthWstEthReserveOracle(ILK_INDEX, emptyFeeds, QUORUM, DEFAULT_MAX_CHANGE); + spotOracle = new EzEthWstEthSpotOracle(MAX_LTV, address(reserveOracle), MAX_TIME_FROM_LAST_UPDATE); + } +} diff --git a/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol new file mode 100644 index 00000000..7b0823b7 --- /dev/null +++ b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { UniswapFlashswapDirectMintHandlerWithDust } from + "../../../../src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol"; +import { WadRayMath, RAY } from "../../../../src/libraries/math/WadRayMath.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +using WadRayMath for uint256; + +import { console2 } from "forge-std/console2.sol"; + +struct Config { + uint256 initialDepositLowerBound; +} + +// TODO: The base contracts are currently not market agnostic +abstract contract UniswapFlashswapDirectMintHandlerWithDust_FuzzTest is LrtHandler_ForkBase { + Config ufdmConfig; + + function testForkFuzz_FlashswapAndMint(uint256 initialDeposit, uint256 resultingCollateralMultiplier) public { + initialDeposit = bound(initialDeposit, ufdmConfig.initialDepositLowerBound, INITIAL_THIS_UNDERLYING_BALANCE); + uint256 resultingCollateral = initialDeposit * bound(resultingCollateralMultiplier, 1, 5); + + weth.approve(address(_getTypedUFDMHandler()), type(uint256).max); + ionPool.addOperator(address(_getTypedUFDMHandler())); + + uint256 ilkRate = ionPool.rate(_getIlkIndex()); + uint256 ilkSpot = ionPool.spot(_getIlkIndex()).getSpot(); + + // uint256 maxResultingDebt = resultingCollateral * ilkSpot / 1e27; + uint256 maxResultingDebt = + _getProviderLibrary().getEthAmountInForLstAmountOut(resultingCollateral - initialDeposit); + console2.log("maxResultingDebt: ", maxResultingDebt); + + // Calculating this way emulates the newTotalDebt value in IonPool + uint256 newTotalDebt = maxResultingDebt.rayDivUp(ilkRate) * ilkRate; + + bool unsafePositionChange = newTotalDebt > resultingCollateral * ilkSpot; + + vm.assume(!unsafePositionChange); + + _getTypedUFDMHandler().flashswapAndMint( + initialDeposit, resultingCollateral, maxResultingDebt, block.timestamp + 1, new bytes32[](0) + ); + + uint256 currentRate = ionPool.rate(_getIlkIndex()); + uint256 roundingError = currentRate / RAY; + if (currentRate % RAY != 0) roundingError++; + + // TODO: calculate this dust amount + uint256 maxDust = 300_000; + + assertLt( + ionPool.collateral(_getIlkIndex(), address(this)), + resultingCollateral + maxDust, + "resulting collateral with dust is above the minimum amount to mint" + ); + assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFDMHandler())), 0); + assertLe(IERC20(_getUnderlying()).balanceOf(address(_getTypedUFDMHandler())), roundingError); + assertLe( + ionPool.normalizedDebt(_getIlkIndex(), address(this)).rayMulUp(ionPool.rate(_getIlkIndex())), + maxResultingDebt + roundingError + ); + } + + function _getTypedUFDMHandler() internal view returns (UniswapFlashswapDirectMintHandlerWithDust) { + return UniswapFlashswapDirectMintHandlerWithDust(payable(_getHandler())); + } +} + +abstract contract UniswapFlashswapDirectMintHandlerWithDust_WithRateChange_FuzzTest is + UniswapFlashswapDirectMintHandlerWithDust_FuzzTest +{ + function testForkFuzz_WithRateChange_FlashswapAndMint( + uint256 initialDeposit, + uint256 resultingCollateralMultiplier, + uint104 rate + ) + external + { + rate = uint104(bound(rate, 1e27, 10e27)); + ionPool.setRate(_getIlkIndex(), rate); + super.testForkFuzz_FlashswapAndMint(initialDeposit, resultingCollateralMultiplier); + } +} diff --git a/test/fork/fuzz/lrt/EzEthHandler.t.sol b/test/fork/fuzz/lrt/EzEthHandler.t.sol new file mode 100644 index 00000000..59901477 --- /dev/null +++ b/test/fork/fuzz/lrt/EzEthHandler.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { EzEthHandler_ForkBase } from "../../concrete/lrt/EzEthHandler.t.sol"; +import { LrtHandler_ForkBase } from "../../../helpers/handlers/LrtHandlerForkBase.sol"; +import { + UniswapFlashswapDirectMintHandlerWithDust_FuzzTest, + UniswapFlashswapDirectMintHandlerWithDust_WithRateChange_FuzzTest +} from "../handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol"; +import { EZETH } from "../../../../src/Constants.sol"; + +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +abstract contract EzEthHandler_ForkFuzzTest is + EzEthHandler_ForkBase, + UniswapFlashswapDirectMintHandlerWithDust_FuzzTest +{ + function setUp() public virtual override(LrtHandler_ForkBase, EzEthHandler_ForkBase) { + super.setUp(); + } +} + +contract EzEthHandler_WithRateChange_ForkFuzzTest is + EzEthHandler_ForkFuzzTest, + UniswapFlashswapDirectMintHandlerWithDust_WithRateChange_FuzzTest +{ + function setUp() public override(LrtHandler_ForkBase, EzEthHandler_ForkFuzzTest) { + super.setUp(); + ufdmConfig.initialDepositLowerBound = 4 wei; + } + + function _getCollaterals() internal pure override returns (IERC20[] memory _collaterals) { + _collaterals = new IERC20[](1); + _collaterals[0] = EZETH; + } + + function _getDepositContracts() internal pure override returns (address[] memory depositContracts) { + depositContracts = new address[](1); + depositContracts[0] = address(EZETH); + } +} From 6c83dfed984ecbbca3a70679dd2fe70d4dd91a39 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Tue, 19 Mar 2024 17:53:19 -0400 Subject: [PATCH 07/13] test: UFDMWithDust, spot oracle, and reserve oracle tests for ezETH integration --- ...niswapFlashswapDirectMintHandlerWithDust.sol | 4 +--- src/interfaces/ProviderInterfaces.sol | 1 + src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol | 3 +-- test/fork/concrete/lrt/ReserveOracle.t.sol | 8 ++++---- test/fork/concrete/lrt/SpotOracle.t.sol | 17 ++++++++--------- ...swapFlashswapDirectMintHandlerWithDust.t.sol | 8 +++----- 6 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol index 4faf0344..58d7d6f5 100644 --- a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol +++ b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol @@ -117,9 +117,8 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust is IonHandlerBase, I internal { uint256 amountLrt = resultingAdditionalCollateral - initialDeposit; // in collateral terms - console2.log("amountLrt: ", amountLrt); uint256 amountWethToFlashloan = _getAmountInForCollateralAmountOut(amountLrt); - console2.log("amountWethToFlashloan: ", amountWethToFlashloan); + if (amountWethToFlashloan == 0) { // AmountToBorrow.IS_MAX because we don't want to create any new debt here _depositAndBorrow(msg.sender, address(this), resultingAdditionalCollateral, 0, AmountToBorrow.IS_MAX); @@ -134,7 +133,6 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust is IonHandlerBase, I recipient: address(this), data: abi.encode(msg.sender, resultingAdditionalCollateral, initialDeposit) }); - console2.log("baseAssetSwappedIn: ", baseAssetSwappedIn); if (baseAssetSwappedIn > maxResultingDebt) { revert FlashloanRepaymentTooExpensive(amountWethToFlashloan, maxResultingDebt); diff --git a/src/interfaces/ProviderInterfaces.sol b/src/interfaces/ProviderInterfaces.sol index a8959d9f..29010a09 100644 --- a/src/interfaces/ProviderInterfaces.sol +++ b/src/interfaces/ProviderInterfaces.sol @@ -184,6 +184,7 @@ interface IRenzoOracle { external pure returns (uint256); + function calculateTVLs() external view returns (uint256[][] memory, uint256[] memory, uint256); } interface IOperatorDelegator { diff --git a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol index a0b60cee..e55c0170 100644 --- a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol +++ b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol @@ -47,14 +47,13 @@ contract EzEthWstEthSpotOracle is SpotOracle { (, int256 ethPerEzEth,, uint256 ethPerEzEthUpdatedAt,) = REDSTONE_EZETH_ETH_PRICE_FEED.latestRoundData(); // ETH / stETH [18 decimals] (, int256 ethPerStEth,, uint256 ethPerStEthUpdatedAt,) = ETH_PER_STETH_CHAINLINK.latestRoundData(); - if ( block.timestamp - ethPerEzEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE || block.timestamp - ethPerStEthUpdatedAt > MAX_TIME_FROM_LAST_UPDATE ) { return 0; // collateral valuation is zero if oracle data is stale } else { - // (ETH / ezETH) / (ETH / ezETH) = stETH / ezETH + // (ETH / ezETH) / (ETH / stETH) = stETH / ezETH uint256 stEthPerEzEth = ethPerEzEth.toUint256().scaleUpToWad(REDSTONE_DECIMALS).wadDivDown(ethPerStEth.toUint256()); // [wad] diff --git a/test/fork/concrete/lrt/ReserveOracle.t.sol b/test/fork/concrete/lrt/ReserveOracle.t.sol index ba109d1c..441fa22b 100644 --- a/test/fork/concrete/lrt/ReserveOracle.t.sol +++ b/test/fork/concrete/lrt/ReserveOracle.t.sol @@ -185,24 +185,24 @@ contract EzEthWstEthReserveOracle_ForkTest is ReserveOracle_ForkTest { reserveOracle = new EzEthWstEthReserveOracle(ILK_INDEX, emptyFeeds, QUORUM, MAX_CHANGE); } - function _increaseExchangeRate() internal override returns (uint256) { + function _increaseExchangeRate() internal override returns (uint256 newExchangeRate) { uint256 prevExchangeRate = _getProtocolExchangeRate(); // effectively doubles the exchange rate by halving the total supply of ezETH uint256 existingEzETHSupply = EZETH.totalSupply(); uint256 newTotalSupply = existingEzETHSupply / 2; vm.store(address(EZETH), EZETH_TOTAL_SUPPLY_SLOT, bytes32(newTotalSupply)); - uint256 newExchangeRate = _getProtocolExchangeRate(); + newExchangeRate = _getProtocolExchangeRate(); require(newExchangeRate > prevExchangeRate, "exchange rate should increase"); } - function _decreaseExchangeRate() internal override returns (uint256) { + function _decreaseExchangeRate() internal override returns (uint256 newExchangeRate) { uint256 prevExchangeRate = _getProtocolExchangeRate(); // effectively halves the exchange rate by doubling the total supply of ezETH uint256 existingEzETHSupply = EZETH.totalSupply(); uint256 newTotalSupply = existingEzETHSupply * 2; vm.store(address(EZETH), EZETH_TOTAL_SUPPLY_SLOT, bytes32(newTotalSupply)); - uint256 newExchangeRate = _getProtocolExchangeRate(); + newExchangeRate = _getProtocolExchangeRate(); require(newExchangeRate < prevExchangeRate, "exchange rate should decrease"); } diff --git a/test/fork/concrete/lrt/SpotOracle.t.sol b/test/fork/concrete/lrt/SpotOracle.t.sol index 4e49dd93..798dc55a 100644 --- a/test/fork/concrete/lrt/SpotOracle.t.sol +++ b/test/fork/concrete/lrt/SpotOracle.t.sol @@ -6,12 +6,12 @@ import { ReserveOracle } from "../../../../src/oracles/reserve/ReserveOracle.sol import { SpotOracle } from "../../../../src/oracles/spot/SpotOracle.sol"; import { RsEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/RsEthWstEthReserveOracle.sol"; import { RsEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/rsEthWstEthSpotOracle.sol"; -import { WeEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/WeEthWstEthReserveOracle.sol"; -import { WeEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/weEthWstEthSpotOracle.sol"; import { RswEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/RswEthWstEthReserveOracle.sol"; import { RswEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/rswEthWstEthSpotOracle.sol"; -import { EzEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol"; -import { EzEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/ezEthWstEthSpotOracle.sol"; +import { WeEthWstEthReserveOracle } from "../../../../src/oracles/reserve/lrt/WeEthWstEthReserveOracle.sol"; +import { WeEthWstEthSpotOracle } from "../../../../src/oracles/spot/lrt/weEthWstEthSpotOracle.sol"; +import { EzEthWstEthReserveOracle } from "./../../../../src/oracles/reserve/lrt/EzEthWstEthReserveOracle.sol"; +import { EzEthWstEthSpotOracle } from "./../../../../src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol"; import { WadRayMath } from "../../../../src/libraries/math/WadRayMath.sol"; import { ReserveOracleSharedSetup } from "../../../helpers/ReserveOracleSharedSetup.sol"; @@ -32,14 +32,13 @@ abstract contract SpotOracle_ForkTest is ReserveOracleSharedSetup { } function testFork_ViewSpot() public { - uint256 ltv = spotOracle.LTV(); - uint256 price = spotOracle.getPrice(); - uint256 currentExchangeRate = reserveOracle.currentExchangeRate(); + uint256 exchangeRate = reserveOracle.currentExchangeRate(); + uint256 value = price <= exchangeRate ? price : exchangeRate; - uint256 min = Math.min(price, currentExchangeRate); + uint256 ltv = spotOracle.LTV(); + uint256 expectedSpot = ltv.wadMulDown(value); - uint256 expectedSpot = ltv.wadMulDown(min); uint256 spot = spotOracle.getSpot(); assertEq(spot, expectedSpot, "spot"); diff --git a/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol index 7b0823b7..c6692650 100644 --- a/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol +++ b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol @@ -16,7 +16,6 @@ struct Config { uint256 initialDepositLowerBound; } -// TODO: The base contracts are currently not market agnostic abstract contract UniswapFlashswapDirectMintHandlerWithDust_FuzzTest is LrtHandler_ForkBase { Config ufdmConfig; @@ -33,7 +32,6 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust_FuzzTest is LrtHandl // uint256 maxResultingDebt = resultingCollateral * ilkSpot / 1e27; uint256 maxResultingDebt = _getProviderLibrary().getEthAmountInForLstAmountOut(resultingCollateral - initialDeposit); - console2.log("maxResultingDebt: ", maxResultingDebt); // Calculating this way emulates the newTotalDebt value in IonPool uint256 newTotalDebt = maxResultingDebt.rayDivUp(ilkRate) * ilkRate; @@ -50,13 +48,13 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust_FuzzTest is LrtHandl uint256 roundingError = currentRate / RAY; if (currentRate % RAY != 0) roundingError++; - // TODO: calculate this dust amount - uint256 maxDust = 300_000; + // TODO: Can this dust amount be bounded at run-time? + uint256 maxDust = 400_000; assertLt( ionPool.collateral(_getIlkIndex(), address(this)), resultingCollateral + maxDust, - "resulting collateral with dust is above the minimum amount to mint" + "resulting collateral with dust is max bounded" ); assertEq(IERC20(address(_getCollaterals()[_getIlkIndex()])).balanceOf(address(_getTypedUFDMHandler())), 0); assertLe(IERC20(_getUnderlying()).balanceOf(address(_getTypedUFDMHandler())), roundingError); From 843edcdcccb734cd7b466fdb29a371dfea89f089 Mon Sep 17 00:00:00 2001 From: Hrik Bhowal Date: Wed, 20 Mar 2024 19:48:49 -0400 Subject: [PATCH 08/13] fix: handle occasional rounding error --- ...swapFlashswapDirectMintHandlerWithDust.sol | 32 +++++++------ src/libraries/lrt/RenzoLibrary.sol | 47 +++++++++++++++---- test/fork/fuzz/lrt/RenzoLibrary.t.sol | 16 ++++++- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol index 58d7d6f5..2c5364a3 100644 --- a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol +++ b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol @@ -11,32 +11,34 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { console2 } from "forge-std/console2.sol"; + /** * @notice This contract is forked off of the UniswapFlashswapDirectMintHandler, * with one distinction that it handles potential dust collateral amounts that - * can accrue when the contract ends up minting more collateral than originally intended. - * This situation can occur when the user has a desired leverage amount and thus an exact - * resulting collateral amount, but due to rounding errors in the minting contract, the handler - * is forced to mint a dust amount more than the desired collateral amount. - * In this contract, the dust is added to the total final deposit amount and ends up in the - * user's vault as additional collateral. + * can accrue when the contract ends up minting more collateral than originally + * intended. This situation can occur when the user has a desired leverage + * amount and thus an exact resulting collateral amount, but due to rounding + * errors in the minting contract, the handler is forced to mint a dust amount + * more than the desired collateral amount. In this contract, the dust is added + * to the total final deposit amount and ends up in the user's vault as + * additional collateral. * - * The key difference between this contract and `UniswapFlashswapDirectMintHandler1 is a - * relaxed bound in comparing the sum of initiail user deposit and additionally minted collateral - * to the caller's requested resulting additional collateral amount. + * The key difference between this contract and + * `UniswapFlashswapDirectMintHandler` is a relaxed bound in comparing the sum + * of initial user deposit and additionally minted collateral to the caller's + * requested resulting additional collateral amount. * - * This contract allows for easy creation of leverge positions through a - * Uniswap flashswap and direct mint of the collateral from the provider. This - * will be used when the collateral cannot be minted directly with the base - * asset but can be directly minted by a token that the base asset has a - * UniswapV3 pool with. + * This contract allows for easy creation of leverge positions through a Uniswap + * flashswap and direct mint of the collateral from the provider. This will be + * used when the collateral cannot be minted directly with the base asset but + * can be directly minted by a token that the base asset has a UniswapV3 pool + * with. * * This contract is to be used when there exists a UniswapV3 pool between the * base asset and the mint asset. * * @custom:security-contact security@molecularlabs.io */ - abstract contract UniswapFlashswapDirectMintHandlerWithDust is IonHandlerBase, IUniswapV3SwapCallback { using SafeERC20 for IERC20; using SafeERC20 for IWETH9; diff --git a/src/libraries/lrt/RenzoLibrary.sol b/src/libraries/lrt/RenzoLibrary.sol index 8f116eb6..7ab07f3e 100644 --- a/src/libraries/lrt/RenzoLibrary.sol +++ b/src/libraries/lrt/RenzoLibrary.sol @@ -78,7 +78,8 @@ using WadRayMath for uint256; * range for `226219` ezETH is `227528` to `455054` wei. */ library RenzoLibrary { - error InvalidAmountOut(uint256 trueAmountOut, uint256 expectedAmountOut); + error InvalidAmountOut(uint256 amountOut); + error InvalidAmountIn(uint256 amountIn); /** * @notice Returns the amount of ETH required to mint at least @@ -105,6 +106,7 @@ library RenzoLibrary { if (minAmountOut == 0) return (0, 0); (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 totalSupply = EZETH.totalSupply(); // Each `inflationPercentage` maps to a "mint amount" and, therefore, a // "mint range". We need to first calculate the `inflationPercentage` @@ -126,7 +128,7 @@ library RenzoLibrary { // to "mint range"s below themselves by substracting 1. Underflow // avoided by the check for `minAmountOut == 0` at the start of the // function. - uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1); + uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1, totalSupply); if (ethAmount == 0) return (0, 0); uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); @@ -138,7 +140,6 @@ library RenzoLibrary { // Then we go on to calculate the ezETH amount and optimal eth deposit // mapping to that `inflationPercentage`. - uint256 totalSupply = EZETH.totalSupply(); // Calculate the new supply uint256 newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); @@ -146,6 +147,19 @@ library RenzoLibrary { amountOut = newEzETHSupply - totalSupply; ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); + + // Very rarely, the `inflationPercentage` is less by one. So we try both. + if (_calculateMintAmount(totalTVL, ethAmountIn, totalSupply) >= minAmountOut) return (ethAmountIn, amountOut); + + ++inflationPercentage; + ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); + + newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); + amountOut = newEzETHSupply - totalSupply; + + if (_calculateMintAmount(totalTVL, ethAmountIn, totalSupply) >= minAmountOut) return (ethAmountIn, amountOut); + + revert InvalidAmountOut(ethAmountIn); } /** @@ -164,9 +178,18 @@ library RenzoLibrary { returns (uint256 amount, uint256 optimalAmount) { (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 totalSupply = EZETH.totalSupply(); + + amount = _calculateMintAmount(totalTVL, ethAmount, totalSupply); + optimalAmount = _calculateDepositAmount(totalTVL, amount, totalSupply); - amount = _calculateMintAmount(totalTVL, ethAmount, EZETH.totalSupply()); - optimalAmount = _calculateDepositAmount(totalTVL, amount); + // Can be off by 1 wei + if (_calculateMintAmount(totalTVL, optimalAmount, totalSupply) == amount) return (amount, optimalAmount); + if (_calculateMintAmount(totalTVL, optimalAmount + 1, totalSupply) == amount) { + return (amount, optimalAmount + 1); + } + + revert InvalidAmountOut(amount); } function depositForLrt(uint256 ethAmount) internal returns (uint256 ezEthAmountToMint) { @@ -187,20 +210,28 @@ library RenzoLibrary { * @param totalTVL Total TVL in the system. * @param amountOut Desired amount of ezETH to mint. */ - function _calculateDepositAmount(uint256 totalTVL, uint256 amountOut) private view returns (uint256) { + function _calculateDepositAmount( + uint256 totalTVL, + uint256 amountOut, + uint256 totalSupply + ) + private + pure + returns (uint256) + { if (amountOut == 0) return 0; // uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; // // Solve for newEzETHSupply - uint256 newEzEthSupply = (amountOut + EZETH.totalSupply()); + uint256 newEzEthSupply = (amountOut + totalSupply); uint256 newEzEthSupplyRay = newEzEthSupply.scaleUpToRay(18); // uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - // inflationPercentage); // // Solve for inflationPercentage - uint256 intem = EZETH.totalSupply().scaleUpToRay(18).mulDiv(RAY, newEzEthSupplyRay); + uint256 intem = totalSupply.scaleUpToRay(18).mulDiv(RAY, newEzEthSupplyRay); uint256 inflationPercentage = RAY - intem; // uint256 inflationPercentage = SCALE_FACTOR * _newValueAdded / (_currentValueInProtocol + diff --git a/test/fork/fuzz/lrt/RenzoLibrary.t.sol b/test/fork/fuzz/lrt/RenzoLibrary.t.sol index 3e98722c..a3a6d735 100644 --- a/test/fork/fuzz/lrt/RenzoLibrary.t.sol +++ b/test/fork/fuzz/lrt/RenzoLibrary.t.sol @@ -16,7 +16,19 @@ contract RenzoLibrary_FuzzTest is Test { vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); } - function testForkFuzz_GetEthAmountInForLstAmountOut(uint128 minLrtAmount) external { + function test_GetEthAmountInForLstAmountOut() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_472_376); + uint128 minLrtAmount = 184_626_086_978_191_358; + testForkFuzz_GetEthAmountInForLstAmountOut(minLrtAmount); + } + + function test_GetLstAmountOutForEthAmountIn() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_472_376); + uint128 ethAmount = 185_854_388_659_820_839; + testForkFuzz_GetLstAmountOutForEthAmountIn(ethAmount); + } + + function testForkFuzz_GetEthAmountInForLstAmountOut(uint128 minLrtAmount) public { (uint256 ethAmount, uint256 actualLrtAmount) = RenzoLibrary.getEthAmountInForLstAmountOut(minLrtAmount); assertGe(actualLrtAmount, minLrtAmount, "actualLrtAmount"); @@ -29,7 +41,7 @@ contract RenzoLibrary_FuzzTest is Test { assertEq(EZETH.balanceOf(address(this)), actualLrtAmount, "ezETH balance"); } - function testForkFuzz_GetLstAmountOutForEthAmountIn(uint128 ethAmount) external { + function testForkFuzz_GetLstAmountOutForEthAmountIn(uint128 ethAmount) public { uint256 mintAmount = _calculateMintAmount(ethAmount); // Prevent revert From a34db21adf19e0b2c3ebc096276ca6acbabb3281 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:11:44 -0400 Subject: [PATCH 09/13] docs: reword --- src/libraries/lrt/RenzoLibrary.sol | 51 ++++++++++++++++-------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/libraries/lrt/RenzoLibrary.sol b/src/libraries/lrt/RenzoLibrary.sol index 7ab07f3e..19aab1b4 100644 --- a/src/libraries/lrt/RenzoLibrary.sol +++ b/src/libraries/lrt/RenzoLibrary.sol @@ -15,7 +15,7 @@ using WadRayMath for uint256; * @notice A helper library for Renzo-related conversions. * * @dev The behaviour in minting ezETH is quite strange, so for the sake of the - * maintenence of this code, we document the behaviour at block 19387902. + * maintenance of this code, we document the behaviour at block 19387902. * * The following function is invoked to calculate the amount of ezETH to mint * given an `ethAmount` to deposit: @@ -69,8 +69,8 @@ using WadRayMath for uint256; * user should pay `227528` wei instead of any other value between `227528` and * `455054` wei. * - * We will call a mintable amount of ezETH a "mint amount" (recall that at block - * 19387902, a user cannot mint between `226219` and `452438` ezETH). So + * We will call a mintable amount of ezETH a "mintable amount" (recall that at + * block 19387902, a user cannot mint between `226219` and `452438` ezETH). So * `226219` and `452438` are mint amounts. * * We will call the range of values that produce the same amount of ezETH a @@ -87,10 +87,10 @@ library RenzoLibrary { * depositing that amount of ETH. * * @dev The goal here is to mint at least `minAmountOut` ezETH. So first, we - * must find the "mint amount" right above `minAmountOut`. This ensures that + * must find the "mintable amount" right above `minAmountOut`. This ensures that * we mint at least `minAmountOut`. Then we find the minimum amount of ETH - * required to mint that "mint amount". Essentially, we want to find the - * lower bound of the "mint range" of the "mint amount" right above + * required to mint that "mintable amount". Essentially, we want to find the + * lower bound of the "mint range" of the "mintable amount" right above * `minAmountOut`. * * @param minAmountOut Minimum amount of ezETH to mint @@ -108,33 +108,36 @@ library RenzoLibrary { (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); uint256 totalSupply = EZETH.totalSupply(); - // Each `inflationPercentage` maps to a "mint amount" and, therefore, a - // "mint range". We need to first calculate the `inflationPercentage` - // that maps to the "mint range" of the `minAmountOut`. (Technically, - // the `minAmountOut` is unlikely to have its own "mint range" since it - // probably won't be a "mint amount," but we can find the mint range of - // the "mint amount" below `minAmountOut`".) + // Each `inflationPercentage` maps to a "mintable amount" and, therefore, + // there exists a "mint range" that maps to a single + // `inflationPercentage` and a single "mint amount". + + // We need to first calculate the `inflationPercentage` that maps to the + // "mint range" of the `minAmountOut`. + + // Technically, the specified `minAmountOut` is unlikely to be mintable + // given the rounding errors. But we can find the mint range of the + // "mint amount" below `minAmountOut // // To understand the reason for using `minAmountOut - 1`, consider the // case where `minAmountOut` is a "mint amount". Continuing with the // example from block 19387902, if `minAmountOut` is `226218`, the // `inflationPercentage` below would be 0. It would then be incremented - // to 1 and then when deriving the the true `amountOut` from the - // incremented `inflationPercentage`, it would get `amountOut = 226219`. - // However, if `minAmountOut` is `226219`, the `inflationPercentage` - // below would be 1 and it would be incremented to 2. Then, true - // `amountOut` would then be `452438` which is unecessarily minting more - // when the initial "mint amount" was perfect. So we map "mint amount"s - // to "mint range"s below themselves by substracting 1. Underflow - // avoided by the check for `minAmountOut == 0` at the start of the - // function. + // to 1 and then when deriving the true `amountOut` from the incremented + // `inflationPercentage`, it would get `amountOut = 226219`. However, if + // `minAmountOut` is `226219`, the `inflationPercentage` below would be + // 1 and it would be incremented to 2. Then, true `amountOut` would then + // be `452438` which is unnecessarily minting more when the initial + // "mintable amount" was perfect. So we map "mintable amount"s to "mint + // range"s below themselves by subtracting 1. Underflow avoided by the + // check for `minAmountOut == 0` at the start of the function. uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1, totalSupply); if (ethAmount == 0) return (0, 0); uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); - // Once we have the `inflationPercentage` mapping to the "mint amount" + // Once we have the `inflationPercentage` mapping to the "mintable amount" // below `minAmountOut`, we increment it to find the - // `inflationPercentage` mapping to the "mint amount" above + // `inflationPercentage` mapping to the "mintable amount" above // `minAmountOut". ++inflationPercentage; @@ -204,7 +207,7 @@ library RenzoLibrary { * * @dev This function does NOT account for the rounding errors in the ezETH. * It simply performs the minting calculation in reverse. To use this - * function properly, `amountOut` should be a "mint amount" (an amount of + * function properly, `amountOut` should be a "mintable amount" (an amount of * ezETH that is actually possible to mint). * * @param totalTVL Total TVL in the system. From 78c9e0a524e6b230c633e789aeeb0d760d990a14 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Mon, 15 Apr 2024 02:45:59 -0400 Subject: [PATCH 10/13] fix: do not truncate in UniswapOracleLibrary --- src/libraries/uniswap/UniswapOracleLibrary.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libraries/uniswap/UniswapOracleLibrary.sol b/src/libraries/uniswap/UniswapOracleLibrary.sol index f87169ac..7a3d310b 100644 --- a/src/libraries/uniswap/UniswapOracleLibrary.sol +++ b/src/libraries/uniswap/UniswapOracleLibrary.sol @@ -34,13 +34,15 @@ library UniswapOracleLibrary { // NOTE: Changed to match versions // arithmeticMeanTick = int24(tickCumulativesDelta / secondsAgo); - arithmeticMeanTick = int24(tickCumulativesDelta) / int24(int32(secondsAgo)); + arithmeticMeanTick = int24(int256(tickCumulativesDelta) / int256(uint256(secondsAgo))); // Always round to negative infinity // NOTE: Changed to match versions // if (tickCumulativesDelta < 0 && (tickCumulativesDelta % secondsAgo != 0)) arithmeticMeanTick--; - if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(int32(secondsAgo)) != 0)) arithmeticMeanTick--; + if (tickCumulativesDelta < 0 && (int256(tickCumulativesDelta) % int256(uint256(secondsAgo)) != 0)) { + arithmeticMeanTick--; + } // We are multiplying here instead of shifting to ensure that harmonicMeanLiquidity doesn't overflow uint128 uint192 secondsAgoX160 = uint192(secondsAgo) * type(uint160).max; From b77cc131bb694575676a3c7edaee1bd44de3bfa1 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Mon, 15 Apr 2024 02:49:02 -0400 Subject: [PATCH 11/13] fix: round up input amount for deposit amount calculation in RenzoLibrary --- src/libraries/lrt/RenzoLibrary.sol | 134 ++++----- test/fork/fuzz/lrt/RenzoLibrary.t.sol | 415 +++++++++++++++++++++++++- 2 files changed, 463 insertions(+), 86 deletions(-) diff --git a/src/libraries/lrt/RenzoLibrary.sol b/src/libraries/lrt/RenzoLibrary.sol index 19aab1b4..3d5569f1 100644 --- a/src/libraries/lrt/RenzoLibrary.sol +++ b/src/libraries/lrt/RenzoLibrary.sol @@ -2,13 +2,12 @@ pragma solidity 0.8.21; import { RENZO_RESTAKE_MANAGER, EZETH } from "../../Constants.sol"; -import { WadRayMath, WAD, RAY } from "../math/WadRayMath.sol"; +import { WadRayMath, WAD } from "../math/WadRayMath.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; using Math for uint256; using WadRayMath for uint256; - /** * @title RenzoLibrary * @@ -76,10 +75,12 @@ using WadRayMath for uint256; * We will call the range of values that produce the same amount of ezETH a * "mint range". The mint range for `0` ezETH is `0` to `227527` wei and the mint * range for `226219` ezETH is `227528` to `455054` wei. + * + * @custom:security-contact security@molecularlabs.io */ + library RenzoLibrary { error InvalidAmountOut(uint256 amountOut); - error InvalidAmountIn(uint256 amountIn); /** * @notice Returns the amount of ETH required to mint at least @@ -93,6 +94,24 @@ library RenzoLibrary { * lower bound of the "mint range" of the "mintable amount" right above * `minAmountOut`. * + * There exists an edge case where `minAmountOut` is an exact "mintable amount". Continuing with the + * example from block 19387902, if `minAmountOut` is `226218`, the + * `inflationPercentage` below would be 0. It would then be incremented + * to 1 and then when deriving the true `amountOut` from the incremented + * `inflationPercentage`, it would get `amountOut = 226219`. However, if + * `minAmountOut` is `226219`, the `inflationPercentage` below would be + * 1 and it would be incremented to 2. Then, true `amountOut` would then + * be `452438` which is unnecessarily minting more when the initial + * "mintable amount" was perfect. + * + * In this case, the inflationPercentage that the + * `_calculateDepositAmount`'s `ethAmountIn` maps to may not be the most + * optimal and users may incur the cost of paying extra dust for the same + * mint amount. However, we have empirically observed via fuzzing that 90% + * of the time, the ethAmountIn calculated through this function will be the + * most optimal eth amount in, and one less the `ethAmountIn` will result in + * a mint amount out lower than the minimum. + * * @param minAmountOut Minimum amount of ezETH to mint * @return ethAmountIn Amount of ETH required to mint the desired amount of * ezETH @@ -105,63 +124,14 @@ library RenzoLibrary { { if (minAmountOut == 0) return (0, 0); - (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); - uint256 totalSupply = EZETH.totalSupply(); - - // Each `inflationPercentage` maps to a "mintable amount" and, therefore, - // there exists a "mint range" that maps to a single - // `inflationPercentage` and a single "mint amount". - - // We need to first calculate the `inflationPercentage` that maps to the - // "mint range" of the `minAmountOut`. - - // Technically, the specified `minAmountOut` is unlikely to be mintable - // given the rounding errors. But we can find the mint range of the - // "mint amount" below `minAmountOut - // - // To understand the reason for using `minAmountOut - 1`, consider the - // case where `minAmountOut` is a "mint amount". Continuing with the - // example from block 19387902, if `minAmountOut` is `226218`, the - // `inflationPercentage` below would be 0. It would then be incremented - // to 1 and then when deriving the true `amountOut` from the incremented - // `inflationPercentage`, it would get `amountOut = 226219`. However, if - // `minAmountOut` is `226219`, the `inflationPercentage` below would be - // 1 and it would be incremented to 2. Then, true `amountOut` would then - // be `452438` which is unnecessarily minting more when the initial - // "mintable amount" was perfect. So we map "mintable amount"s to "mint - // range"s below themselves by subtracting 1. Underflow avoided by the - // check for `minAmountOut == 0` at the start of the function. - uint256 ethAmount = _calculateDepositAmount(totalTVL, minAmountOut - 1, totalSupply); - if (ethAmount == 0) return (0, 0); - uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); - - // Once we have the `inflationPercentage` mapping to the "mintable amount" - // below `minAmountOut`, we increment it to find the - // `inflationPercentage` mapping to the "mintable amount" above - // `minAmountOut". - ++inflationPercentage; + (,, uint256 _currentValueInProtocol) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + uint256 _existingEzETHSupply = EZETH.totalSupply(); - // Then we go on to calculate the ezETH amount and optimal eth deposit - // mapping to that `inflationPercentage`. - - // Calculate the new supply - uint256 newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); - - amountOut = newEzETHSupply - totalSupply; - - ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); - - // Very rarely, the `inflationPercentage` is less by one. So we try both. - if (_calculateMintAmount(totalTVL, ethAmountIn, totalSupply) >= minAmountOut) return (ethAmountIn, amountOut); - - ++inflationPercentage; - ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); - - newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); - amountOut = newEzETHSupply - totalSupply; - - if (_calculateMintAmount(totalTVL, ethAmountIn, totalSupply) >= minAmountOut) return (ethAmountIn, amountOut); + ethAmountIn = _calculateDepositAmount(_currentValueInProtocol, _existingEzETHSupply, minAmountOut); + if (ethAmountIn == 0) return (0, 0); + amountOut = _calculateMintAmount(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn); + if (amountOut >= minAmountOut) return (ethAmountIn, amountOut); revert InvalidAmountOut(ethAmountIn); } @@ -183,12 +153,12 @@ library RenzoLibrary { (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); uint256 totalSupply = EZETH.totalSupply(); - amount = _calculateMintAmount(totalTVL, ethAmount, totalSupply); - optimalAmount = _calculateDepositAmount(totalTVL, amount, totalSupply); + amount = _calculateMintAmount(totalTVL, totalSupply, ethAmount); + optimalAmount = _calculateDepositAmount(totalTVL, totalSupply, amount); // Can be off by 1 wei - if (_calculateMintAmount(totalTVL, optimalAmount, totalSupply) == amount) return (amount, optimalAmount); - if (_calculateMintAmount(totalTVL, optimalAmount + 1, totalSupply) == amount) { + if (_calculateMintAmount(totalTVL, totalSupply, optimalAmount) == amount) return (amount, optimalAmount); + if (_calculateMintAmount(totalTVL, totalSupply, optimalAmount + 1) == amount) { return (amount, optimalAmount + 1); } @@ -198,7 +168,7 @@ library RenzoLibrary { function depositForLrt(uint256 ethAmount) internal returns (uint256 ezEthAmountToMint) { (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); - ezEthAmountToMint = _calculateMintAmount(totalTVL, ethAmount, EZETH.totalSupply()); + ezEthAmountToMint = _calculateMintAmount(totalTVL, EZETH.totalSupply(), ethAmount); RENZO_RESTAKE_MANAGER.depositETH{ value: ethAmount }(0); } @@ -210,13 +180,14 @@ library RenzoLibrary { * function properly, `amountOut` should be a "mintable amount" (an amount of * ezETH that is actually possible to mint). * - * @param totalTVL Total TVL in the system. + * @param _currentValueInProtocol Total TVL in the system. + * @param _existingEzETHSupply Total supply of ezETH. * @param amountOut Desired amount of ezETH to mint. */ function _calculateDepositAmount( - uint256 totalTVL, - uint256 amountOut, - uint256 totalSupply + uint256 _currentValueInProtocol, + uint256 _existingEzETHSupply, + uint256 amountOut ) private pure @@ -227,28 +198,29 @@ library RenzoLibrary { // uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; // // Solve for newEzETHSupply - uint256 newEzEthSupply = (amountOut + totalSupply); - uint256 newEzEthSupplyRay = newEzEthSupply.scaleUpToRay(18); + uint256 newEzEthSupply = (amountOut + _existingEzETHSupply); // uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - // inflationPercentage); // // Solve for inflationPercentage - uint256 intem = totalSupply.scaleUpToRay(18).mulDiv(RAY, newEzEthSupplyRay); - uint256 inflationPercentage = RAY - intem; + uint256 inflationPercentage = WAD - WAD.mulDiv(_existingEzETHSupply, newEzEthSupply); // uint256 inflationPercentage = SCALE_FACTOR * _newValueAdded / (_currentValueInProtocol + // _newValueAdded); // // Solve for _newValueAdded - uint256 ethAmountRay = inflationPercentage.mulDiv(totalTVL.scaleUpToRay(18), RAY - inflationPercentage); - - // Truncate from RAY to WAD with roundingUp plus one extra - // The one extra to get into the next mint range - uint256 ethAmount = ethAmountRay / 1e9 + 1; - if (ethAmountRay % 1e9 != 0) ++ethAmount; - - return ethAmount; + // NOTE This equation is intentionally rounded up. This is because if + // the division truncates and value is lost, the `ethAmountIn` in the + // forward compute will output less than the minimum `amountOut`. We + // round up to guarantee that the users will always only pay the minimum + // necessary amount for either the exact minimum amount out or the next + // closest possible mintable amount if the minimum amount out is not a + // mintable value. + uint256 ethAmountIn = + inflationPercentage.mulDiv(_currentValueInProtocol, WAD - inflationPercentage, Math.Rounding.Ceil); + + return ethAmountIn; } /** @@ -264,8 +236,8 @@ library RenzoLibrary { */ function _calculateMintAmount( uint256 _currentValueInProtocol, - uint256 _newValueAdded, - uint256 _existingEzETHSupply + uint256 _existingEzETHSupply, + uint256 _newValueAdded ) private pure diff --git a/test/fork/fuzz/lrt/RenzoLibrary.t.sol b/test/fork/fuzz/lrt/RenzoLibrary.t.sol index a3a6d735..32608412 100644 --- a/test/fork/fuzz/lrt/RenzoLibrary.t.sol +++ b/test/fork/fuzz/lrt/RenzoLibrary.t.sol @@ -1,29 +1,412 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.21; +import { WAD, RAY, WadRayMath } from "./../../../../src/libraries/math/WadRayMath.sol"; import { RenzoLibrary } from "../../../../src/libraries/lrt/RenzoLibrary.sol"; import { EZETH, RENZO_RESTAKE_MANAGER } from "../../../../src/Constants.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Test } from "forge-std/Test.sol"; +import { console2 } from "forge-std/console2.sol"; -import { safeconsole as console } from "forge-std/safeconsole.sol"; +using Math for uint256; +using WadRayMath for uint256; uint256 constant SCALE_FACTOR = 1e18; -contract RenzoLibrary_FuzzTest is Test { +/** + * Used for comparing different backcompute methods. + */ +library OldRenzoLibrary { + error InvalidAmountOut(uint256 amountOut); + error InvalidAmountIn(uint256 amountIn); + + function mockCalculateMintAmount( + uint256 _currentValueInProtocol, + uint256 _existingEzETHSupply, + uint256 _newValueAdded + ) + public + pure + returns (uint256) + { + // For first mint, just return the new value added. + // Checking both current value and existing supply to guard against gaming the initial mint + if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) { + return _newValueAdded; // value is priced in base units, so divide by scale factor + } + + // Calculate the percentage of value after the deposit + uint256 inflationPercentage = WAD * _newValueAdded / (_currentValueInProtocol + _newValueAdded); + + // Calculate the new supply + uint256 newEzETHSupply = (_existingEzETHSupply * WAD) / (WAD - inflationPercentage); + + // Subtract the old supply from the new supply to get the amount to mint + uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; + + return mintAmount; + } + + function mockCalculateDepositAmount( + uint256 totalTVL, + uint256 totalSupply, + uint256 amountOut + ) + public + pure + returns (uint256) + { + if (amountOut == 0) return 0; + + // uint256 mintAmount = newEzETHSupply - _existingEzETHSupply; + // + // Solve for newEzETHSupply + uint256 newEzEthSupply = (amountOut + totalSupply); + console2.log("newEzEthSupply: ", newEzEthSupply); + uint256 newEzEthSupplyRay = newEzEthSupply.scaleUpToRay(18); + + // uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - + // inflationPercentage); + // + // Solve for inflationPercentage + uint256 intem = totalSupply.scaleUpToRay(18).mulDiv(RAY, newEzEthSupplyRay); + uint256 inflationPercentage = RAY - intem; + + // uint256 inflationPercentage = SCALE_FACTOR * _newValueAdded / (_currentValueInProtocol + + // _newValueAdded); + // + // Solve for _newValueAdded + uint256 ethAmountRay = inflationPercentage.mulDiv(totalTVL.scaleUpToRay(18), RAY - inflationPercentage); + + // Truncate from RAY to WAD with roundingUp plus one extra + // The one extra to get into the next mint range + uint256 ethAmount = ethAmountRay / 1e9 + 1; + if (ethAmountRay % 1e9 != 0) ++ethAmount; + + return ethAmount; + } + + // current math but with configurable totalTVL and totalSupply + function mockGetEthAmountInForLstAmountOut( + uint256 totalTVL, + uint256 totalSupply, + uint256 minAmountOut + ) + public + view + returns (uint256 ethAmountIn, uint256 amountOut) + { + if (minAmountOut == 0) return (0, 0); + + // passed in as params + // (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); + // uint256 totalSupply = EZETH.totalSupply(); + + uint256 ethAmount = mockCalculateDepositAmount(totalTVL, totalSupply, minAmountOut - 1); + console2.log("first calculated ethAmount: ", ethAmount); + if (ethAmount == 0) return (0, 0); + uint256 inflationPercentage = WAD * ethAmount / (totalTVL + ethAmount); + + // Once we have the `inflationPercentage` mapping to the "mintable amount" + // below `minAmountOut`, we increment it to find the + // `inflationPercentage` mapping to the "mintable amount" above + // `minAmountOut". + ++inflationPercentage; + + // Then we go on to calculate the ezETH amount and optimal eth deposit + // mapping to that `inflationPercentage`. + + // Calculate the new supply + uint256 newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); + + amountOut = newEzETHSupply - totalSupply; + + ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); + console2.log("second calculated ethAmountIn: ", ethAmountIn); + // Very rarely, the `inflationPercentage` is less by one. So we try both. + if (mockCalculateMintAmount(totalTVL, totalSupply, ethAmountIn) >= minAmountOut) { + return (ethAmountIn, amountOut); + } + + ++inflationPercentage; + ethAmountIn = inflationPercentage.mulDiv(totalTVL, WAD - inflationPercentage, Math.Rounding.Ceil); + + newEzETHSupply = (totalSupply * WAD) / (WAD - inflationPercentage); + amountOut = newEzETHSupply - totalSupply; + + if (mockCalculateMintAmount(totalTVL, totalSupply, ethAmountIn) >= minAmountOut) { + return (ethAmountIn, amountOut); + } + + revert InvalidAmountOut(ethAmountIn); + } +} + +contract RenzoLibraryHelper { + /** + * Copy of the private _calculateMintAmount function in RenzoLibrary.sol + */ + function forwardCompute( + uint256 _existingEzETHSupply, + uint256 _currentValueInProtocol, + uint256 ethAmountIn + ) + internal + pure + returns (uint256 mintAmount) + { + uint256 inflationPercentage = SCALE_FACTOR * ethAmountIn / (_currentValueInProtocol + ethAmountIn); + uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - inflationPercentage); + mintAmount = newEzETHSupply - _existingEzETHSupply; + } + + /** + * Copy of the private _calculateDepositAmount function in RenzoLibrary.sol + */ + function backCompute( + uint256 _existingEzETHSupply, + uint256 _currentValueInProtocol, + uint256 mintAmount + ) + internal + pure + returns (uint256) + { + if (mintAmount == 0) return 0; + + uint256 newEzETHSupply = mintAmount + _existingEzETHSupply; + + uint256 inflationPercentage = SCALE_FACTOR - ((SCALE_FACTOR * _existingEzETHSupply) / newEzETHSupply); + + uint256 ethAmountIn = inflationPercentage * _currentValueInProtocol / (SCALE_FACTOR - inflationPercentage); + + if (inflationPercentage * _currentValueInProtocol % (SCALE_FACTOR - inflationPercentage) != 0) { + ethAmountIn++; + } + + return ethAmountIn; + } +} + +contract RenzoLibrary_Comparison_FuzzTest is RenzoLibraryHelper, Test { + function setUp() public { } + + /** + * Compare which method has a lower dust bound. + * Compare which method has a lower ethAmountIn. + * Old method dust is always greater than the new method dust. + * Out of 10000 runs, + * - 9576 runs have equal ethAmountIn. + * - 420 runs have old method ethAmountIn greater than new method ethAmountIn. + * - 2 runs have old method ethAmountIn less than new method ethAmountIn. + */ + function testFuzz_BackComputeComparison( + uint256 _existingEzETHSupply, + uint128 minMintAmount, + uint256 exchangeRate + ) + public + { + uint256 maxExistingEzETHSupply = 120_000_000e18; + + _existingEzETHSupply = bound(_existingEzETHSupply, 1e18, maxExistingEzETHSupply); + + exchangeRate = bound(exchangeRate, 1e18, 3e18); + + uint256 _currentValueInProtocol = _existingEzETHSupply * exchangeRate / 1e18; // backed 1:1 + + minMintAmount = uint128(bound(uint256(minMintAmount), 1e9, _existingEzETHSupply)); + + (uint256 oldMethodEthAmountIn, uint256 actualLrtAmount) = OldRenzoLibrary.mockGetEthAmountInForLstAmountOut( + _currentValueInProtocol, _existingEzETHSupply, minMintAmount + ); + + uint256 newMethodEthAmountIn = backCompute(_existingEzETHSupply, _currentValueInProtocol, minMintAmount); + + uint256 oldMethodActualMintAmountOut = + forwardCompute(_existingEzETHSupply, _currentValueInProtocol, oldMethodEthAmountIn); + uint256 newMethodActualMintAmountOut = + forwardCompute(_existingEzETHSupply, _currentValueInProtocol, newMethodEthAmountIn); + + vm.assume(oldMethodActualMintAmountOut != 0); + vm.assume(newMethodActualMintAmountOut != 0); + + assertGe( + oldMethodActualMintAmountOut, + newMethodActualMintAmountOut, + "old method mint amount out is greater than or equal to new method mint amount out" + ); + + assertApproxEqAbs(oldMethodEthAmountIn, newMethodEthAmountIn, 1e8, "eth amount in approx eq"); + + uint256 oldMethodDust = oldMethodActualMintAmountOut - minMintAmount; + uint256 newMethodDust = newMethodActualMintAmountOut - minMintAmount; + + assertGe(oldMethodDust, newMethodDust, "old method dust is greater than or equal to new method dust"); + assertLe(oldMethodDust - newMethodDust, 1e9, "dust differential bound"); + + assertLe(newMethodDust, 1e9, "gwei bound"); // depends heavily on `maxExistingEzETHSupply` + } +} + +contract RenzoLibrary_FuzzTest is RenzoLibraryHelper, Test { + /** + * -- Observations --- + * Max _existingEzETHSupply 10Me18 ($30B) + * _currentValueInProtocol = _existingEzETHSupply + * Max mintAmount _existingEzETHSupply / 2 + * New Method: 1.75e7 fail, 1e8 pass + * Old Method: 1.75e7 fail, 1e8 pass + * + * Max _existingEzETHSupply 10Me19 (26 zeroes) + * 1e8 fails, 1e9 passes (9 zeroes) + * + * Max _existingEzETHSupply 10Me20 (27 zeroes) + * 1e9 fails, 1e10 passes (10 zeroes) + * + * Max _existingEzETHSupply 10Me21 + * 1e10 fails, 1e11 passes + * + * Max _existingEzETHSuppply 10Me27 + * 1e16 fails, 1e17 passes + * + * No proof, but max dust goes up 10x as the _existingEzETHSupply goes up 10x. + * + * Q: What happens when the Max _existingEzETHSupply increases? + * A: With all else equal, the dust increases. + * + * Q: What happens when the minMintAmount relative to _existingEzETHSupply increases? + * A: With all else equal, if there is no bound, or if the bound is 100x the existing supply, dust increases + * + * Q: What happens when the exchangeRate between ezETH and underlying increases? + * A: Even with very large exchange rates such as 10e18, the dust bound is not affected. + */ + function testFuzz_BackComputeDustBound(uint256 _existingEzETHSupply, uint128 minMintAmount) public { + uint256 maxExistingEzETHSupply = 10_000_000e21; + _existingEzETHSupply = bound(_existingEzETHSupply, 1, maxExistingEzETHSupply); + + uint256 _currentValueInProtocol = _existingEzETHSupply * 1.2e18 / 1e18; // backed 1.2:1 + + minMintAmount = uint128(bound(uint256(minMintAmount), 0, _existingEzETHSupply / 2)); + + uint256 ethAmountIn = backCompute(_currentValueInProtocol, _existingEzETHSupply, minMintAmount); + uint256 actualMintAmountOut = forwardCompute(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn); + + vm.assume(actualMintAmountOut != 0); + assertGe(actualMintAmountOut, minMintAmount, "amount out comparison"); + + assertLe(actualMintAmountOut - minMintAmount, 1e11, "gwei bound"); + } + + /** + * _existingEzETHSupply / 10**17 bound passes with: + * - _existingEzETHSupply [1e18, type(uint128).max] + * - exchangeRate [1e18, 8e18] + * - minMintAmount [0, _existingEzETHSupply] + */ + function testFuzz_BackComputeFormulaicDustBound( + uint256 _existingEzETHSupply, + uint128 minMintAmount, + uint128 exchangeRate + ) + public + { + _existingEzETHSupply = bound(_existingEzETHSupply, WAD, type(uint128).max); + + exchangeRate = uint128(bound(exchangeRate, 1e18, 8e18)); // 9e18 fails + + uint256 _currentValueInProtocol = _existingEzETHSupply * exchangeRate / 1e18; + + minMintAmount = uint128(bound(uint256(minMintAmount), 0, _existingEzETHSupply)); + + uint256 ethAmountIn = backCompute(_currentValueInProtocol, _existingEzETHSupply, minMintAmount); + uint256 actualMintAmountOut = forwardCompute(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn); + + uint256 dustBound = _existingEzETHSupply / 10 ** 17; + + vm.assume(actualMintAmountOut != 0); + + assertGe(actualMintAmountOut, minMintAmount, "amount out comparison"); + + assertLe(actualMintAmountOut - minMintAmount, dustBound, "exact dust bound"); + } + + function testFuzz_BackComputeRealisticDustBound( + uint256 _existingEzETHSupply, + uint128 minMintAmount, + uint128 exchangeRate + ) + public + { + // There are 120M circulating ETH as of 4/9/2024. + _existingEzETHSupply = bound(_existingEzETHSupply, WAD, 120_000_000e18); + // realistically, the exchangeRate will not more than triple + exchangeRate = uint128(bound(exchangeRate, 1e18, 3e18)); + // realistically, a single mint would not be double the entire supply + minMintAmount = uint128(bound(uint256(minMintAmount), 0, _existingEzETHSupply * 2)); + + uint256 _currentValueInProtocol = _existingEzETHSupply * exchangeRate / 1e18; + + uint256 ethAmountIn = backCompute(_currentValueInProtocol, _existingEzETHSupply, minMintAmount); + uint256 actualMintAmountOut = forwardCompute(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn); + + uint256 dustBound = 1e10; + + vm.assume(actualMintAmountOut != 0); + + assertGe(actualMintAmountOut, minMintAmount, "amount out comparison"); + assertLe(actualMintAmountOut - minMintAmount, dustBound, "exact dust bound"); + } + + function testFuzz_BackComputeResultIsOptimal( + uint256 _existingEzETHSupply, + uint128 minMintAmount, + uint128 exchangeRate + ) + public + { + uint256 maxExistingEzETHSupply = 10_000_000e18; + + _existingEzETHSupply = bound(_existingEzETHSupply, 1, maxExistingEzETHSupply); + exchangeRate = uint128(bound(exchangeRate, 1e18, 2e18)); + minMintAmount = uint128(bound(uint256(minMintAmount), 0, _existingEzETHSupply)); + + uint256 _currentValueInProtocol = _existingEzETHSupply * exchangeRate / 1e18; + + uint256 ethAmountIn = backCompute(_currentValueInProtocol, _existingEzETHSupply, minMintAmount); + uint256 actualMintAmountOut = forwardCompute(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn); + + vm.assume(ethAmountIn != 0); + + // try minting with one less + uint256 mintAmountOutWithOneLess = + forwardCompute(_currentValueInProtocol, _existingEzETHSupply, ethAmountIn - 1); + + assertLe(mintAmountOutWithOneLess, minMintAmount, "one less eth amount comopared to min mint amount"); + assertLe(mintAmountOutWithOneLess, actualMintAmountOut, "one less eth amount compared to actual mint amount"); + + vm.assume(actualMintAmountOut != 0); + assertGe(actualMintAmountOut, minMintAmount, "amount out comparison"); + + assertLe(actualMintAmountOut - minMintAmount, 1e11, "gwei bound"); + } +} + +contract RenzoLibrary_ForkFuzzTest is RenzoLibraryHelper, Test { function setUp() public { // vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19387902); - vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); + vm.createSelectFork(vm.envString("MAINNET_ARCHIVE_RPC_URL")); } function test_GetEthAmountInForLstAmountOut() public { - vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_472_376); + vm.createSelectFork(vm.envString("MAINNET_ARCHIVE_RPC_URL"), 19_472_376); uint128 minLrtAmount = 184_626_086_978_191_358; testForkFuzz_GetEthAmountInForLstAmountOut(minLrtAmount); } function test_GetLstAmountOutForEthAmountIn() public { - vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_472_376); + vm.createSelectFork(vm.envString("MAINNET_ARCHIVE_RPC_URL"), 19_472_376); uint128 ethAmount = 185_854_388_659_820_839; testForkFuzz_GetLstAmountOutForEthAmountIn(ethAmount); } @@ -41,6 +424,19 @@ contract RenzoLibrary_FuzzTest is Test { assertEq(EZETH.balanceOf(address(this)), actualLrtAmount, "ezETH balance"); } + function testForkFuzz_GetEthAmountInForLstAmountOutWithDustBound(uint128 minLrtAmount) public { + uint256 _existingEzETHSupply = EZETH.totalSupply(); + minLrtAmount = uint128(bound(uint256(minLrtAmount), 1, _existingEzETHSupply)); + + (uint256 ethAmount, uint256 actualLrtAmount) = RenzoLibrary.getEthAmountInForLstAmountOut(minLrtAmount); + assertGe(actualLrtAmount, minLrtAmount, "actualLrtAmount"); + + uint256 mintAmount = _calculateMintAmount(ethAmount); + vm.assume(mintAmount != 0); + + assertLe(mintAmount - minLrtAmount, 1e8, "hard coded dust bound"); + } + function testForkFuzz_GetLstAmountOutForEthAmountIn(uint128 ethAmount) public { uint256 mintAmount = _calculateMintAmount(ethAmount); @@ -60,6 +456,15 @@ contract RenzoLibrary_FuzzTest is Test { assertEq(EZETH.balanceOf(address(this)), lrtAmountOut); } + /** + * The optimal amount outputted from this function should always be less + * than the original input amount. + */ + function testForkFuzz_GetLstAmountOutForEthAmountInOptimalAmount(uint128 ethAmount) public { + (uint256 amount, uint256 optimalAmount) = RenzoLibrary.getLstAmountOutForEthAmountIn(ethAmount); + assertLe(optimalAmount, ethAmount, "optimalAmount"); + } + function _calculateMintAmount(uint256 ethAmount) internal view returns (uint256 mintAmount) { (,, uint256 totalTVL) = RENZO_RESTAKE_MANAGER.calculateTVLs(); uint256 inflationPercentage = SCALE_FACTOR * ethAmount / (totalTVL + ethAmount); From 8d97951f32544d5e9eff365f2c72a6dc01fc14c5 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Mon, 15 Apr 2024 02:54:36 -0400 Subject: [PATCH 12/13] fix: OZ L-01 and informationals L-01 incomplete docstrings N-01 no need for unchecked block N-02 remove unused errors N-04 security contact N-06 typo --- src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol | 4 ++-- src/flash/lrt/EzEthHandler.sol | 2 +- src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol | 3 +++ .../UniswapFlashswapDirectMintHandlerWithDust.t.sol | 5 +---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol index 2c5364a3..9742271b 100644 --- a/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol +++ b/src/flash/UniswapFlashswapDirectMintHandlerWithDust.sol @@ -10,7 +10,6 @@ import { IUniswapV3SwapCallback } from "@uniswap/v3-core/contracts/interfaces/ca import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { console2 } from "forge-std/console2.sol"; /** * @notice This contract is forked off of the UniswapFlashswapDirectMintHandler, @@ -28,7 +27,7 @@ import { console2 } from "forge-std/console2.sol"; * of initial user deposit and additionally minted collateral to the caller's * requested resulting additional collateral amount. * - * This contract allows for easy creation of leverge positions through a Uniswap + * This contract allows for easy creation of leverage positions through a Uniswap * flashswap and direct mint of the collateral from the provider. This will be * used when the collateral cannot be minted directly with the base asset but * can be directly minted by a token that the base asset has a UniswapV3 pool @@ -94,6 +93,7 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust is IonHandlerBase, I * @param initialDeposit in collateral terms. [WAD] * @param resultingAdditionalCollateral in collateral terms. [WAD] * @param maxResultingDebt in base asset terms. [WAD] + * @param deadline The unix timestamp after which the uniswap transaction reverts. * @param proof used to validate the user is whitelisted. */ function flashswapAndMint( diff --git a/src/flash/lrt/EzEthHandler.sol b/src/flash/lrt/EzEthHandler.sol index 44656d9b..7dc0596a 100644 --- a/src/flash/lrt/EzEthHandler.sol +++ b/src/flash/lrt/EzEthHandler.sol @@ -7,7 +7,7 @@ import { Whitelist } from "../../Whitelist.sol"; import { UniswapFlashswapDirectMintHandlerWithDust } from "./../UniswapFlashswapDirectMintHandlerWithDust.sol"; import { IonHandlerBase } from "../IonHandlerBase.sol"; import { RenzoLibrary } from "./../../libraries/lrt/RenzoLibrary.sol"; -import { EZETH, WETH_ADDRESS } from "../../Constants.sol"; +import { WETH_ADDRESS } from "../../Constants.sol"; import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; diff --git a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol index e55c0170..3fef6882 100644 --- a/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol +++ b/src/oracles/spot/lrt/EzEthWstEthSpotOracle.sol @@ -41,6 +41,9 @@ contract EzEthWstEthSpotOracle is SpotOracle { /** * @notice Gets the price of ezETH in wstETH * (ETH / ezETH) / (ETH / stETH) * (wstETH / stETH) = wstETH / ezETH + * @dev Redstone oracle returns ETH per ezETH with 8 decimals. This needs to + * be converted to wstETH per ezETH denomination. + * @return wstEthPerWeEth price of ezETH in wstETH. [WAD] */ function getPrice() public view override returns (uint256) { // ETH / ezETH [8 decimals] diff --git a/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol index c6692650..05f7ff45 100644 --- a/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol +++ b/test/fork/fuzz/handlers-base/UniswapFlashswapDirectMintHandlerWithDust.t.sol @@ -10,8 +10,6 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; using WadRayMath for uint256; -import { console2 } from "forge-std/console2.sol"; - struct Config { uint256 initialDepositLowerBound; } @@ -48,8 +46,7 @@ abstract contract UniswapFlashswapDirectMintHandlerWithDust_FuzzTest is LrtHandl uint256 roundingError = currentRate / RAY; if (currentRate % RAY != 0) roundingError++; - // TODO: Can this dust amount be bounded at run-time? - uint256 maxDust = 400_000; + uint256 maxDust = 1e9; assertLt( ionPool.collateral(_getIlkIndex(), address(this)), From 2f48bc5210dab95b9c929ac10feec69121ba533e Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Fri, 19 Apr 2024 17:35:34 -0400 Subject: [PATCH 13/13] chore: add security contact --- src/libraries/lrt/KelpDaoLibrary.sol | 2 ++ src/periphery/IonInvariants.sol | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/libraries/lrt/KelpDaoLibrary.sol b/src/libraries/lrt/KelpDaoLibrary.sol index 7e0c08a0..40f9f933 100644 --- a/src/libraries/lrt/KelpDaoLibrary.sol +++ b/src/libraries/lrt/KelpDaoLibrary.sol @@ -14,6 +14,8 @@ using Math for uint256; * @title KelpDaoLibrary * * @notice A helper library for KelpDao-related conversions. + * + * @custom:security-contact security@molecularlabs.io */ library KelpDaoLibrary { /** diff --git a/src/periphery/IonInvariants.sol b/src/periphery/IonInvariants.sol index 98738390..f2a996fa 100644 --- a/src/periphery/IonInvariants.sol +++ b/src/periphery/IonInvariants.sol @@ -7,6 +7,8 @@ import { WadRayMath } from "../libraries/math/WadRayMath.sol"; /** * @notice This contract will be deployed on mainnet and be used to check the * invariants of the Ion system offchain every block. + * + * @custom:security-contact security@molecularlabs.io */ contract IonInvariants { using WadRayMath for uint256;