Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Liquifier: pricing of stETH via CurvePool & fixed rate fee #188

Merged
merged 10 commits into from
Oct 22, 2024
13 changes: 13 additions & 0 deletions operations/20241018_upgrade_liquifier.json
Original file line number Diff line number Diff line change
@@ -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"
}
] }
Original file line number Diff line number Diff line change
@@ -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**:
49 changes: 26 additions & 23 deletions src/Liquifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)) {
Expand All @@ -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++) {
Expand Down Expand Up @@ -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++) {
Expand Down
10 changes: 9 additions & 1 deletion test/EtherFiTimelock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion test/Liquifier.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading