Code under review: 2024-04-renzo (3231 nSLOC)
Contest Page: Renzo-contest
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L300 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Deposits/DepositQueue.sol#L137
Renzo has a buffer that acts as a pool of assets to support withdrawal requests. There is a need for buffer in order to support withdrawal of assets.
The withdrawQueue contract get filled by 3 ways ->
New Deposits Daily Rewards Coming in the Protocol. Manual withdrawal from EigenLayer. Permissioned call from OperatorDelegator. If options 1 and 2 are not sufficient to fulfill withdraw requests of Users then admin accounts will manually unstake from EigenLayer through 2 step process:
OperatorDelegator.queueWithdrawals
OperatorDelegator.completeWithdrawals
// OperatorDelegator.sol
function completeQueuedWithdrawal(
IDelegationManager.Withdrawal calldata withdrawal,
IERC20[] calldata tokens,
uint256 middlewareTimesIndex
) external nonReentrant onlyNativeEthRestakeAdmin {
-- SNIP --
restakeManager.depositQueue().fillERC20withdrawBuffer( //@audit
address(tokens[i]),
bufferToFill
);
-- SNIP --
The fillERC20withdrawBuffer is called to fill up ERC20 withdraw buffer. However the function call will revert because that function required to be called by the restake manager admin. In Renzo, each and every roles are unique and have their own functionalities to perform. This means that whenever there is a need to fill up the buffer, the 3rd option will not be able to do so, bricking the protocol if there isn't enough assets to cover the withdrawal requests.
Furthermore, the Operator will not be able to unstake delegated funds from Eigenlayer. Assets remain frozen due to the inability to complete the steps required for withdrawal.
// DepositQueue.sol
/// @dev Allows only the RestakeManager address to call functions
modifier onlyRestakeManager() {
if (msg.sender != address(restakeManager)) revert NotRestakeManager();
_;
}
function fillERC20withdrawBuffer(
address _asset,
uint256 _amount
) external nonReentrant onlyRestakeManager { //@audit
-- SNIP --
Buffer will never be filled in the case of supporting withdrawal requests. Operator unable to unstake delegated assets from Eigenlayer, causing assets to be frozen.
Manual Review
Remove the access control for only restake manager, leaving the function similar to a donation function.
function fillERC20withdrawBuffer(
address _asset,
uint256 _amount
-- ) external nonReentrant onlyRestakeManager {
++ ) external nonReentrant {
Access Control
The calculateTVLs()
function in RestakeManager calculates TVLs for each operator delegator per token, total per OD, and total for the protocol.
It employs two for loops using elements i
and j
, where i
is used for the outer loop and j
for the inner loop. The function calls RenzoOracle::lookupTokenValue()
to get the asset value in the underlying currency based on a single token and balance.
However, due to always using the outer loop element i, it only loops through the first collateral token, resulting in the return value for that token. This affects the totalWithdrawalQueueValue
and the overall asset value locked.
For example, with whitelisted tokens [ezETH, stETH, wBETH], using i for ezETH means the loop always uses the ezETH price feed. This can lead to incorrect calculations when considering the amounts and price feeds of other tokens like stETH
and wBETH
.
Whenever user submit withdraw request, it will always produce the wrong amount converted to underlying asset.
This can produce false results in the total value locked calculations, impacting crucial state variables like the amount to redeem in ETH/LST.
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
-- SNIP --
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
collateralTokens[i],
collateralTokens[j].balanceOf(withdrawQueue) //@audit redundant since it was already added in getTokenBalanceFromStrategy
);
}
-- SNIP --
// Add native ETH help in withdraw Queue and totalWithdrawalQueueValue to totalTVL
totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);
Manual Review
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
-- collateralTokens[i],
++ collateralTokens[j],
collateralTokens[j].balanceOf(withdrawQueue) //@audit redundant since it was already added in getTokenBalanceFromStrategy
);
}
Loop
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L206 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L279
The protocol can be paused by the admin DEPOSIT_WITHDRAW_PAUSER
and WITHDRAW_QUEUE_ADMIN
for DepositQueue.sol
and WithdrawalQueue.sol
. It allows the admin to pause/unpause deposits and withdraws. Users are not allowed to deposit or withdraw when paused. However, the user is still able to withdraw even if protocol is paused.
The WithdrawalQueue::withdraw()
and WithdrawalQueue::claim()
did not enforce the Openzeppelins whenNotPaused
modifier. This means that if the admin were to call the WithdrawalQueue::pause()
, it does not affect the withdraw flow. Hence, users are still able to submit withdrawal request and withdraw their underlying assets after 7 days.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
function claim(uint256 withdrawRequestIndex) external nonReentrant {
Users can withdraw underlying assets even if the Renzo is paused.
Manual Review
Include the whenNotPaused modifier to vital functions.
-- function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
++ function withdraw(uint256 _amount, address _assetOut) external nonReentrant whenNotPaused{
-- function claim(uint256 withdrawRequestIndex) external nonReentrant {
++ function claim(uint256 withdrawRequestIndex) external nonReentrant whenNotPaused{
Access Control