Platform: Code4rena
Start Date: 30/04/2024
Pot Size: $112,500 USDC
Total HM: 22
Participants: 122
Period: 8 days
Judge: alcueca
Total Solo HM: 1
Id: 372
League: ETH
Rank: 13/122
Findings: 5
Award: $1,320.88
🌟 Selected for report: 0
🚀 Solo Findings: 0
511.6872 USDC - $511.69
Judge has assessed an item in Issue #539 as 2 risk. The relevant finding follows:
#0 - c4-judge
2024-05-28T04:12:05Z
alcueca marked the issue as duplicate of #563
#1 - c4-judge
2024-05-28T04:12:08Z
alcueca marked the issue as satisfactory
🌟 Selected for report: t0x1c
Also found by: 0xCiphky, 0xDemon, Bauchibred, DanielArmstrong, FastChecker, MSaptarshi, Maroutis, NentoR, Ocean_Sky, PNS, Rhaydden, SBSecurity, Shaheen, Tigerfrake, ZanyBonzy, atoko, btk, carlitox477, crypticdefense, honey-k12, hunter_w3b, ilchovski, jokr, ladboy233, rbserver, twcctop, umarkhatab_465
1.479 USDC - $1.48
Judge has assessed an item in Issue #539 as 2 risk. The relevant finding follows:
#0 - c4-judge
2024-05-27T11:17:36Z
alcueca marked the issue as duplicate of #484
#1 - c4-judge
2024-05-27T11:17:40Z
alcueca marked the issue as satisfactory
🌟 Selected for report: 0xCiphky
Also found by: 0rpse, 0x007, 0xAadi, 14si2o_Flint, ADM, Aamir, Aymen0909, BiasedMerc, DanielArmstrong, Fassi_Security, FastChecker, KupiaSec, LessDupes, MaslarovK, Neon2835, RamenPeople, SBSecurity, Shaheen, Tendency, ZanyBonzy, adam-idarrha, araj, b0g0, baz1ka, bigtone, bill, blutorque, carrotsmuggler, cu5t0mpeo, fyamf, gesha17, gumgumzum, hunter_w3b, inzinko, jokr, josephdara, kennedy1030, kinda_very_good, lanrebayode77, m_Rassska, mt030d, mussucal, tapir, underdog, xg, zzykxx
0.0402 USDC - $0.04
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L147 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L546-L549 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L562
A denial of service will allways occur for users who's deposit amount happens to match the needed bufferToFill which will cause them not being able deposit or mint ezETH tokens.
When users deposit a collateral token, the required calculations are performed, the amount is pulled from the caller and the buffer deficit is gotten from withdrawQueue.sol. Now if bufferToFill
is greater than zero, the bufferToFill
is set to a new value. If _amount <= bufferToFill
, the bufferToFill
is capped to the amount, upon which it is reduced by bufferToFill
. This causes the now _amount
to be 0. This 0 _amount
is then granted as allowance to OperatorDelegator.sol upon which the deposit
function is called.
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { ... // Transfer the collateral token to this address _collateralToken.safeTransferFrom(msg.sender, address(this), _amount); // 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); // Calculate how much ezETH to mint uint256 ezETHToMint = renzoOracle.calculateMintAmount( totalTVL, collateralTokenValue, ezETH.totalSupply() ); // Mint the ezETH ezETH.mint(msg.sender, ezETHToMint); // Emit the deposit event emit Deposit(msg.sender, _collateralToken, _amount, ezETHToMint, _referralId); }
The deposit
function in OperatorDelegator.sol checks if tokenAmount
is 0, which if it is, will revert, causing the deposit
function to revert.
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); }
This causes that users will not be able to deposit, mint ezETH and the buffer deficit will not be clearable through the deposit
function.
Manual code review
Wrap the operatorDelegator.deposit
function in an if() block.
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { ... 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); } ... }
Error
#0 - c4-judge
2024-05-20T05:11:22Z
alcueca marked the issue as satisfactory
18.1958 USDC - $18.20
Judge has assessed an item in Issue #539 as 2 risk. The relevant finding follows:
#0 - c4-judge
2024-05-24T09:41:10Z
alcueca marked the issue as duplicate of #5
#1 - c4-judge
2024-05-24T09:41:13Z
alcueca marked the issue as satisfactory
🌟 Selected for report: Sathish9098
Also found by: 0x73696d616f, 0xCiphky, 0xmystery, ABAIKUNANBAEV, Bauchibred, BiasedMerc, Fassi_Security, FastChecker, GalloDaSballo, GoatedAudits, K42, KupiaSec, LessDupes, Limbooo, ReadyPlayer2, Rhaydden, SBSecurity, Sabit, Sparrow, WildSniper, ZanyBonzy, adam-idarrha, adeolu, araj, aslanbek, atoko, b0g0, carlitox477, crypticdefense, fyamf, gesha17, gjaldon, grearlake, gumgumzum, hihen, honey-k12, hunter_w3b, inzinko, jesjupyter, jokr, kennedy1030, kind0dev, kinda_very_good, ladboy233, lanrebayode77, oakcobalt, oualidpro, pauliax, rbserver, t0x1c, tapir, underdog, xg, zzykxx
789.4737 USDC - $789.47
Links to affected code *
If a new strategy is set, there's no check to see if there's an existing strategy
or transfer the current's strategy balance to the new one. Setting a new stategy doesn't migrate previous stategy's tokens to the new one which and can cause previous balances to be overwritten and issues with accounting.
function setTokenStrategy( IERC20 _token, IStrategy _strategy ) external nonReentrant onlyOperatorDelegatorAdmin { if (address(_token) == address(0x0)) revert InvalidZeroInput(); tokenStrategyMapping[_token] = _strategy; emit TokenStrategyUpdated(_token, _strategy); }
The token balance is gotten from the getTokenBalanceFromStrategy
function which will be overwritten if a new asset strategy is set. The value is used in calculating TVLs which is used extensively in assets deposits and redemptions.
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)] ); }
ezETH
amount parameter to receive upon deposits.Lines of code*
When users deposit into the protocol through the deposit
and depositETH
function, there's no way for intoduce the minimum amount of ezETH they'd like to receive. Considering that the calculated ezETH is dependent on oracle prices of the collateral token being deposited, the value of which is used to calculate how much ezETH to mint to the users, they have no way of predicting how many ezETH
they will get back at the moment of minting, as the prices could be updated while the request is in the mempool. This leaves them vulnerable to unfavourable slippage or sandwich attacks.
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); ... // Calculate how much ezETH to mint uint256 ezETHToMint = renzoOracle.calculateMintAmount( totalTVL, collateralTokenValue, ezETH.totalSupply() ); // Mint the ezETH ezETH.mint(msg.sender, ezETHToMint); // Emit the deposit event emit Deposit(msg.sender, _collateralToken, _amount, ezETHToMint, _referralId); }
Introduce a minimum amount out parameter.
setRenzoDeposit
should check if contract is pausedLines of code*
From the comment in the constructor of ConnextReceiver.sol, the contract is paused so as to be able to setup xRenzoDeposit
address
constructor(address _connext, address _xRenzoBridgeL1, uint32 _connextEthChainDomain) { ... // Pause The contract to setup xRenzoDeposit _pause(); }
And the setRenzoDeposit
is used to set or potentially update the xRenzoDeposit
address, but it's missing a check for contract's current paused status, meaning that the address can be set up whether the contract is paused or not, which goes against the desired behaviour of the contract.
/** * @notice This function updates the xRenzoDeposit Contract address * @dev This should be a permissioned call (onlyOnwer) * @param _newXRenzoDeposit New xRenzoDeposit Contract address */ function setRenzoDeposit(address _newXRenzoDeposit) external onlyOwner { if (_newXRenzoDeposit == address(0)) revert InvalidZeroInput(); emit XRenzoDepositUpdated(_newXRenzoDeposit, address(xRenzoDeposit)); xRenzoDeposit = IxRenzoDeposit(_newXRenzoDeposit); }
Consider introducing a whenPaused
modifier or check if contract is paused before updating the address.
Lines of code*
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L181
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L369
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L429
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L142
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L552
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L559
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L664
None of the various use of the safeApprove
function approved to zero. Openzeppelin's safeApprove
function is used to prevent approval front-runs by enforcing first approving to 0, then to a new amount. It's implemented as following:
function safeApprove( IERC20 token, address spender, uint256 value ) internal { // safeApprove should only be called when setting an initial allowance, // or when resetting it to zero. To increase and decrease it, use // 'safeIncreaseAllowance' and 'safeDecreaseAllowance' require( (value == 0) || (token.allowance(address(this), spender) == 0), "SafeERC20: approve from non-zero to non-zero allowance" ); _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value)); }
For instance, in the _trade
function in xRenzoDeposit.sol, in a case of an very extremly profitable swap in which not all approved tokens are used, the remaining unspent allowance, not zeroed out will cause any subsequent calls to approve connext to fail.
function _trade(uint256 _amountIn, uint256 _deadline) internal returns (uint256) { // Approve the deposit asset to the connext contract depositToken.safeApprove(address(connext), _amountIn); ... // Swap the tokens uint256 amountNextWETH = connext.swapExact( swapKey, _amountIn, address(depositToken), address(collateralToken), minOut, _deadline ); ... }
Consider zeroing out approvals after transaction is done.
getMintRate
reverting when there's no receiver and no oracle makes price updates by owner redundant.Lines of code*
The contract owner can update token price. This can be done, regardless of if there's an oracle or a receiver or not.
function updatePriceByOwner(uint256 price) external onlyOwner { return _updatePrice(price, block.timestamp); }
The issue is that this fact is not taken into consideration when getting mint rate. The function reverts if there's no recevier and no oracle, tentatively ignoring the price updates from the owner.
function getMintRate() public view returns (uint256, uint256) { // revert if PriceFeedNotAvailable if (receiver == address(0) && address(oracle) == address(0)) revert PriceFeedNotAvailable(); if (address(oracle) != address(0)) { (uint256 oraclePrice, uint256 oracleTimestamp) = oracle.getMintRate(); return oracleTimestamp > lastPriceTimestamp ? (oraclePrice, oracleTimestamp) : (lastPrice, lastPriceTimestamp); } else { return (lastPrice, lastPriceTimestamp); } }
Even if owner updates the prices constantly, as long as both receivers and oracles are down, the price updates will be potentially useless, rendering the function more or less redundant.
Consider removing the check, if price feed has not been updated by the owner in a while, the lastPriceTimestamp
will revert in the deposit
function where it's used as it reverts when timestamp is stale. This prevents the use of stale prices while also making use of the prices as updated by the owner.
function _deposit( uint256 _amountIn, uint256 _minOut, uint256 _deadline ) internal returns (uint256) { ... // Fetch price and timestamp of ezETH from the configured price feed (uint256 _lastPrice, uint256 _lastPriceTimestamp) = getMintRate(); // Verify the price is not stale if (block.timestamp > _lastPriceTimestamp + 1 days) { revert OraclePriceExpired(); }
Lines of code*
In XERC20.sol, there's a max limit introduced on the bridge, which indicates how much can be minted or burned by a bridge at a time. However, the protocol introduces no limit on how much tokens a user can mint/burn at a time.
function mintingMaxLimitOf(address _bridge) public view returns (uint256 _limit) { _limit = bridges[_bridge].minterParams.maxLimit; }
function burningMaxLimitOf(address _bridge) public view returns (uint256 _limit) { _limit = bridges[_bridge].burnerParams.maxLimit; }
This introduces a consequence in which whale addresses can easily use up the mint and burn limits of bridges, causing a form of DDOS for other users. Griefers can also take advantage of this by maliciously frontrunning other users transactions with just enough amount to cause their transaction to exceed bridge limit. In a more extreme or coordinated attack, attackers can continously mint and burn tokens (mint and burn limits are independent of each other) to slowly meet the bridge limits causing a problems for the network and bad user experience.
A good way is to put a max limit on how much users can mint or burn at a time.
updateXRenzoBridgeL1
should check if contract is pausedLines of code*
From the comment in the constructor of ConnextReceiver.sol, the contract is paused so as to be able to setup xRenzoDeposit
address
constructor( address _router, address _xRenzoBridgeL1, uint64 _ccipEthChainSelector ) CCIPReceiver(_router) { ... // Pause The contract to setup xRenzoDeposit _pause(); }
And the updateXRenzoBridgeL1
is used to set or potentially update the xRenzoBridgeL1
address, but it's missing a check for contract's current paused status, meaning that the address can be set up whether the contract is paused or not, which goes against the desired behaviour of the contract.
/** * @notice This function updates the xRenzoBridge Contract address deployed on Ethereum L1 * @dev This should be a permissioned call (onlyOnwer) * @param _newXRenzoBridgeL1 New address of xRenzoBridge Contract */ function updateXRenzoBridgeL1(address _newXRenzoBridgeL1) external onlyOwner { if (_newXRenzoBridgeL1 == address(0)) revert InvalidZeroInput(); emit XRenzoBridgeL1Updated(_newXRenzoBridgeL1, xRenzoBridgeL1); xRenzoBridgeL1 = _newXRenzoBridgeL1; }
Consider introducing a whenPaused
modifier or check if contract is paused before updating the address.
Lines of code* https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Rewards/RewardHandler.sol#L52
RewardHandler holds the _forwardETH
function which is responsible transferring ETH
in the contract to the rewarddesitnation
.
THe function can be triggered through the receive
and forwardRewards
functions. forwardRewards
function is protected by the onlyNativeEthRestakeAdmin
modifier, hinting that only the Native Eth Restake Admin should be able to call the function, but the receive function can be triggered by anyone. They both perform the same function.
/// @dev Forwards all native ETH rewards to the DepositQueue contract /// Handle ETH sent to this contract from outside the protocol that trigger contract execution - e.g. rewards receive() external payable nonReentrant { _forwardETH(); }
/// @dev Forwards all native ETH rewards to the DepositQueue contract /// Handle ETH sent to this contract from validator nodes that do not trigger contract execution - e.g. rewards function forwardRewards() external nonReentrant onlyNativeEthRestakeAdmin { _forwardETH(); }
Important to also note that the forwardRewards
function is not marked payable, so admin can't directly send ETH through the function. If he desires to send ETH, he has to send it through the receive function which triggers _forwardETH
anyway.
If truly desired that only the admin should be able to forward ETH, consider removing _forwardETH
from receive
/// @dev Forwards all native ETH rewards to the DepositQueue contract /// Handle ETH sent to this contract from outside the protocol that trigger contract execution - e.g. rewards receive() external payable nonReentrant { // _forwardETH(); }
On the contrary, the onlyNativeEthRestakeAdmin
modifier can be removed from forwardRewards
so anyone can call it, or removing the function entirely.
/// @dev Forwards all native ETH rewards to the DepositQueue contract /// Handle ETH sent to this contract from validator nodes that do not trigger contract execution - e.g. rewards function forwardRewards() external nonReentrant /**onlyNativeEthRestakeAdmin */{ _forwardETH(); }
Links to affected code *
RestakeManagerAdmin can add or remove collateral tokens as its needed. Upon adding collateral token, by default, the Tvl Limit is non existent, meaning users can deposit as much as they want without any restrictions.
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { ... // 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]) revert MaxTokenTVLReached(); } ... }
RestakeManagerAdmin can set a tvl limit for the collateral token which updates the collateralTokenTvlLimits mapping.
function setTokenTvlLimit(IERC20 _token, uint256 _limit) external onlyRestakeManagerAdmin { // Verify collateral token is in the list - call will revert if not found getCollateralTokenIndex(_token); // Set the limit collateralTokenTvlLimits[_token] = _limit; emit CollateralTokenTvlUpdated(_token, _limit); }
However, upon removing collateral, this mapping is not cleared, the token is only removed.
function removeCollateralToken( IERC20 _collateralTokenToRemove ) external onlyRestakeManagerAdmin { // Remove it from the list uint256 tokenLength = collateralTokens.length; for (uint256 i = 0; i < tokenLength; ) { if (address(collateralTokens[i]) == address(_collateralTokenToRemove)) { collateralTokens[i] = collateralTokens[collateralTokens.length - 1]; collateralTokens.pop(); emit CollateralTokenRemoved(_collateralTokenToRemove); return; } unchecked { ++i; } } // If the item was not found, throw an error revert NotFound(); }
So upon readding the collateral token, the previously set tvllimit will still hold. Depending on how the protocol desires to work with collateral tokens that are readded, this can lead unexpected behaviours and reversions.
Unless such a behaviour is desired, its more advisable to remove tvl limits upon collateral token removal.
function removeCollateralToken( IERC20 _collateralTokenToRemove ) external onlyRestakeManagerAdmin { // Remove it from the list uint256 tokenLength = collateralTokens.length; for (uint256 i = 0; i < tokenLength; ) { if (address(collateralTokens[i]) == address(_collateralTokenToRemove)) { collateralTokenTvlLimits[_collateralTokenToRemove] = 0; ++++ collateralTokens[i] = collateralTokens[collateralTokens.length - 1]; collateralTokens.pop(); emit CollateralTokenRemoved(_collateralTokenToRemove); return; } unchecked { ++i; } } // If the item was not found, throw an error revert NotFound(); }
_rewardDestination
is not the RewardHandler addressLines of code*
Setting RewardHandler
as _rewardDestination
will trigger an OOG infinte loop scenario whenever the contract recevies ETH. Or when the NativeEthRestakeAdmin tries to forward rewards.
The _forwardETH
function will send the balance to the rewardDestination
, in this case, RewardHandler
which holds a receive
function.
function _forwardETH() internal { uint256 balance = address(this).balance; if (balance == 0) { return; } (bool success, ) = rewardDestination.call{ value: balance }(""); if (!success) revert TransferFailed(); }
The receive function will again trigger the _forwardETH
function and so the loop will continue, triggerring an out of gas error anytime ETH is deposted into the contract. The same applies for the forwardRewards
function.
receive() external payable nonReentrant { _forwardETH(); }
Include a check in setRewardDestination
function.
function setRewardDestination( address _rewardDestination ) external nonReentrant onlyRestakeManagerAdmin { if (address(_rewardDestination) == address(0x0) || address(_rewardDestination) == RewardsHandler) revert(); ++++ rewardDestination = _rewardDestination; emit RewardDestinationUpdated(_rewardDestination); } }
Lines of code*
RenzoOracle uses the same a stale window of about a day and 1 minute for all the tokens that are in use.
uint256 constant MAX_TIME_WINDOW = 86400 + 60;
This value is used in lookupTokenValue
and lookupTokenAmountFromValue
functions to ensure that returned prices are not stale. The returned values are then used to get token prices upon collateral token deposit or withdrawal and to calculate rewards and tvl.
Currently, the tokens in use have relatively the same heartbeat, so its not really much of a problem. https://data.chain.link/feeds/ethereum/mainnet/steth-eth https://data.chain.link/feeds/ethereum/mainnet/ezeth-eth
But, considering that collateral tokens can be added, some of the added tokens may have shorter heartbeats, which will lead to protocol working with stale prices.
Consider using a mapping to set each collateral token, to its desired heartbeat. That way differences in hearbeats can be properly handled.
Links to affected code *
The protocol uses its own oracle for receiving current prices of ezETH price. The data can be updated by either the receiver or the owner. This opens an opportunity for a sandwich attack as users can monitor the mempool for price update transactions to gain an unfair advantage depending on what direction the price update is going to be. The updatePriceByOwner
function is more vulnerable to this since the function doesn't exist in an atomic form, unlike the updatePrice
function.
function updatePriceByOwner(uint256 price) external onlyOwner { return _updatePrice(price, block.timestamp); }
Links to affected code *
There are cases where chains have changed their native tokens, although rare. Polygon chain for instance is currently in the process of changing their native token from MATIC
to POL
planning on fully replacing its token status. A change like this on any of the supported chains will break functionality of XERC20Lockbox.sol and possibly force redeployment. Withdrawals will be virtually impossible leading to locked funds.
function _withdraw(address _to, uint256 _amount) internal { ... if (IS_NATIVE) { (bool _success, ) = payable(_to).call{ value: _amount }(""); //@note will no longer work as token is no longer native on chain. if (!_success) revert IXERC20Lockbox_WithdrawFailed(); } else { ERC20.safeTransfer(_to, _amount); } }
Consider introducing a function to toggle the native status of the token.
inflationPercentage
is misspeltuint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) / (_currentValueInProtocol + _newValueAdded); // Calculate the new supply uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - inflationPercentaage);
Links to affected code *
There's no minimum amount that can be deposited leading to the inflationPercentaage
risking being forcefully scaled up, to either mint large amount of ezeth or prevent future depositors from being able to deposit.
The calculateMintAmount
function is called when users call the deposit
/depositETH
function is called in RestakManager.sol.This allows for depositing as little as 1 wei of tokens. A malicious user can use this to his advantage to manipulate the inflationPercentaage
parameter so as to mint large amount of ezETH or prevent future depositors from being able to deposit.
The function initially checks if the exisiting ezETHSupply is 0, upon which the deposited amount is returned. By depositing 1 wei of token the returned ezETHToMint is 1, and 1 wei of ezETH is minted to the user.
Depending on how much TVL is available in the protocol at this moment, the next deposit by the same user might will result in subsequent user deposits that less than the amount the attacker had initially sent to return them zero tokens or not being able to mint at all.
The way UniswapV2 prevents this is by requiring a minimum deposit amount and sending 1000 initial shares to the zero address to make this attack more expensive.
The contract performs transfers and approvals without checking for token balances before and after. This will lead to issues as the protcol aims to support stETH
which is a rebasing token. It's increase or decrease as the balance of Eth in certain pools changes to maintain peg can also break the various deposit, tvl and collateral limits.
stETH
/ETH
chainlink oracle has too long of heartbeat and deviation thresholdProtocol uses stETH
/ETH
chainlink oracle to get its price. This can be problematic (due to the possibility of arbitrage) since stETH/ETH has a 24 hour heartbeat and a 2% deviation threshold. This deviation in price could easily cause loss of funds to the user. The large hearbeat and deviation means that the price can move up to 2% or 24 hours before a price update is triggered. The result is that the on-chain price could be much different than the true stETH price. Consider using Use the stETH
/USD
oracle instead because it has a 1-hour heartbeat and a 1% deviation threshold.
#0 - CloudEllie
2024-05-13T14:05:52Z
#1 - c4-judge
2024-05-24T09:42:16Z
alcueca marked the issue as grade-a
#2 - alcueca
2024-05-27T11:21:40Z
L2 upgraded L16 mentions stETH as a rebasing token, but doesn't mention the actual issue which is recording an amount in the withdrawal queue that might not be there when claim is called. L17 not duplicate of any valid medium, and just using oracles with long heartbeats is not a vulnerability L4 withdrawn
#3 - thebrittfactor
2024-06-03T12:40:30Z
For awarding purposes, staff have marked as 2nd place
. Also noting there was a tied for 2nd/3rd place.