ID | Description | Severity |
---|---|---|
H-01 | OperatorDelegator::getTokenBalanceFromStrategy never account queuedShares |
High |
H-02 | Eigen rewards can be sandwiched to earn more ezETH | High |
H-03 | WithdrawQueue::claim rebasing tokens can block the last claim temporarily |
High |
H-04 | RestakeManager::calculateTvls always account withdraw queue balance for wrong token |
High |
M-01 | No slippage in RestakeManger::deposit and WithdrawQueue::withdraw |
Medium |
M-02 | RestakeManager::deposit will revert always |
Medium |
M-03 | Possible arbitrage due to the long heartbeat of stETH/ETH price feed |
Medium |
L-01 | WithdrawQueue::claim should expect the recipient |
Low |
L-02 | No way to cancel a withdrawal request | Low |
L-03 | chooseOperatorDelegatorForWithdraw should fetch the TVL |
Low |
L-04 | Inaccuracy of shares between Eigen withdrawal request and claim | Low |
Due to a wrong token check in the getTokenBalanceFromStrategy
, RestakeManager::calculateTVLs
will return less balance when there are queued withdrawals.
RestakeManager::calculateTVLs
is used to calculate the TVL of OperatorDelegator
, divided into 3 separate variables:
- by individual collateral tokens
- a sum of all collateral tokens
- total for the protocol (previous 2 + withdraw and deposit queue balances).
For each operator getTokenBalanceFromStrategy
is called and the returned amount of this function is added to all 3 sources mentioned above:
OperatorDelegator.sol#L326-L335
/// @dev Gets the underlying token amount from the amount of shares + queued withdrawal shares
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) {
return
queuedShares[address(this)] == 0
? tokenStrategyMapping[token].userUnderlyingView(address(this))
: tokenStrategyMapping[token].userUnderlyingView(address(this)) +
tokenStrategyMapping[token].sharesToUnderlyingView(
queuedShares[address(token)]
);
}
From the snippet we can see that queuedShares
, which is responsible for tracking the queued withdrawals from EigenLayer
for given token, uses address(this)
instead.
Important note is that there is no place in the code which increases this particular key of the mapping and it will always be 0, even when there are active queued withdrawals.
As result the above mentioned function will not account the queued shares for all tokens and will return highly reduced balance.
Then when depositing 3 major issues will be observed:
- In
deposit
tokenTVL limit will be bypassed indicating more withdraw needs.
function deposit(
IERC20 _collateralToken,
uint256 _amount,
uint256 _referralId
) public nonReentrant notPaused {
// Verify collateral token is in the list - call will revert if not found
uint256 tokenIndex = getCollateralTokenIndex(_collateralToken);
// Get the TVLs for each operator delegator and the total TVL
(
uint256[][] memory operatorDelegatorTokenTVLs,
uint256[] memory operatorDelegatorTVLs,
uint256 totalTVL
) = calculateTVLs();
// Get the value of the collateral token being deposited
uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);
// Enforce TVL limit if set, 0 means the check is not enabled
if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) {
revert MaxTVLReached();
}
// Enforce individual token TVL limit if set, 0 means the check is not enabled
if (collateralTokenTvlLimits[_collateralToken] != 0) {
// Track the current token's TVL
uint256 currentTokenTVL = 0;
// For each OD, add up the token TVLs
uint256 odLength = operatorDelegatorTokenTVLs.length;
for (uint256 i = 0; i < odLength; ) {
currentTokenTVL += operatorDelegatorTokenTVLs[i][tokenIndex];
unchecked {
++i;
}
// Check if it is over the limit
if (currentTokenTVL + collateralTokenValue > collateralTokenTvlLimits[_collateralToken])//@audit limit will be filled slower
revert MaxTokenTVLReached();
}
totalTVL
when depositing in any of the available collateral tokens will be way less than the actual balance, minting moreezETH
tokens than needed.
function depositETH(uint256 _referralId) public payable nonReentrant notPaused {
// Get the total TVL
(, , uint256 totalTVL) = calculateTVLs();
// Enforce TVL limit if set
if (maxDepositTVL != 0 && totalTVL + msg.value > maxDepositTVL) {
revert MaxTVLReached();
}
// Deposit the remaining ETH into the DepositQueue
depositQueue.depositETHFromProtocol{ value: msg.value }();
// Calculate how much ezETH to mint
uint256 ezETHToMint = renzoOracle.calculateMintAmount(
totalTVL, //@audit wrong value used
msg.value,
ezETH.totalSupply()
);
// Mint the ezETH
ezETH.mint(msg.sender, ezETHToMint);
// Emit the deposit event
emit Deposit(msg.sender, IERC20(address(0x0)), msg.value, ezETHToMint, _referralId);
}
Let’s review two examples, one with the right totalTVL
and one when half of it is queued for withdrawal from EigenLayer
and deposited amount 10e18
:
For sake of simplicity we will assume that ezETH and ETH price to be at 1:1 ratio.
function calculateMintAmount(
uint256 _currentValueInProtocol,
uint256 _newValueAdded,
uint256 _existingEzETHSupply
) external 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 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;
// Sanity check
if (mintAmount == 0) revert InvalidTokenAmount();
return mintAmount;
}
totalTVL/_currentValueInProtocol
- 1000e18_newValueAdded
- 10e18ezETH.totalSupply/_existingEzETHSupply
- 1000e18
Then we receive:
inflationPercentage
- 9900990099009900newEzETHSupply
- 1009999999999999998990 ≈ 1009e18mintAmount
- 9999999999999998990 ≈ 9e18
User is minted 9 ezETH
for 10 tokens deposit.
totalTVL/_currentValueInProtocol
- 500e18 (as we mentioned half of the totalTVL is queued for withdrawal from EigenLayer and wrongly won’t be added to this calculation)_newValueAdded
- 10e18ezETH.totalSupply/_existingEzETHSupply
- 1000e18
Then we receive:
inflationPercentage
- 19607843137254901newEzETHSupply
- 1019999999999999999000 ≈ 1019e18mintAmount
- 19999999999999999000 ≈ 19e18
User is minted 19 ezETH
for 10 tokens deposit.
After the actual withdrawal from EigenLayer is executed, queuedShares
will be zeroed, buffer will be filled (if needed) and the rest will be redeposited in the Strategy
, totalTVL
will now return the real value held in the protocol.
Then user can redeem his 19 ezETH
tokens and get 19 ETH back:
function calculateRedeemAmount(
uint256 _ezETHBeingBurned,
uint256 _existingEzETHSupply,
uint256 _currentValueInProtocol
) external pure returns (uint256) {
// This is just returning the percentage of TVL that matches the percentage of ezETH being burned
uint256 redeemAmount = (_currentValueInProtocol * _ezETHBeingBurned) / _existingEzETHSupply;
// Sanity check
if (redeemAmount == 0) revert InvalidTokenAmount();
return redeemAmount;
}
redeemAmount
= (1010e18 * 19999999999999999000) / 1019999999999999999000 ≈ 19e18
In summary, he deposited 10 ETH when there were pending withdrawal and redeemed roughly 19 ETH back when withdrawal from EigenLayer
has been completed.
- When picking
operatorDelegator
inselectOperatorDelegatorForDeposit()
, it will pick the wrong one because of the loweredtotalTVL
.
Manual Review
Replace address(this)
with the token that is being verified.
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) {
return
- queuedShares[address(this)] == 0
+ queuedShares[address(token)] == 0
? tokenStrategyMapping[token].userUnderlyingView(address(this))
: tokenStrategyMapping[token].userUnderlyingView(address(this)) +
tokenStrategyMapping[token].sharesToUnderlyingView(
queuedShares[address(token)]
);
}
Native rewards initiated from OperatorDelegator::verifyAndProcessWithdrawals
and OperatorDelegator::withdrawNonBeaconChainETHBalanceWei
can be sandwiched from users to steal assets when the exchange rate of ezETH
increases.
The protocol handles ETH rewards by sending them to the OperatorDelegator’s
. There are at least 3 flows that end up sending funds there:
- When the function
withdrawNonBeaconChainETHBalanceWei
is called to scrape non-beacon chain ETH from an Eigenpod. - When a validator receives rewards via partial withdrawals after the function
EigenPod::verifyAndProcessWithdrawals
is called. - When a validator exists and has more than 32ETH the excess will be sent as rewards after the function
EigenPod::verifyAndProcessWithdrawals
is called.
All of these 3 flows end up queuing a withdrawal to the OperatorDelegator
. After a delay the rewards can claimed by calling the permissionless function DelayedWithdrawalRouter::claimDelayedWithdrawals
this call will instantly increase the totalTVL
of the protocol.
Following the mint and redeem flows in Renzo
we can see that the exchange rate of ezETH
is dependent on the totalTVL
, which will be increased when rewards are sent to the system:
- Minting:
function calculateMintAmount(
uint256 _currentValueInProtocol,
uint256 _newValueAdded,
uint256 _existingEzETHSupply
) external 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 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;
// Sanity check
if (mintAmount == 0) revert InvalidTokenAmount();
return mintAmount;
}
or simplified:
- Redeeming:
function calculateRedeemAmount(
uint256 _ezETHBeingBurned,
uint256 _existingEzETHSupply,
uint256 _currentValueInProtocol
) external pure returns (uint256) {
// This is just returning the percentage of TVL that matches the percentage of ezETH being burned
uint256 redeemAmount = (_currentValueInProtocol * _ezETHBeingBurned) / _existingEzETHSupply;
// Sanity check
if (redeemAmount == 0) revert InvalidTokenAmount();
return redeemAmount;
}
or simplified:
An attacker can take advantage of this to steal a part of the rewards:
- Mint a sensible amount of
ezETH
by depositing an accepted asset. - Call
DelayedWithdrawalRouter::claimDelayedWithdrawals
, after which the value of theezETH
tokens just minted will immediately increase. - Request a withdrawal for all the
ezETH
tokens viaWithdrawQueue::withdraw
Manual Review
It is hard to give a perfect solution for this issue because it is also affected the EigenLayer
and the fact that DelayedWithdrawalRouter::claimDelayedWithdrawals
is permissionless and everyone incentivized can call it.
One possible mitigation would be to add a time period after entering the system to not be able to initiate withdrawal requests, this will remove the possibility users to benefit from the stepwise jumps that rewards distribution causes.
Issue Description:
Due to explicitly mentioning that Renzo will support stETH
(rebasing) token as collateral here, the following scenario can occur and block the last withdrawal request for at least 24 hours.
- User has 10
ezETH
tokens which he wants to withdraw. WithdrawQueue::withdraw
is called and these 10 restaking tokens result in 10stETH
tokens that he will receive when the delay period passes.- In the meantime, all other withdrawals are claimed and there just enough tokens to fulfil this request.
- Rebasing of
stETH
occurs (once in 24 hours) and thebalanceOf(address(this))
decreases to 9 tokens, whereasamountToRedeem
, calculated at step 2, remains the same - 10 tokens. - User wants to claim but the transfer will revert due to insufficient funds in the
WithdrawQueue
:
function claim(uint256 withdrawRequestIndex) external nonReentrant {
...MORE CODE
else {
IERC20(_withdrawRequest.collateralToken).transfer(
msg.sender,
_withdrawRequest.amountToRedeem //@audit doesn't apply the rebasing
);
}
}
Recommendation:
Consider using wstETH
which is the non-rebasing version of the stETH
.
Usage of rebasing tokens has not been a problem so far because Renzo’s withdrawal functionality is not live yet.
Due to the wrong token index being passed, when summing up totalWithdrawalQueueValue
, calculateTVLs
will always return the wrong value.
In calculateTVLs()
, it goes through each OperatorDelegator and for each one, it loops through all supported tokens.totalWithdrawalQueueValue
is calculated when iterating all the supported collateral tokens and uses withdrawQueueTokenBalanceRecorded
as a flag to account them only one time - it will enter the if check only for the first OperatorDelegator
.
In the first iteration of the OperatorDelegator loop, it calculates all available withdrawals for every token. However, it consistently passes the first token from the collateralTokens
array, because using the OperatorDelegator loop index (i), which is always 0 in this scenario, instead of looping through all tokens by passing the collateralToken loop index (j).
// record token value of withdraw queue
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
collateralTokens[i], // @ audit it will always pass the first token, instead of looping through all tokens
collateralTokens[j].balanceOf(withdrawQueue)
);
}
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
...
for (uint256 i = 0; i < odLength; ) {
// Track the TVL for this OD
uint256 operatorTVL = 0;
// Track the individual token TVLs for this OD - native ETH will be last item in the array
uint256[] memory operatorValues = new uint256[](collateralTokens.length + 1);
operatorDelegatorTokenTVLs[i] = operatorValues;
// Iterate through the tokens and get the value of each
uint256 tokenLength = collateralTokens.length;
for (uint256 j = 0; j < tokenLength; ) {
// Get the value of this token
uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(
collateralTokens[j]
);
// Set the value in the array for this OD
operatorValues[j] = renzoOracle.lookupTokenValue(
collateralTokens[j],
operatorBalance
);
// Add it to the total TVL for this OD
operatorTVL += operatorValues[j];
// record token value of withdraw queue
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
collateralTokens[i], // @ audit it will always pass the first token, instead of looping through all tokens
collateralTokens[j].balanceOf(withdrawQueue)
);
}
unchecked {
++j;
}
}
...
}
Because of this, it will always convert the collateral token available for withdrawal to the price of the token at index 0.
Manual Review
In calculateTVLs()
, collateralTokens
****should take tokens based on j index, not i index.
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
...
for (uint256 i = 0; i < odLength; ) {
// Track the TVL for this OD
uint256 operatorTVL = 0;
// Track the individual token TVLs for this OD - native ETH will be last item in the array
uint256[] memory operatorValues = new uint256[](collateralTokens.length + 1);
operatorDelegatorTokenTVLs[i] = operatorValues;
// Iterate through the tokens and get the value of each
uint256 tokenLength = collateralTokens.length;
for (uint256 j = 0; j < tokenLength; ) {
// Get the value of this token
uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(
collateralTokens[j]
);
// Set the value in the array for this OD
operatorValues[j] = renzoOracle.lookupTokenValue(
collateralTokens[j],
operatorBalance
);
// Add it to the total TVL for this OD
operatorTVL += operatorValues[j];
// record token value of withdraw queue
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
- collateralTokens[i],
+ collateralTokens[j],
collateralTokens[j].balanceOf(withdrawQueue)
);
}
unchecked {
++j;
}
}
...
}
RestakeManager::deposit()
and WithdrawQueue::withdraw()
have no slippage protection. deposit()
receives amount collateral from the user and mints them ezETH
, and withdraw()
does the opposite. Both calculations, collateral → ezETH
and ezETH → collateral
, rely on oracles to convert everything to ETH denomination, meaning that the amount received in return may vary indefinitely while the request is waiting to be executed.
As you can see, none of the functions have a slippage parameter that allows the user to specify a minimum amount they expect to receive. This means that the user has no control over how much ezETH
or collateral they will receive in return.
function deposit(
IERC20 _collateralToken,
uint256 _amount,
uint256 _referralId // @audit no minAmountOut
) public nonReentrant notPaused {
...
}
function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
...
}
Manual Review
Add minAmountOut
to deposit()
and minAmountOut
to withdraw()
because it calculates amountToRedeem
there.
All deposits under withdrawQueue.bufferToFill
will always revert.
The first depositor must deposit at least the bufferToFill
+ 1 wei to enable smaller deposits.
When users deposit to the RestakeManager
, first the withdrawQueue.bufferToFill
must be filled, and then the deposits will go to the OperatorDelegaror
and from there to EigenLayer
.
Note: The sponsor said that bufferToFill will be around 1000ETH at the beginning.
In deposit()
it will get _collateralToken.bufferToFill
and move the token to the depositQueue
, filling the buffer for that particular token and if it reaches the buffer limit the remainder is saved to _amount
and deposited into the operatorDelegator
.
function deposit(
IERC20 _collateralToken,
uint256 _amount,
uint256 _referralId
) public nonReentrant notPaused {
...
// Check the withdraw buffer and fill if below buffer target
uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit(
address(_collateralToken)
);
if (bufferToFill > 0) {
bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill;
// update amount to send to the operator Delegator
_amount -= bufferToFill;
// safe Approve for depositQueue
_collateralToken.safeApprove(address(depositQueue), bufferToFill);
// fill Withdraw Buffer via depositQueue
depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill);
}
// Approve the tokens to the operator delegator
_collateralToken.safeApprove(address(operatorDelegator), _amount);
// Call deposit on the operator delegator
operatorDelegator.deposit(_collateralToken, _amount);
...
}
However, until the buffer is full, _amount
will be 0 and it will try to deposit 0 into the operatorDelegator
, but operatorDelegator.deposit()
reverts when the amount equals 0, causing all deposits to revert. It will only work if someone fills the entire buffer with one deposit and makes _amount
at least 1, which is close to impossible.
OperatorDelegator.sol#L143-L154
function deposit(
IERC20 token,
uint256 tokenAmount
) external nonReentrant onlyRestakeManager returns (uint256 shares) {
if (address(tokenStrategyMapping[token]) == address(0x0) || tokenAmount == 0)
revert InvalidZeroInput();
// Move the tokens into this contract
token.safeTransferFrom(msg.sender, address(this), tokenAmount);
return _deposit(token, tokenAmount);
}
Manual Review
Until the buffer is reached, deposits to the operatorDelegator
should only be made if _amount > 0
.
function deposit(
IERC20 _collateralToken,
uint256 _amount,
uint256 _referralId
) public nonReentrant notPaused {
...
// Check the withdraw buffer and fill if below buffer target
uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit(
address(_collateralToken)
);
if (bufferToFill > 0) {
bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill;
// update amount to send to the operator Delegator
_amount -= bufferToFill;
// safe Approve for depositQueue
_collateralToken.safeApprove(address(depositQueue), bufferToFill);
// fill Withdraw Buffer via depositQueue
depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill);
}
+ if (_amount > 0) {
// Approve the tokens to the operator delegator
_collateralToken.safeApprove(address(operatorDelegator), _amount);
// Call deposit on the operator delegator
operatorDelegator.deposit(_collateralToken, _amount);
+ }
...
}
Issue Description:
Renzo protocol relies on stETH/ETH
price feed to convert stETH
when used for deposit, withdrawal, etc. But this price feed has too long heartbeat - 24 hours, which can open arbitrage opportunities if the price is not updated accurately.
This is the price feed: stETH/ETH
The 24-hour heartbeat and 0.5% deviation threshold means that price can move up to 0.5% or stay flat for 24 hours before triggering a price update, which is unlikely to be reached, but historically it is not impossible, you can check this graph for example:
This allows you to deposit at these times to mint more ezETH
or withdraw at a better rate.
The same issue was reported here and I used it as a guide - you can check it out as it has many more diagrams and explanations of possible situations. code-423n4/2023-11-kelp-findings#584
Recommendation:
It's hard to give a proper solution, one of them we also pointed out in our Kelp issue was to use multiple oracles, like this stETH/USD
, but that will certainly make the system more complex, so can't suggest anything exactly.
Issue Description:
When user claim their withdraw request it will always sent the tokens to him (msg.sender), consider allowing the user to specify who to receive the tokens.
Recommendation:
Add a new property to the WithdrawRequest
struct:
struct WithdrawRequest {
address collateralToken;
uint256 withdrawRequestID;
uint256 amountToRedeem;
uint256 ezETHLocked;
uint256 createdAt;
+ address recipient;
}
As well as modify the code to use it: WithdrawQueue.sol
- function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
+ function withdraw(uint256 _amount, address _assetOut, address recipient) external nonReentrant {
...MORE CODE
// add withdraw request for msg.sender
withdrawRequests[msg.sender].push(
WithdrawRequest(
_assetOut,
withdrawRequestNonce,
amountToRedeem,
_amount,
block.timestamp,
+ recipient
)
);
...MORE CODE
}
function claim(uint256 withdrawRequestIndex) external nonReentrant {
// check if provided withdrawRequest Index is valid
if (withdrawRequestIndex >= withdrawRequests[msg.sender].length)
revert InvalidWithdrawIndex();
WithdrawRequest memory _withdrawRequest = withdrawRequests[msg.sender][
withdrawRequestIndex
];
if (block.timestamp - _withdrawRequest.createdAt < coolDownPeriod) revert EarlyClaim();
// subtract value from claim reserve for claim asset
claimReserve[_withdrawRequest.collateralToken] -= _withdrawRequest.amountToRedeem;
// delete the withdraw request
withdrawRequests[msg.sender][withdrawRequestIndex] = withdrawRequests[msg.sender][
withdrawRequests[msg.sender].length - 1
];
withdrawRequests[msg.sender].pop();
// burn ezETH locked for withdraw request
ezETH.burn(address(this), _withdrawRequest.ezETHLocked);
// send selected redeem asset to user
if (_withdrawRequest.collateralToken == IS_NATIVE) {
- payable(msg.sender).transfer(_withdrawRequest.amountToRedeem);
+ payable(_withdrawRequest.recipient).transfer(_withdrawRequest.amountToRedeem);
} else {
- IERC20(_withdrawRequest.collateralToken).transfer(
- msg.sender,
- _withdrawRequest.amountToRedeem
- );
+ IERC20(_withdrawRequest.collateralToken).transfer(_withdrawRequest.recipient, _withdrawRequest.amountToRedeem);
}
// emit the event
emit WithdrawRequestClaimed(_withdrawRequest);
}
Issue Description:
There is no way for the user who requested a withdrawal to cancel it. Due to various factors, such as unfavorable conditions or increased yield on EigenLayer, user may wish to cancel their withdrawal request and request it later.
Recommendation:
Implement a function that allows users to cancel their withdrawal request by deleting their WithdrawRequest
struct and returning them their ezETH
back. But this should only be allowed after the withdrawal delay, because if it can be done immediately, other users can be gamed, by sandwich their withdraw request with request/cancel and lower the buffer. Also, if while the user is thinking about claiming or canceling their request, the buffer is refilled, the collateral that was reserved for them must be deposited into the EigenLayer, otherwise the buffer will overflow.
Issue Description:
chooseOperatorDelegatorForWithdraw
relies on providing the accurate data to the following arguments:
operatorDelegatorTokenTVLs
operatorDelegatorTVLs
totalTVL
instead of calculating them inside the function itself.
This will remove the possibilities when some transactions, for example deposits and withdrawals/claims, execute before chooseOperatorDelegatorForWithdraw
and the values given as arguments becoming stale.
function chooseOperatorDelegatorForWithdraw(
uint256 tokenIndex,
uint256 ezETHValue,
uint256[][] memory operatorDelegatorTokenTVLs,
uint256[] memory operatorDelegatorTVLs,
uint256 totalTVL
) public view returns (IOperatorDelegator) {
Recommendation:
Apply the following changes to the chooseOperatorDelegatorForWithdraw
function:
function chooseOperatorDelegatorForWithdraw(
uint256 tokenIndex,
uint256 ezETHValue,
- uint256[][] memory operatorDelegatorTokenTVLs,
- uint256[] memory operatorDelegatorTVLs,
- uint256 totalTVL
) public view returns (IOperatorDelegator) {
+ (uint256[][] operatorDelegatorTokenTVLs, uint256[] operatorDelegatorTVLs, uint256 totalTVL) = calculateTVLs();
...code below remains unmodified
}
The drawback of this additions is the increased gas cost, but on the other hand it guarantees values will always to be up-to-date.
Issue Description:
Withdrawals from EigenLayer will not be up to date, because the shares for the given amount are calculated upon request and various price changes may occur until they are claimed.
When a withdrawal is queued, it calculates how many shares to withdraw based on the underlyingToShare
exchange rate at the time of the request.
OperatorDelegator.sol#L193-L256
function queueWithdrawals(
IERC20[] calldata tokens,
uint256[] calldata tokenAmounts
) external nonReentrant onlyNativeEthRestakeAdmin returns (bytes32) {
..
if (address(tokenStrategyMapping[tokens[i]]) == address(0))
revert InvalidZeroInput();
// set the strategy of the token
queuedWithdrawalParams[0].strategies[i] = tokenStrategyMapping[tokens[i]];
// set the equivalent shares for tokenAmount
queuedWithdrawalParams[0].shares[i] = tokenStrategyMapping[tokens[i]]
.underlyingToSharesView(tokenAmounts[i]); // <----------- @audit it will convert the tokenAmount to shares at the time of request
....
// set withdrawer as this contract address
queuedWithdrawalParams[0].withdrawer = address(this);
// track shares of tokens withdraw for TVL
queuedShares[address(tokens[i])] += queuedWithdrawalParams[0].shares[i];
unchecked {
++i;
}
}
...
}
And then the shares that were saved in queuedShares
will be withdrawn, but what if the exchange rate changes during the withdrawal delay has passed and be able to call completeQueuedWithdrawal()
.
- Let's assume that
1 stETH
equals1 EigenLayer-stETH
and the withdrawal buffer is full, so all deposits will be made to Eigen. - Alice deposits
5e18
stETH. - After some time, Bob deposits
100e18 stETH
and then requests an immediate withdrawal, and at the time of the request, the amount of his withdrawal shares will be100e18 EigenLayer-stETH
. - Now assume that the value of EigenLayer-stETH increases, meaning that 1 EigenLayer-stETH is now worth more stETH. This is expected behavior as EigenLayer-stETH is similar to an ERC4626 vault and value may increase over time.
- Let's say
1 EigenLayer-stETH
is now worth1.1 stETH
. - For the initial
100stETH
that bob deposited, now only 90.9 EigenLayer-stETH can be exchanged and plus the5stETH
deposited by Alice gives us a total of90.9 + 4.54 = 95.44
, but bob's request was for 100.
This would mean that Bob's withdrawal request would not be fulfilled because there were changes in the exchange rate between the request and the claim.
A similar finding was reported here: sherlock-audit/2024-02-rio-network-core-protocol-judging#109
Recommendation:
It is hard to give appropriate solution to this, because this is how EigenLayer shares works, but it can be something to check in completeQueuedWithdrawal()
, if the initial shares are still the same and if the exchange rate was updated.