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: 6/122
Findings: 10
Award: $2,329.44
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: LessDupes
Also found by: 0rpse, 0xAadi, 0xCiphky, 0xhacksmithh, 0xnightfall, FastChecker, KupiaSec, NentoR, SBSecurity, Tendency, adam-idarrha, aman, araj, baz1ka, bigtone, fyamf, jokr, kennedy1030, maxim371, mussucal, p0wd3r, zigtur
13.5262 USDC - $13.53
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Delegation/OperatorDelegator.sol#L327 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Delegation/OperatorDelegator.sol#L193 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L274
The getTokenBalanceFromStrategy function in the OperatorDelegator contract retrieves the underlying token amount plus queued withdrawal shares for an operator's specified token. This calculation is used by the calculateTVLs function to determine the TVLs for each operator delegator by individual token, total for each OD, and total for the protocol.
The issue with the current implementation is that the function uses queuedShares[address(this)] to check if there are currently any queued shares for the specified token. If there are no queued shares, it returns just the underlying token amount.
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)]); }
However, if we look at the queueWithdrawals function, the queuedShares mapping saves queued shares for a specific token using the token address as the key. Therefore, the getTokenBalanceFromStrategy function should also be using the token address as the key to correctly perform the check.
function queueWithdrawals(IERC20[] calldata tokens, uint256[] calldata tokenAmounts) external nonReentrant onlyNativeEthRestakeAdmin returns (bytes32) { ... for (uint256 i; i < tokens.length;) { ... // track shares of tokens withdraw for TVL queuedShares[address(tokens[i])] += queuedWithdrawalParams[0].shares[i]; unchecked { ++i; } } ... return withdrawalRoot; }
To address this issue, the getTokenBalanceFromStrategy function in the OperatorDelegator contract can be modified to perform the correct check as shown below.
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) { return queuedShares[address(token)] == 0 // Change from address(this) to address(token) ? tokenStrategyMapping[token].userUnderlyingView(address(this)) : tokenStrategyMapping[token].userUnderlyingView(address(this)) + tokenStrategyMapping[token].sharesToUnderlyingView(queuedShares[address(token)]); }
Other
#0 - C4-Staff
2024-05-15T14:35:31Z
CloudEllie marked the issue as duplicate of #291
#1 - c4-judge
2024-05-16T10:43:42Z
alcueca marked the issue as satisfactory
🌟 Selected for report: guhu95
Also found by: 0rpse, 0x007, 0x73696d616f, 0xCiphky, 0xabhay, Audinarey, Bauchibred, Fassi_Security, GalloDaSballo, GoatedAudits, KupiaSec, LessDupes, MSaptarshi, OMEN, Ocean_Sky, RamenPeople, SBSecurity, Tendency, WildSniper, aslanbek, bill, blutorque, crypticdefense, cu5t0mpeo, d3e4, gjaldon, grearlake, gumgumzum, honey-k12, ilchovski, jokr, josephdara, kennedy1030, p0wd3r, peanuts, stonejiajia, t0x1c, tapir, underdog, zzykxx
0.4071 USDC - $0.41
The METHShim contract uses the _getMETHData function to retrieve the price of 1 mETH in ETH from the mETH staking contract. Although the Meth token is not yet supported, "it can technically be supported using the METHShim contract if required". However, there is currently an unhandled edge case that can cause losses for both users and the protocol.
As mentioned in the Mantle docs:
To mitigate this, the mantle staking contract implements the following mechanism:
However, in the RestakeManager contract, users can create a withdraw request at any time as long as there are currently enough funds for one of the collateral tokens or ETH in the withdrawal queue contract. This withdraw request will lock in the amount at the current exchange rate and be able to be claimed once a cooldown period has passed.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant { ... // Calculate amount to Redeem in ETH uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(_amount, ezETH.totalSupply(), totalTVL); // update amount in claim asset, if claim asset is not ETH if (_assetOut != IS_NATIVE) { // Get ERC20 asset equivalent amount amountToRedeem = renzoOracle.lookupTokenAmountFromValue(IERC20(_assetOut), amountToRedeem); } // revert if amount to redeem is greater than withdrawBufferTarget if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer(); // increment the withdrawRequestNonce withdrawRequestNonce++; // add withdraw request for msg.sender withdrawRequests[msg.sender].push( WithdrawRequest(_assetOut, withdrawRequestNonce, amountToRedeem, _amount, block.timestamp) ); ... }
Given that this issue is specific to the mETH exchange rate, consider implementing a mechanism to handle this edge case when retrieving the price. One approach could be to add functionality that allows disabling the Oracle, preventing deposits and withdrawals using mETH, until manually re-enabled by an admin. This would help mitigate the risk of users taking advantage of the exchange rate discrepancy caused by major slashing events.
Invalid Validation
#0 - jatinj615
2024-05-14T14:03:41Z
mETH is not being used in the protocol.
#1 - C4-Staff
2024-05-15T14:18:24Z
CloudEllie marked the issue as primary issue
#2 - c4-judge
2024-05-20T03:50:31Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-20T03:50:43Z
alcueca marked the issue as duplicate of #326
#4 - c4-judge
2024-05-24T10:10:00Z
alcueca marked the issue as satisfactory
🌟 Selected for report: zzykxx
Also found by: 0x007, 0xCiphky, GalloDaSballo, GoatedAudits, LessDupes, fyamf, jokr, mt030d
336.3531 USDC - $336.35
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L168 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L227 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L1/xRenzoBridge.sol#L139 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L592
The xRenzoDeposit contract facilitates the minting of xezETH tokens on Layer 2 (L2) in exchange for ETH or WETH tokens.
Specifically, the internal _deposit function exchanges the deposit tokens for nextWETH and mints xezETH tokens to the user. Subsequently, the funds in the contract are aggregated and swept by an admin down to Layer 1.
Upon reaching the L1 contract, the collateral is unwrapped and deposited into Renzo. The ezETH from the deposit is then sent to the lockbox to be wrapped into xezETH, and the xezETH is burned, as it is already minted on L2.
function sweep() public payable nonReentrant { ... // Get the balance of nextWETH in the contract uint256 balance = collateralToken.balanceOf(address(this)); ... // Approve it to the connext contract collateralToken.safeApprove(address(connext), balance); // Need to send some calldata so it triggers xReceive on the target bytes memory bridgeCallData = abi.encode(balance); connext.xcall{value: msg.value}( bridgeDestinationDomain, bridgeTargetAddress, address(collateralToken), msg.sender, balance, 0, // Asset is already nextWETH, so no slippage will be incurred bridgeCallData ); ... }
Two potential problems exist:
To reduce the discrepancy in price updates and deposits, increase the frequency of updates and deposits. A more permanent solution could involve minting and bridging for each deposit to the L1 and returning the minted shares' value on the L2, allowing users to mint the exact amount. However, this approach may incur higher fees.
Context
#0 - c4-judge
2024-05-16T08:48:06Z
alcueca marked the issue as satisfactory
🌟 Selected for report: pauliax
Also found by: 0rpse, 0x73696d616f, 0xAadi, 0xCiphky, 0xPwned, 0xhacksmithh, 0xnev, 0xnightfall, 0xordersol, 14si2o_Flint, Aamir, Aymen0909, BiasedMerc, DanielArmstrong, Fassi_Security, FastChecker, GoatedAudits, Greed, KupiaSec, LessDupes, Maroutis, NentoR, OMEN, SBSecurity, Stefanov, TheFabled, adam-idarrha, ak1, aman, araj, aslanbek, b0g0, baz1ka, bigtone, blutorque, carlitox477, carrotsmuggler, crypticdefense, eeshenggoh, fyamf, gesha17, gjaldon, grearlake, guhu95, honey-k12, hunter_w3b, ilchovski, josephdara, kinda_very_good, lanrebayode77, m_Rassska, maxim371, mt030d, mussucal, oakcobalt, p0wd3r, peanuts, rbserver, shui, siguint, t0x1c, tapir, twcctop, ustazz, xg, zhaojohnson, zigtur, zzykxx
0.0026 USDC - $0.00
The calculateTVL function in RestakeManager.sol calculates the TVLs for each operator delegator by individual tokens, total for each OD, and total for the protocol. However, it currently incorrectly calculates the totalWithdrawalQueueValue.
The issue arises from the parameter passed to renzoOracle.lookupTokenValue, where collateralTokens[i] is used throughout the inside loop instead of collateralTokens[j] , causing the same collateral token to be used for all iterations.
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;) { ... // record token value of withdraw queue if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue)); } unchecked { ++j; } } ... unchecked { ++i; } } ... return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL); }
Consequently, the totalWithdrawalQueueValue will be incorrectly calculated as the same collateral token will be used throughout the loop with different collateral token balances.
Modify the following code to pass collateralTokens[j] instead of collateralTokens[i].
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue)); }
Context
#0 - c4-judge
2024-05-16T10:27:52Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-16T10:38:47Z
alcueca changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-05-16T10:39:08Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-20T04:26:27Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-23T13:47:21Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: zigtur
Also found by: 0x73696d616f, 0xBeastBoy, 0xCiphky, Aymen0909, FastChecker, LessDupes, NentoR, Sathish9098, TECHFUND, TheFabled, ak1, bigtone, cu5t0mpeo, eeshenggoh, guhu95, ilchovski, josephdara, ladboy233, mt030d, oakcobalt, rbserver, t0x1c, tapir, xg
2.6973 USDC - $2.70
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L206 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L279
The Renzo protocol documentation mentions the DEPOSIT_WITHDRAW_PAUSER role as one of the trusted roles in the protocol with the ability to pause/unpause deposits and withdrawals.
This functionality is correctly implemented in the deposit function, which uses the notPaused modifier controlled by the DEPOSIT_WITHDRAW_PAUSER role. However, although the PausableUpgradeable contract is inherited by the WithdrawQueue contract and initialized, the notPaused modifier is not added to the withdraw or claim functions.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant { ... }
function claim(uint256 withdrawRequestIndex) external nonReentrant { ... }
Consequently, neither the withdraw nor the claim functions will be able to be paused if needed. This could be problematic if the functionality needs to be paused due to the identification of a vulnerability or during a contract upgrade.
Additionally, the pausing ability is currently configured to another role contrary to the docs. However, this does not have any harmful impact.
To address this issue, add the notPaused modifier to the withdraw and claim functions in the WithdrawQueue contract to allow the protocol to pause these functions if needed.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant notPaused { ... }
function claim(uint256 withdrawRequestIndex) external nonReentrant notPaused { ... }
Access Control
#0 - c4-judge
2024-05-16T10:50:39Z
alcueca marked the issue as satisfactory
#1 - jatinj615
2024-05-29T06:49:25Z
Duplicate of #569
🌟 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
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L274 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L206
The deposit and withdraw functions in the RestakeManager contract utilize the totalTVL (Total Value Locked) to determine the mint and redeem amounts for a user. However, the totalTVL may change between the transaction's submission and fulfillment due to various factors, including fluctuations in collateral/ETH balances or price changes.
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; }
Therefore, the lack of slippage control on these functions could lead to users receiving a much worse rate then anticipated. This lack of control could lead to user losses or at the very least discourage users from interacting with the protocol.
Allows users to set a minimum amount out for deposits and withdrawals.
function deposit(IERC20 _collateralToken, uint256 _amount, uint256 _minOut, uint256 _referralId) public nonReentrant notPaused { ... // Calculate how much ezETH to mint uint256 ezETHToMint = renzoOracle.calculateMintAmount(totalTVL, collateralTokenValue, ezETH.totalSupply()); require(ezETHToMint >= _minOut) // ADD HERE // Mint the ezETH ezETH.mint(msg.sender, ezETHToMint); // Emit the deposit event emit Deposit(msg.sender, _collateralToken, _amount, ezETHToMint, _referralId); }
function withdraw(uint256 _amount, address _assetOut, uint256 _minOut) external nonReentrant { ... // Calculate amount to Redeem in ETH uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(_amount, ezETH.totalSupply(), totalTVL); // update amount in claim asset, if claim asset is not ETH if (_assetOut != IS_NATIVE) { // Get ERC20 asset equivalent amount amountToRedeem = renzoOracle.lookupTokenAmountFromValue(IERC20(_assetOut), amountToRedeem); } require(amountToRedeem >= _minOut) // ADD HERE // revert if amount to redeem is greater than withdrawBufferTarget if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer(); // increment the withdrawRequestNonce withdrawRequestNonce++; // add withdraw request for msg.sender withdrawRequests[msg.sender].push( WithdrawRequest(_assetOut, withdrawRequestNonce, amountToRedeem, _amount, block.timestamp) ); ... }
Context
#0 - c4-judge
2024-05-17T13:28:55Z
alcueca marked the issue as satisfactory
133.552 USDC - $133.55
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/L2/xRenzoDeposit.sol#L414 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L592 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20.sol#L328
The sweep function in the xRenzoDeposit contract takes the balance of nextWETH in the contract and bridges it down to the L1. The L1 contract will then unwrap it, deposit it in Renzo, and lock up the ezETH in the lockbox on L1.
A possible problem with the function is that it doesn’t let the user calling the function specify the amount being swept but will instead always sweep the full balance in the contract.
function sweep() public payable nonReentrant { // Verify the caller is whitelisted if (!allowedBridgeSweepers[msg.sender]) { revert UnauthorizedBridgeSweeper(); } // Get the balance of nextWETH in the contract uint256 balance = collateralToken.balanceOf(address(this)); ... connext.xcall{value: msg.value}( bridgeDestinationDomain, bridgeTargetAddress, address(collateralToken), msg.sender, balance, 0, // Asset is already nextWETH, so no slippage will be incurred bridgeCallData ); ... }
The reason this is problematic is because there are restrictions on the L1 side which will make the call fail if not met.
Once a transaction fails in the receiving contract, it will have to be manually deposited, once possible by the team, as the full amount will still be deposited in the receiving contract but not to Renzo.
Note: If the Connext bridge transaction reverts on the receiving contract, the amount will still be deposited in the contract but not to Renzo.
This causes some more problems:
Modify the sweep function to allow the user to specify the amount being swept, rather than sweeping the full balance in the contract. This will allow for more control over the bridging process and reduce the risk of exceeding limits on the L1 side.
Additionally, consider implementing checks and limits on the L2 side to prevent the minting of amounts that could exceed daily burning limits or TVL thresholds.
DoS
#0 - jatinj615
2024-05-15T11:59:41Z
The max TVL configuration was used in the private beta phase which is currently set to 0. i.e. no maxTVL enforcement. and will be removed from the system. As for the xERC20 burning limits for xRenzoBridge contract protocol can configure the limit accordingly and tx will be retried by connext bridge to xReceive.
#1 - C4-Staff
2024-05-15T14:34:23Z
CloudEllie marked the issue as primary issue
#2 - alcueca
2024-05-16T10:06:45Z
The impact doesn't seem to warrant a High severity.
Tokens would have been minted on the L2 side but not accounted for inside the protocol on the L1 side. This means that the value sitting idle could leave the L2 tokens undercollateralized.
That's not great, but not immediately fatal.
The protocol will be earning less rewards than it should, as the value will be sitting idle but shares have been minted for it, so all users will get less rewards.
That's some yield lost, but again not a large proportion of the yield in the protocol.
The team having to manually deposit tokens will mean this will not count towards the bridge's limits, and therefore the limit will be compromised as it can be bypassed for that duration until refilled.
That's not fatal either.
#3 - c4-judge
2024-05-16T10:06:52Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-16T10:07:11Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2024-05-16T10:07:14Z
alcueca marked the issue as selected for report
#6 - jatinj615
2024-05-25T16:33:40Z
@alcueca , #392 should be merged with this one.
#7 - s1n1st3r01
2024-05-25T18:26:35Z
I believe that both should be merged/duped too. sweep()
will revert if the maxTVL
was hit on L1 which means that this will occur if users are depositing on L2 while maxTVL
is capped/maxed-out in L1 (which is what #392 is discussing).
#8 - c4-judge
2024-05-28T05:12:54Z
alcueca marked the issue as duplicate of #373
#9 - c4-judge
2024-05-30T06:12:10Z
alcueca marked the issue as not selected for report
133.552 USDC - $133.55
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/L2/xRenzoDeposit.sol#L168 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L592
The RestakeManager contract on the L1 side sets a maxDepositTVL limit on the depositETH function, causing it to revert when the cap is reached. However, this restriction is not applied to L2 deposits.
On the L2 side, the depositETH function in the xRenzoDeposit contract can establish daily minting limits, but there is currently no method to monitor or restrict deposits based on the maxDepositTVL limit set on the L1 side.
function depositETH(uint256 _minOut, uint256 _deadline) external payable nonReentrant returns (uint256) { if (msg.value == 0) { revert InvalidZeroInput(); } // Get the deposit token balance before uint256 depositBalanceBefore = depositToken.balanceOf(address(this)); // Wrap the deposit ETH to WETH IWeth(address(depositToken)).deposit{value: msg.value}(); // Get the amount of tokens that were wrapped uint256 wrappedAmount = depositToken.balanceOf(address(this)) - depositBalanceBefore; // Sanity check for 0 if (wrappedAmount == 0) { revert InvalidZeroOutput(); } return _deposit(wrappedAmount, _minOut, _deadline); }
As a result, any deposits that push the threshold over the limit will result in a transaction failure in the receiving contract after a sweep is performed. The team will then need to manually deposit this amount when feasible.
Note: If the Connext bridge transaction reverts on the receiving contract, the amount will still be deposited in the contract but not to Renzo.
This scenario will result in tokens being minted on the L2 side without being recorded within the protocol on the L1 side. This discrepancy could potentially leave the L2 tokens undercollateralized. Additionally, the protocol would earn fewer rewards than expected, as the value would remain idle despite shares already being minted for it, resulting in losses for all users. Furthermore, the maxDepositTVL is compromised, as it is not enforced when minting tokens on the L2 side.
Implement a mechanism to track and limit deposits in relation to the maxDepositTVL limit on the L1 side.
Access Control
#0 - jatinj615
2024-05-24T15:04:31Z
maxDepositTVL limit was used in early beta phase. Currently set to 0. Not enforcing any deposit limit. Can be removed.
#1 - c4-judge
2024-05-24T15:46:51Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2024-05-24T15:48:20Z
alcueca marked the issue as selected for report
#3 - c4-judge
2024-05-28T05:09:57Z
alcueca marked the issue as duplicate of #287
#4 - c4-judge
2024-05-28T05:12:52Z
alcueca marked the issue as duplicate of #373
#5 - c4-judge
2024-05-30T06:12:01Z
alcueca marked the issue as not selected for report
🌟 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.0522 USDC - $0.05
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Delegation/OperatorDelegator.sol#L143
The deposit function in the RestakeManager contract enables users to deposit ERC20 whitelisted collateral tokens into the protocol. It first checks the withdrawal buffer and fills it up using some or all of the deposited amount if it is below the buffer target. The remaining amount is then transferred to the operator delegator and deposited into EigenLayer.
The current issue with this implementation is that if the amount deposited is less than bufferToFill, the full amount will be used to fill the withdrawal buffer, leaving the amount value as zero.
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); ... // 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); ... }
Subsequently, the function will approve the zero amount to the operator delegator and call deposit on the operator delegator. However, as seen in the OperatorDelegator contract's deposit function below, a zero deposit will be reverted.
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); }
To address this issue, the deposit function can be modified to only approve the amount to the operator delegator and call deposit on the operator delegator if the amount is greater than zero.
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); ... // 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) { // ADD HERE // Transfer the tokens to the operator delegator _collateralToken.safeApprove(address(operatorDelegator), _amount); // Call deposit on the operator delegator operatorDelegator.deposit(_collateralToken, _amount); } ... }
Other
#0 - c4-judge
2024-05-20T05:08:41Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-20T05:16:12Z
alcueca marked the issue as selected for report
1841.3657 USDC - $1,841.37
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/L1/xRenzoBridge.sol#L210 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RateProvider/BalancerRateProvider.sol#L29 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/L2/xRenzoDeposit.sol#L310
The sendPrice function in the xRenzoBridge contract calls the getRate function to retrieve the current price of ezETH to ETH and broadcasts it to Layer 2 networks. Subsequently, the price is received by either the ConnextReceiver or CCIPReceiver and invokes the updatePrice function in the xRenzoDeposit contract on L2. However, a potential opportunity exists where a user can monitor the L1 mempool for the sendPrice function call, observe the new price, and sandwich it if profitable.
/** * @notice Exposes the price via getRate() * @dev This is required for a balancer pool to get the price of ezETH * @return uint256 . */ function getRate() external view override returns (uint256) { return lastPrice; }
Upon detecting a favourable price change, the user could mint xezETH on L2 before the price adjustment takes effect. Subsequently, when the price change is finalized, the user can sell the xezETH on a protocol that reads the price from the getRate function in the xRenzoDeposit contract, thus profiting from the price discrepancy.
Since there are two fees associated with L2 deposits, this should help minimize this problem. Additionally, as ezETH is more on the stable side, it is less prone to significant price fluctuations. However, continuous monitoring and adjustment of the update frequency may be necessary to prevent potential exploitation.
Other
#0 - c4-judge
2024-05-16T08:45:02Z
alcueca marked the issue as not a duplicate
#1 - c4-judge
2024-05-16T08:45:52Z
alcueca marked the issue as primary issue
#2 - jatinj615
2024-05-21T13:12:52Z
Expected behaviour.
#3 - alcueca
2024-05-23T09:37:50Z
#135 offers a complete mitigation (minting only on L1, instead of bridging prices.)
#4 - c4-judge
2024-05-23T09:37:54Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2024-05-23T09:38:34Z
alcueca marked the issue as selected for report
🌟 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
0 USDC - $0.00
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L206 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L279 https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L129
The protocol implements a two-step process for withdrawals: first, the withdraw function in the WithdrawQueue contract is called by a user to create a withdrawal request. Once created, a user can call the claim function to claim the withdrawal request after the cooldown period has passed.
The issue with the current implementation is that the claim function uses the coolDownPeriod state variable to check if a user's withdrawal request has passed the period and met the condition.
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(); ... }
This coolDownPeriod can be modified at any time by the WithdrawQueueAdmin, meaning that since the claim function uses the state variable, previous withdrawal requests that were finalized at the initial cooldown period will now have that time period changed and could have a longer wait.
function updateCoolDownPeriod(uint256 _newCoolDownPeriod) external onlyWithdrawQueueAdmin { if (_newCoolDownPeriod == 0) revert InvalidZeroInput(); emit CoolDownPeriodUpdated(coolDownPeriod, _newCoolDownPeriod); coolDownPeriod = _newCoolDownPeriod; }
The protocol should calculate and add the release time to the specific withdrawal request. That way, any changes to the cooldown period will only affect future withdrawal requests.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant { ... uint256 releaseTime = block.timestamp + coolDownPeriod; //ADD HERE // add withdraw request for msg.sender withdrawRequests[msg.sender].push( WithdrawRequest(_assetOut, withdrawRequestNonce, amountToRedeem, _amount, block.timestamp, releaseTime) //ADD HERE ); ... }
Other
#0 - c4-judge
2024-05-28T17:23:55Z
alcueca marked the issue as duplicate of #607
#1 - c4-judge
2024-05-30T05:34:41Z
alcueca marked the issue as unsatisfactory: Invalid
#2 - c4-judge
2024-05-30T05:34:51Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-05-30T05:36:09Z
alcueca marked the issue as grade-b