code-423n4 / 2024-04-renzo-findings

11 stars 8 forks source link

ezETH withdrawers do not earn yield while waiting for a withdrawal #258

Closed howlbot-integration[bot] closed 5 months ago

howlbot-integration[bot] commented 5 months ago

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L206

Vulnerability details

Impact

Users withdrawing in ezETH do no earn any rewards when withdrawing. Yield should accumulated while in the withdraw waiting queue. It is fair to assume that withdrawing users have a proportional claim to the yield.

Proof of Concept

According to the documentation:

ezETH is the liquid restaking token representing a user’s restaked position at Renzo

The underlying restaking positions earn rewards which are reflected in the price of ezETH

The value ezETH increase relative to the underlying LSTs as it earns more rewards in AVS tokens

Therefore, the price of ezETH increases over time, and the value of the rewards is reflected in the value of ezETH.

When extracting rewards, Renzo handles it as follows:

/**
 * @notice  Creates a withdraw request for user
 * @param   _amount  amount of ezETH to withdraw
 * @param   _assetOut  output token to receive on claim
 */
function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
    // check for 0 values
    if (_amount == 0 || _assetOut == address(0)) revert InvalidZeroInput();

    // check if provided assetOut is supported
    if (withdrawalBufferTarget[_assetOut] == 0)
        revert UnsupportedWithdrawAsset();

    // transfer ezETH tokens to this address
    IERC20(address(ezETH)).safeTransferFrom(msg.sender, address(this), _amount);

    // calculate totalTVL
    (, , uint256 totalTVL) = restakeManager.calculateTVLs();

    // Calculate amount to Redeem in ETH
    uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(
        _amount,
        ezETH.totalSupply(),
        totalTVL
    );
    .......
}

The calculateRedeemAmount function calculates the reward amount.

// Given the amount of ezETH to burn, the supply of ezETH, and the total value in the protocol, determine amount of value to return to user
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;
}

Finally, after the coolDownPeriod users can claim their rewards.

    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);
        } else {
            IERC20(_withdrawRequest.collateralToken).transfer(
                msg.sender,
                _withdrawRequest.amountToRedeem
            );
        }
        // emit the event
        emit WithdrawRequestClaimed(_withdrawRequest);
    }
}

Here, there is a problem. The reward amount calculates the current value of the withdrawn ezETH, but according to the documentation, there is a waiting period of at least seven days before rewards can be extracted. The calculation method above does not consider the waiting time, which results in users receiving rewards based on the value of ezETH that is seven days ago, leading to unnecessary losses for users.

Tools Used

vscode

Recommended Mitigation Steps

Account for the accumulate rewards during the withdrawal period that belongs to the deposit pool

Assessed type

Other

C4-Staff commented 5 months ago

CloudEllie marked the issue as duplicate of #259

c4-judge commented 5 months ago

alcueca marked the issue as satisfactory

c4-judge commented 5 months ago

alcueca marked the issue as duplicate of #326