diff --git a/operations/20241018_upgrade_liquifier.json b/operations/20241018_upgrade_liquifier.json new file mode 100644 index 00000000..fe0dedf6 --- /dev/null +++ b/operations/20241018_upgrade_liquifier.json @@ -0,0 +1,13 @@ +{ "version": "1.0", "chainId": "1", "meta": { "name": "Transactions Batch", "description": "", "txBuilderVersion": "1.16.5", "createdFromSafeAddress": "0xcdd57D11476c22d265722F68390b036f3DA48c21" }, "transactions": [ +{ + "to": "0x9f26d4C958fD811A1F59B01B86Be7dFFc9d20761", + "value": "0", + "data": "0x01d5062a0000000000000000000000009ffdf407cde9a93c47611799da23924af3ef764f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f48000000000000000000000000000000000000000000000000000000000000000243659cfe60000000000000000000000005769ff35545b0bbfa27cc97c9407c5ed9d39545500000000000000000000000000000000000000000000000000000000" +} +, +{ + "to": "0x9f26d4C958fD811A1F59B01B86Be7dFFc9d20761", + "value": "0", + "data": "0x134008d30000000000000000000000009ffdf407cde9a93c47611799da23924af3ef764f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000243659cfe60000000000000000000000005769ff35545b0bbfa27cc97c9407c5ed9d39545500000000000000000000000000000000000000000000000000000000" +} +] } diff --git a/proposals/references/Liquifier_ pricing of stETH via CurvePool & fixed rate fee.md b/proposals/references/Liquifier_ pricing of stETH via CurvePool & fixed rate fee.md new file mode 100644 index 00000000..5aed39ec --- /dev/null +++ b/proposals/references/Liquifier_ pricing of stETH via CurvePool & fixed rate fee.md @@ -0,0 +1,41 @@ +--- +title: 'Liquifier: pricing of stETH via CurvePool & fixed rate fee' + +--- + +# Liquifier: pricing of stETH via CurvePool & fixed rate fee + +**PR**: https://github.com/etherfi-protocol/smart-contracts/pull/188 + +## Summary + +This PR adds the option to price `stETH` via a `ETH/stETH` Curve pool. Additionally, it adds the option of applying a fixed rate fee in the `depositWithERC20(...)` function. + +--- + +## Findings + +### [Medium] Spot prices from Curve can be manipulated + +**File(s)**: [`Liquifier.sol`](https://github.com/etherfi-protocol/smart-contracts/blob/1f95dcd0677f7ffa387e70c2240981c478a701b2/src/Liquifier.sol#L404) + +**Description**: The use of `CurvePool` as quoter has the goal of `removing the ability to swap stEth/eETH 1:1 without slippage`. To get the price from the `CurvePool` the `get_dy(...)` function is used. + +```solidity +... +if (_token == address(lido)) { + if (quoteStEthWithCurve) { + return _min(_amount, ICurvePoolQuoter1(address(stEth_Eth_Pool)).get_dy(1, 0, _amount)); + } else { + return _amount; /// 1:1 from stETH to eETH + } +... +``` + +The `get_dy(...)` function returns the result of swapping `amount` of tokens at the current state of the pool. The result of this function can be easily manipulated by swapping in the `CurvePool`. The returned value could be manipulated to still enforce the use of a `1:1` rate. + +**Recommendation(s)**: Consider using a different method to quote the `stEth` that is not easily manipulable. The use of other oracle solutions like `TWAPs` or `Chainlink Oracles` is recommended. + +**Status**: Unresolved + +**Update from the client**: \ No newline at end of file diff --git a/src/Liquifier.sol b/src/Liquifier.sol index 89df6a1b..191cfe70 100644 --- a/src/Liquifier.sol +++ b/src/Liquifier.sol @@ -53,7 +53,7 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab uint32 public DEPRECATED_eigenLayerWithdrawalClaimGasCost; uint32 public timeBoundCapRefreshInterval; // seconds - bool public DEPRECATED_quoteStEthWithCurve; + bool public quoteStEthWithCurve; uint128 public DEPRECATED_accumulatedFee; @@ -101,6 +101,7 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab error NotRegistered(); error WrongOutput(); error IncorrectCaller(); + error IncorrectAmount(); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -132,20 +133,6 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab DEPRECATED_eigenLayerWithdrawalClaimGasCost = 150_000; } - function initializeOnUpgrade(address _eigenLayerDelegationManager, address _pancakeRouter) external onlyOwner { - // Disable the deposits on {cbETH, wBETH} - updateDepositCap(address(cbEth), 0, 0); - updateDepositCap(address(wbEth), 0, 0); - - pancakeRouter = IPancackeV3SwapRouter(_pancakeRouter); - eigenLayerDelegationManager = IDelegationManager(_eigenLayerDelegationManager); - } - - function initializeL1SyncPool(address _l1SyncPool) external onlyOwner { - if (l1SyncPool != address(0)) revert(); - l1SyncPool = _l1SyncPool; - } - receive() external payable {} /// the users mint eETH given the queued withdrawal for their LRT with withdrawer == address(this) @@ -181,11 +168,8 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab // The L1SyncPool's `_anticipatedDeposit` should be the only place to mint the `token` and always send its entirety to the Liquifier contract if(tokenInfos[_token].isL2Eth) _L2SanityChecks(_token); - - uint256 dx = quoteByMarketValue(_token, _amount); - - // discount - dx = (10000 - tokenInfos[_token].discountInBasisPoints) * dx / 10000; + + uint256 dx = quoteByDiscountedValue(_token, _amount); require(!isDepositCapReached(_token, dx), "CAPPED"); uint256 eEthShare = liquidityPool.depositToRecipient(msg.sender, dx, _referral); @@ -227,7 +211,8 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab } function stEthRequestWithdrawal(uint256 _amount) public onlyAdmin returns (uint256[] memory) { - if (_amount < lidoWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT() || _amount < lido.balanceOf(address(this))) revert NotEnoughBalance(); + if (_amount < lidoWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT()) revert IncorrectAmount(); + if (_amount > lido.balanceOf(address(this))) revert NotEnoughBalance(); tokenInfos[address(lido)].ethAmountPendingForWithdrawals += uint128(_amount); @@ -305,6 +290,14 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab pausers[_address] = _isPauser; } + function updateDiscountInBasisPoints(address _token, uint16 _discountInBasisPoints) external onlyAdmin { + tokenInfos[_token].discountInBasisPoints = _discountInBasisPoints; + } + + function updateQuoteStEthWithCurve(bool _quoteStEthWithCurve) external onlyAdmin { + quoteStEthWithCurve = _quoteStEthWithCurve; + } + //Pauses the contract function pauseContract() external onlyPauser { _pause(); @@ -407,7 +400,11 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab if (!isTokenWhitelisted(_token)) revert NotSupportedToken(); if (_token == address(lido)) { - return _amount; /// 1:1 from stETH to eETH + if (quoteStEthWithCurve) { + return _min(_amount, ICurvePoolQuoter1(address(stEth_Eth_Pool)).get_dy(1, 0, _amount)); + } else { + return _amount; /// 1:1 from stETH to eETH + } } else if (_token == address(cbEth)) { return _min(_amount * cbEth.exchangeRate() / 1e18, ICurvePoolQuoter2(address(cbEth_Eth_Pool)).get_dy(1, 0, _amount)); } else if (_token == address(wbEth)) { @@ -420,6 +417,13 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab revert NotSupportedToken(); } + // Calculates the amount of eETH that will be minted for a given token considering the discount rate + function quoteByDiscountedValue(address _token, uint256 _amount) public view returns (uint256) { + uint256 marketValue = quoteByMarketValue(_token, _amount); + + return (10000 - tokenInfos[_token].discountInBasisPoints) * marketValue / 10000; + } + function verifyQueuedWithdrawal(address _user, IDelegationManager.Withdrawal calldata _queuedWithdrawal) public view returns (bytes32) { require(_queuedWithdrawal.staker == _user && _queuedWithdrawal.withdrawer == address(this), "wrong depositor/withdrawer"); for (uint256 i = 0; i < _queuedWithdrawal.strategies.length; i++) { @@ -523,7 +527,6 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab function _completeWithdrawals(IDelegationManager.Withdrawal memory _queuedWithdrawal) internal { bytes32 withdrawalRoot = eigenLayerDelegationManager.calculateWithdrawalRoot(_queuedWithdrawal); - if (!isRegisteredQueuedWithdrawals[withdrawalRoot]) revert NotRegistered(); uint256 numStrategies = _queuedWithdrawal.strategies.length; for (uint256 i = 0; i < numStrategies; i++) { diff --git a/test/EtherFiTimelock.sol b/test/EtherFiTimelock.sol index 62c7935c..71227e2e 100644 --- a/test/EtherFiTimelock.sol +++ b/test/EtherFiTimelock.sol @@ -318,10 +318,18 @@ contract TimelockTest is TestSetup { initializeRealisticFork(MAINNET_FORK); address target = address(managerInstance); bytes4 selector = 0x3ccc861d; - bytes memory data = abi.encodeWithSelector(EtherFiNodesManager.updateAllowedForwardedExternalCalls.selector, selector, 0x7750d328b314EfFa365A0402CcfD489B80B0adda, true); _execute_timelock(target, data, true, true, true, true); } + + function test_upgrade_liquifier() public { + initializeRealisticFork(MAINNET_FORK); + { + address target = address(liquifierInstance); + bytes memory data = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, 0x5769ff35545B0BBFA27cc97C9407C5ed9d395455); + _execute_timelock(target, data, true, true, true, true); + } + } } // {"version":"1.0","chainId":"1 \ No newline at end of file diff --git a/test/Liquifier.t.sol b/test/Liquifier.t.sol index 7daf9bad..31ad5782 100644 --- a/test/Liquifier.t.sol +++ b/test/Liquifier.t.sol @@ -85,13 +85,22 @@ contract LiquifierTest is TestSetup { vm.deal(alice, 100 ether); + vm.startPrank(liquifierInstance.owner()); + liquifierInstance.updateQuoteStEthWithCurve(true); + liquifierInstance.updateDiscountInBasisPoints(address(stEth), 500); // 5% + vm.stopPrank(); + vm.startPrank(alice); stEth.submit{value: 10 ether}(address(0)); stEth.approve(address(liquifierInstance), 10 ether); liquifierInstance.depositWithERC20(address(stEth), 10 ether, address(0)); vm.stopPrank(); - assertGe(eETHInstance.balanceOf(alice), 10 ether - 0.1 ether); + assertApproxEqAbs(eETHInstance.balanceOf(alice), 10 ether - 0.5 ether, 0.1 ether); + + uint256 aliceQuotedEETH = liquifierInstance.quoteByDiscountedValue(address(stEth), 10 ether); + // alice will actually receive 1 wei less due to the infamous 1 wei rounding corner case + assertApproxEqAbs(eETHInstance.balanceOf(alice), aliceQuotedEETH, 1); } function test_deopsit_stEth_and_swap() internal {