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: 79/122
Findings: 2
Award: $1.48
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
Without slippage protection, users are at risk of receiving fewer shares than expected when depositing tokens into strategies. This can occur due to front-running or other market manipulations that affect the price or value of the shares between the initiation and execution of the deposit transaction.
The _deposit
function approves the strategyManager
to spend the tokens on behalf of the contract and then calls strategyManager.depositIntoStrategy
to deposit the tokens into the mapped strategy. Because a slippage protection is not implemented, the user may receive a lesser amount that expected.
The function _deposit
is used to deposit tokens into various strategies managed by the strategyManager. The function approves the strategyManager to spend tokens and then deposits these tokens into a strategy, receiving shares in return. However, the function does not check whether the number of shares received is within an acceptable range defined by the user, thus exposing the user to potential slippage.
function _deposit(IERC20 _token, uint256 _tokenAmount) internal returns (uint256 shares) { // Approve the strategy manager to spend the tokens _token.safeApprove(address(strategyManager), _tokenAmount); // Deposit the tokens via the strategy manager return strategyManager.depositIntoStrategy(tokenStrategyMapping[_token], _token, _tokenAmount); }
As seen, the function directly returns the shares received from the strategyManager.depositIntoStrategy
(an external call inherited from IStrategyManager) call without any checks for the amount of shares against a minimum expected value (minSharesOut). This could lead to a situation where, due to unfavorable changes in the underlying strategy's state (possibly due to other transactions being mined first), the user ends up with significantly fewer shares than expected.
VsCode Manual review
The developers could consider enhancing the _deposit
function by adding a parameter for minSharesOut
, which represents the minimum number of shares the depositor expects to receive. The function should then validate that the shares returned from the deposit operation meet or exceed this minimum threshold. If not, the transaction should revert to protect the user from slippage.
Something like this:
- function _deposit(IERC20 _token, uint256 _tokenAmount) internal returns (uint256 shares) { + function _deposit(IERC20 _token, uint256 _tokenAmount, uint256 minSharesOut) internal returns (uint256 shares) { // Approve the strategy manager to spend the tokens _token.safeApprove(address(strategyManager), _tokenAmount); // Deposit the tokens via the strategy manager - return - strategyManager.depositIntoStrategy(tokenStrategyMapping[_token], _token, _tokenAmount); + shares = strategyManager.depositIntoStrategy(tokenStrategyMapping[_token], _token, _tokenAmount); + // Check for slippage + require(shares >= minSharesOut, "Slippage exceeded"); + return shares; }
Token-Transfer
#0 - c4-judge
2024-05-17T13:28:57Z
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
0 USDC - $0.00
To maintain clarity and conciseness, we have selectively shortened the provided code snippets to highlight the most pertinent parts. Some sections may be condensed, and certain references are accessible through search commands due to the project's scope and time limitations.
Note to the judge: To avoid cluttering the judging repo, we have included some potentially subjective findings in this report that could be considered either medium or low severity
. We have indicated these instances within the reports. If the judge deems it appropriate, we would appreciate these findings being upgraded accordingly."
Issue ID | Description |
---|---|
QA-01 | Hardcoded Gas Limit in CCIP Message Sending Leads to Potential Excess Gas Usage |
QA-02 | Lack of Solvency Checks in removeCollateralToken Function |
QA-03 | Potential for LINK token approval overwriting in sendPrice function |
QA-04 | Unchecked LINK Token Approval in sendPrice Function |
QA-05 | abi.encodePacked() should not be used with dynamic types when passing the result to a hash function such as keccak256() |
QA-06 | Incorrect maxDepositTVL value may prevent users from depositing funds |
QA-07 | Absence of timelock to critical functions |
QA-08 | Redundant usage of safecast |
QA-09 | Logic Issue |
QA-10 | External calls inside a loop in lookupTokenValues function |
QA-11 | Lack of Event for Failure in sendPrice Function |
QA-12 | setTokenTvlLimit function name and event name are somewhat misleading |
QA-13 | Lack of check of the return value |
QA-14 | External Calls inside the calculateTVLs function |
QA-15 | Ignored return value in RestakeManager |
QA-16 | Missing zero-check on the feeAddress assignment in setFeeConfig function |
QA-17 | Mix-up in comments |
QA-18 | Typos (Multiple Instances) |
QA-19 | ERC20 Approve/Transfer Call is Not Safe |
QA-20 | The nonReentrant modifier should occur before all other modifiers |
QA-21 | PUSH0 is not supported by all chains |
QA-22 | Large literal values multiples of 10000 can be replaced with scientific notation |
QA-23 | Modifiers invoked only once can be shoe-horned into the function |
QA-24 | Event is missing indexed fields |
The hardcoded gas limit of 200,000 in the CCIP message sending function may lead to unnecessary gas consumption, especially when the message is sent to an externally owned account (EOA) that does not require complex computations or the execution of a ccipReceive()
function. This can result in higher transaction costs and reduced efficiency in cross-chain communication.
Borderline medium/low as this could cause loss a financial loss to the user.
In the sendPrice
function , the gasLimit
is hardcoded within the extraArgs
parameter for CCIP messages, as shown below:
extraArgs: Client._argsToBytes( Client.EVMExtraArgsV1({ gasLimit: 200_000 }) ),
This setting is used regardless of the nature of the destination address, which could be an EOA that does not necessitate such a high gas limit. The relevant part of the function is as follows:
for (uint256 i = 0; i < _destinationParam.length; ) { Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({ receiver: abi.encode(_destinationParam[i]._renzoReceiver), data: _callData, tokenAmounts: new Client.EVMTokenAmount[](0), extraArgs: Client._argsToBytes( Client.EVMExtraArgsV1({ gasLimit: 200_000 }) ), feeToken: address(linkToken) }); // Sending logic... }
This implementation does not account for scenarios where a lower gas limit could suffice, potentially leading to wasted gas and increased costs.
sendPrice
function to accept a gasLimit
parameter or determine it dynamically based on the type of the receiver address. This allows for adjusting the gas limit based on actual needs rather than using a one-size-fits-all value.For production code, it is recommended to adhere to the following best practices:
Check more details here: https://docs.chain.link/ccip/getting-started
removeCollateralToken
FunctionThe current implementation of the removeCollateralToken
function in the RestakeManager.sol
contract does not perform any checks related to the solvency or financial health of the system after removing a token. This could potentially lead to a situation where the removal of a token compromises the overall solvency or financial stability of the protocol, putting both current and future users at risk.
Borderline medium/low
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(); }
Scenario:
removeCollateralToken
function to remove Token A from the list of supported tokens.Implement solvency checks in the removeCollateralToken
function to ensure that the removal of a token does not compromise the financial stability of the protocol.
If the solvency checks fail, the function should revert the removal of the token and prevent the operation from proceeding.
Consider implementing a governance mechanism that allows stakeholders to vote on the removal of a collateral token, taking into account the potential impact on the system's solvency.
Here's an example of how the removeCollateralToken
function could be modified to include solvency checks:
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)) { + // Temporarily remove the token collateralTokens[i] = collateralTokens[collateralTokens.length - 1]; collateralTokens.pop(); + // Check system solvency or other financial health metrics + if (!checkSystemHealth()) { + // Revert the removal if the system health is compromised + collateralTokens.push(collateralTokens[i]); + collateralTokens[i] = _collateralTokenToRemove; + revert SystemHealthCompromised(); + } emit CollateralTokenRemoved(_collateralTokenToRemove); return; } unchecked { ++i; } } // If the item was not found, throw an error revert NotFound(); }
With these mitigation steps, the protocol can ensure that the removal of a collateral token does not inadvertently compromise the financial stability and protect the interests of all participants.
sendPrice
functionIf the linkRouterClient
doesn't spend the entire approved amount or modifies the allowance internally, subsequent iterations of the loop in the sendPrice
function might not have sufficient allowance to send the message. This could lead to failed message sending and unexpected behavior.
The relevant code snippet is as follows:
// approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK linkToken.approve(address(linkRouterClient), fees); // Send the message through the router and store the returned message ID bytes32 messageId = linkRouterClient.ccipSend( _destinationParam[i].destinationChainSelector, evm2AnyMessage );
In each iteration of the loop, the contract approves the linkRouterClient
to spend the exact amount of fees
in LINK tokens. However, if the linkRouterClient
doesn't spend the entire approved amount or modifies the allowance internally, the subsequent iterations might not have sufficient allowance to send the message.
linkRouterClient
before the loop:// Approve the maximum allowance for linkRouterClient linkToken.approve(address(linkRouterClient), type(uint256).max); for (uint256 i = 0; i < _destinationParam.length; ) { // ... // Remove the linkToken.approve call from inside the loop // ... }
uint256 currentAllowance = linkToken.allowance(address(this), address(linkRouterClient)); if (currentAllowance < fees) { linkToken.approve(address(linkRouterClient), fees); }
We recommend choosing the approach that aligns with the trust level in the linkRouterClient
contract and the desired balance between gas efficiency and granular control over the allowance.
sendPrice
FunctionBorderline medium/low
The sendPrice
function approves the linkRouterClient
to transfer the total fees
amount of LINK tokens before sending the messages. If the message sending fails after the approval, the approved LINK tokens remain available for the linkRouterClient
to spend, even though the messages were not sent successfully. This could lead to unauthorized spending of LINK tokens and potential loss of funds.
Here's the relevant code snippet:
// Get the fee required to send the message uint256 fees = linkRouterClient.getFee( _destinationParam[i].destinationChainSelector, evm2AnyMessage ); if (fees > linkToken.balanceOf(address(this))) revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees); // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK linkToken.approve(address(linkRouterClient), fees); // Send the message through the router and store the returned message ID bytes32 messageId = linkRouterClient.ccipSend( _destinationParam[i].destinationChainSelector, evm2AnyMessage );
The code approves the linkRouterClient
to spend the total fees
amount of LINK tokens before calling ccipSend
to send the messages. If the ccipSend
call fails, the approved LINK tokens remain available for the linkRouterClient
to spend.
To mitigate this issue, we recommend:
Move the LINK token approval after the ccipSend
call to ensure that LINK tokens are approved only when the message is sent successfully.
Approve only the individual fee
amount required for each message instead of the total fees
amount.
Here's an example of how the code can be modified:
// Get the fee required to send the message - uint256 fees = linkRouterClient.getFee( + uint256 fee = linkRouterClient.getFee( _destinationParam[i].destinationChainSelector, evm2AnyMessage ); - if (fees > linkToken.balanceOf(address(this))) + if (fee > linkToken.balanceOf(address(this))) revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees); - // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK - linkToken.approve(address(linkRouterClient), fees); - // Send the message through the router and store the returned message ID - bytes32 messageId = linkRouterClient.ccipSend( - _destinationParam[i].destinationChainSelector, - evm2AnyMessage - ); + // Send the message through the router and store the returned message ID + bytes32 messageId = linkRouterClient.ccipSend{value: fee}( + _destinationParam[i].destinationChainSelector, + evm2AnyMessage + ); + // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fee in LINK + linkToken.approve(address(linkRouterClient), fee);
By implementing these changes, the contract will approve LINK tokens only when the messages are sent successfully, mitigating the risk of unauthorized spending and potential loss of funds.
abi.encodePacked()
should not be used with dynamic types when passing the result to a hash function such as keccak256()
Use abi.encode()
instead which will pad items to 32 bytes, which will prevent hash collisions (e.g. abi.encodePacked(0x123,0x456)
=> 0x123456
=> abi.encodePacked(0x1,0x23456)
, but abi.encode(0x123,0x456)
=> 0x0...1230...456
).
Unless there is a compelling reason, abi.encode
should be preferred. If there is only one argument to abi.encodePacked()
it can often be cast to bytes()
or bytes32()
instead.
If all arguments are strings and or bytes, bytes.concat()
should be used instead.
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/XERC20Factory.sol#L147-L148
solidity bytes32 _salt = keccak256(abi.encodePacked(_name, _symbol, msg.sender));
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/XERC20Factory.sol#L158-L159
solidity bytes memory _bytecode = abi.encodePacked(
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/XERC20Factory.sol#L201-L202
solidity bytes memory _bytecode = abi.encodePacked(
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/optimism/OptimismMintableXERC20Factory.sol#L89-L90
solidity bytes32 _salt = keccak256(abi.encodePacked(_name, _symbol, msg.sender));
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/optimism/OptimismMintableXERC20Factory.sol#L100-L101
solidity bytes memory _bytecode = abi.encodePacked(
maxDepositTVL
value may prevent users from depositing fundsIf the maxDepositTVL
value is set to a value lower than the current total value locked (TVL) in the protocol, users will be unable to deposit any more funds (either ERC20 tokens or ETH) until the TVL decreases below the maxDepositTVL
threshold. This could lead to a situation where users' funds are temporarily stuck, preventing them from participating in the protocol.
In the deposit
function, there is a check to enforce a maximum TVL limit:
// Enforce TVL limit if set, 0 means the check is not enabled if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) { revert MaxTVLReached(); }
Similarly, in the depositETH
function, there is a similar check:
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L596-L599
// Enforce TVL limit if set if (maxDepositTVL != 0 && totalTVL + msg.value > maxDepositTVL) { revert MaxTVLReached(); }
If the maxDepositTVL
is set to a value that is lower than the current total value locked (TVL) in the protocol, these checks will cause the deposit
and depositETH
functions to revert, preventing users from depositing funds.
Consider implementing a mechanism to automatically adjust the maxDepositTVL
based on the protocol's growth rate
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate (less risk of a malicious Manager making a frontrunning/sandwich attack on the fees).
Consider adding a timelock to the setFeeConfig
function:
function setFeeConfig( address _feeAddress, uint256 _feeBasisPoints ) external onlyRestakeManagerAdmin { // Verify address is set if basis points are non-zero if (_feeBasisPoints > 0) { if (_feeAddress == address(0x0)) revert InvalidZeroInput(); } // Verify basis points are not over 100% if (_feeBasisPoints > 10000) revert OverMaxBasisPoints(); feeAddress = _feeAddress; feeBasisPoints = _feeBasisPoints; emit FeeConfigUpdated(_feeAddress, _feeBasisPoints); }
safecast
The SafeCast
library from OpenZeppelin is imported in the contract but it is not used anywhere in the code. The SafeCast
library is indeed useful for safely casting between different numeric types to prevent overflows or underflows, but there are no such casting operations in the provided contract code.
Here is the import statement:
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
And it is declared for use with: https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L13-L14
using SafeCast for uint256;
However, we reviewed the entire contract, there are no instances where uint256
types are being cast to other types using SafeCast
. All operations with uint256
are straightforward assignments, parameter passing, or arithmetic operations that do not involve type casting.
If there is no future plan to include such type casting operations, you could safely remove the SafeCast
import and its associated using
declaration to clean up the code and reduce any unnecessary dependencies.
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();
The mild logic issue here is related to the loop's termination condition and the use of the unchecked
block. The loop iterates through the collateralTokens
array to find the token to remove. When it finds the token, it replaces it with the last token in the array and then removes the last element using pop()
. This is a common technique to efficiently remove items from an array without preserving order.
Albeit, if the token to be removed is the last token in the array. In this case, the token is replaced with itself and then pop()
is called. This does not cause a functional issue but is redundant and could be confusing. It's a minor inefficiency because it involves an unnecessary assignment.
To make the code cleaner and avoid unnecessary operations, you can add a check to see if the token to remove is already the last one in the array:
/// @dev Allows restake manager to remove a collateral token 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)) { + if (i != collateralTokens.length - 1) { 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(); }
This modification ensures that the unnecessary assignment is avoided when the token to be removed is already the last one in the array.
lookupTokenValues
functionThe lookupTokenValues
function makes external calls to lookupTokenValue
inside a loop, which in turn makes external calls to Chainlink oracles. This pattern can lead to high gas costs and potential performance issues if the number of tokens is large.
The lookupTokenValues
function:
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L103-L120
function lookupTokenValues( IERC20[] memory _tokens, uint256[] memory _balances ) external view returns (uint256) { if (_tokens.length != _balances.length) revert MismatchedArrayLengths(); uint256 totalValue = 0; uint256 tokenLength = _tokens.length; for (uint256 i = 0; i < tokenLength; ) { totalValue += lookupTokenValue(_tokens[i], _balances[i]); unchecked { ++i; } } return totalValue; }
The lookupTokenValue
function which is called in the loop:
function lookupTokenValue(IERC20 _token, uint256 _balance) public view returns (uint256) { AggregatorV3Interface oracle = tokenOracleLookup[_token]; if (address(oracle) == address(0x0)) revert OracleNotFound(); (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData(); if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired(); if (price <= 0) revert InvalidOraclePrice(); return (uint256(price) * _balance) / SCALE_FACTOR; }
Limit the maximum number of tokens that can be passed into lookupTokenValues
. This puts an upper bound on the gas cost.
Consider caching oracle prices to avoid repeated external calls for the same token within a short timeframe. The trade-off is potentially using slightly stale prices.
If the set of tokens is known in advance and relatively static, consider having a separate function to update all the prices in one transaction, and then lookupTokenValues
can use the cached values without making external calls.
If sending the price feed fails in the sendPrice function, there is no event emitted or error thrown to indicate the failure. This makes it difficult to track and identify issues when the price feed sending fails. It can lead to silent failures and inconsistencies in the price feed data across different chains without any visible indication of the problem.
The sendPrice function sends the price feed to multiple destination chains using CCIP and Connext. However, if any of these sending operations fail, there is no specific event emitted or error thrown to indicate the failure.
Here are the relevant code snippets:
// send price feed to renzo CCIP receivers for (uint256 i = 0; i < _destinationParam.length; ) { // ... bytes32 messageId = linkRouterClient.ccipSend( _destinationParam[i].destinationChainSelector, evm2AnyMessage ); emit MessageSent( messageId, _destinationParam[i].destinationChainSelector, _destinationParam[i]._renzoReceiver, exchangeRate, address(linkToken), fees ); // ... } // send price feed to renzo connext receiver for (uint256 i = 0; i < _connextDestinationParam.length; ) { connext.xcall{ value: _connextDestinationParam[i].relayerFee }( // ... ); emit ConnextMessageSent( _connextDestinationParam[i].destinationDomainId, _connextDestinationParam[i]._renzoReceiver, exchangeRate, _connextDestinationParam[i].relayerFee ); // ... }
In both loops, there are events emitted (MessageSent and ConnextMessageSent) when the price feed is sent successfully. However, there are no events emitted or errors thrown if the sending operations (ccipSend or xcall) fail.
setTokenTvlLimit
function name and event name are somewhat misleadingThe function name and event name do not accurately reflect the behavior of the function, which could lead to confusion and incorrect usage by developers or auditors.
The setTokenTvlLimit function sets the TVL limit for a specific collateral token in 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, the function name and event name suggest that it is setting an overall TVL limit for the protocol, rather than a limit for a specific collateral token.
We recommend renaming the function to setCollateralTokenTvlLimit
and update the event name to CollateralTokenTvlLimitUpdated
to accurately reflect the behavior of the function:
function setCollateralTokenTvlLimit(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 CollateralTokenTvlLimitUpdated(_token, _limit); }
This will make it clear that the function is setting a TVL limit specifically for a collateral token, rather than an overall TVL limit for the protocol.
The sweepERC20
function ignores the return value of the token.approve(address(restakeManager), balance - feeAmount)
call. Here's the relevant part of the code:
token.approve(address(restakeManager), balance - feeAmount);
In Solidity, the approve
function of ERC20 tokens is expected to return a boolean value indicating whether the approval was successful. However, in this code snippet, the return value is not checked or stored. This can potentially lead to issues if the approve
call fails silently (i.e., it returns false
), as the subsequent operations might assume that the approval was successful.
To improve the safety and reliability of this function, we recommended checking the return value of the approve
call and handle the case where it returns false
. Here's how the modification could be done to include this check:
bool approvalSuccess = token.approve(address(restakeManager), balance - feeAmount); if (!approvalSuccess) { revert ApprovalFailed(); }
This change ensures that the function will revert if the token approval fails, preventing any further actions that depend on this approval from being executed.
calculateTVLs
functionThe calculateTVLs
function has external calls inside a loop. Specifically, the function makes external calls to operatorDelegators[i].getTokenBalanceFromStrategy(collateralTokens[j])
and renzoOracle.lookupTokenValue(collateralTokens[j], operatorBalance)
within nested loops iterating over operator delegators and collateral tokens. Here's the relevant part of the code:
for (uint256 i = 0; i < odLength; ) { uint256 operatorTVL = 0; uint256[] memory operatorValues = new uint256[](collateralTokens.length + 1); operatorDelegatorTokenTVLs[i] = operatorValues; uint256 tokenLength = collateralTokens.length; for (uint256 j = 0; j < tokenLength; ) { uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(collateralTokens[j]); operatorValues[j] = renzoOracle.lookupTokenValue(collateralTokens[j], operatorBalance); operatorTVL += operatorValues[j]; if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue) ); } unchecked { ++j; } } uint256 operatorEthBalance = operatorDelegators[i].getStakedETHBalance(); operatorValues[operatorValues.length - 1] = operatorEthBalance; operatorTVL += operatorEthBalance; totalTVL += operatorTVL; operatorDelegatorTVLs[i] = operatorTVL; withdrawQueueTokenBalanceRecorded = true; unchecked { ++i; } }
These external calls within loops can potentially lead to high gas costs, especially if the number of iterations is large. This pattern can also increase the risk of the contract being affected by external failures or changes in the state of the called contracts.
In the depositTokenRewardsFromProtocol
function within the RestakeManager.sol
contract, the return value of the operatorDelegator.deposit(_token, _amount)
call is indeed ignored. Here's the relevant part of the function:
// Deposit the tokens into EigenLayer operatorDelegator.deposit(_token, _amount);
This function call to operatorDelegator.deposit(_token, _amount)
does not capture any return value, nor does it check for any state changes or conditions post-call that might depend on the outcome of the deposit operation. This could potentially lead to issues if the deposit operation fails silently (i.e., it does not revert but also does not perform as expected), as the contract would continue execution without handling such a case.
In Solidity, it's often important to handle return values or ensure that called functions revert on failure to prevent subtle bugs and ensure contract reliability. If the deposit
function in the IOperatorDelegator
interface is expected to return a value indicating success, or if its successful execution is critical, it would be prudent to modify the depositTokenRewardsFromProtocol
function to handle this return value appropriately.
Also, the deposit
function in the RestakeManager
contract does not handle or check the return value of the operatorDelegator.deposit(_collateralToken, _amount)
call. This function call is made to deposit the specified amount of a collateral token into the designated OperatorDelegator
. Here's the relevant part of the code:
// Call deposit on the operator delegator operatorDelegator.deposit(_collateralToken, _amount);
This line indicates that the function is called, but no return value is captured or checked. This is common in Solidity when the called function does not return any value or when the return value is not needed for further processing. Albeit it's important to ensure that the deposit
function in the OperatorDelegator
contract is designed to handle errors internally (e.g., by using revert
statements) to ensure that any issues during the deposit process are appropriately managed.
feeAddress
assignment in setFeeConfig
functionHere's the relevant part of the code:
function setFeeConfig( address _feeAddress, uint256 _feeBasisPoints ) external onlyRestakeManagerAdmin { // Verify address is set if basis points are non-zero if (_feeBasisPoints > 0) { if (_feeAddress == address(0x0)) revert InvalidZeroInput(); } // Verify basis points are not over 100% if (_feeBasisPoints > 10000) revert OverMaxBasisPoints(); feeAddress = _feeAddress; feeBasisPoints = _feeBasisPoints; emit FeeConfigUpdated(_feeAddress, _feeBasisPoints); }
The function checks if _feeAddress
is zero only when _feeBasisPoints
is greater than zero. However, it directly assigns _feeAddress
to feeAddress
without checking if it's zero when _feeBasisPoints
is zero.
To fix this, we recommend adding a separate zero-check for _feeAddress
before assigning it to feeAddress
. Here's the updated code:
function setFeeConfig( address _feeAddress, uint256 _feeBasisPoints ) external onlyRestakeManagerAdmin { // Verify address is set if basis points are non-zero if (_feeBasisPoints > 0) { if (_feeAddress == address(0x0)) revert InvalidZeroInput(); } // Verify basis points are not over 100% if (_feeBasisPoints > 10000) revert OverMaxBasisPoints(); // Verify fee address is not zero if (_feeAddress == address(0x0)) revert InvalidZeroInput(); feeAddress = _feeAddress; feeBasisPoints = _feeBasisPoints; emit FeeConfigUpdated(_feeAddress, _feeBasisPoints); }
By adding the separate zero-check for _feeAddress
, you ensure that the feeAddress
is not set to the zero address, regardless of the value of _feeBasisPoints
.
The comments for the unPause()
and pause()
functions are swapped.
function unPause() external onlyOwner { _unpause(); } /** * @notice UnPause the contract * @dev This should be a permissioned call (onlyOwner) */ function pause() external onlyOwner { _pause(); }
The comment for unPause()
should mention unpausing the contract, while the comment for pause()
should mention pausing the contract. The code logic itself is correct, just the comments needed to be swapped to match the corresponding functions.
"inflationPercentaage" is misspelled. It should be "inflationPercentage" instead:
// Calculate the percentage of value after the deposit uint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) / (_currentValueInProtocol + _newValueAdded);
The correct spelling should be:
// Calculate the percentage of value after the deposit uint256 inflationPercentage = (SCALE_FACTOR * _newValueAdded) / (_currentValueInProtocol + _newValueAdded);
In the comments for several functions in several contracts, the word "onlyOwner" is misspelled as "onlyOnwer".
They should be corrected onlyOwner
This is just a typographical error in the comments and does not affect the functionality of the contracts, as the actual modifier used in the function definitions is correctly spelled as
onlyOwner
.
The word "maxmimum" should be corrected to "maximum". Here's the corrected comment:
Other than this minor typo in the comment, the contract code itself does not appear to have any typographical errors.
In the comment for the isWithdrawQueueAdmin
function, "haas" should be "has":
It should be corrected to:
/// @dev Determine if the specified address has permission to update Withdraw Queue params
This is just a comment typo and does not affect the functionality of the contract, but it's good practice to fix such typos for clarity and professionalism in the codebase.
The approve
function can be vulnerable to a specific attack, where a malicious actor can double spend tokens. This scenario occurs when the owner changes the approved allowance from N to M (N>0, M>0). The malicious spender can observe this change and quickly transfer the original N tokens before the change is mined, and then spend the additional M tokens afterwards. This could result in a total transfer of N+M tokens, which is not what the owner intended. More info you can see in this link
For this reason, it's recommended to use safeIncreaseAllowance() or safeDecreaseAllowance() for better control. If these methods are not available, using safeApprove() is also an option as it reverts if the current approval is not zero, providing an additional layer of security.
Also, ERC20 functions may not behave as expected. For example: return values are not always meaningful. It is recommended to use OpenZeppelin's SafeERC20 library. Use safeTransfer
instead.
Here are the instances:
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L1/xRenzoBridge.sol#L241
solidity linkToken.approve(address(linkRouterClient), fees);
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L1/xRenzoBridge.sol#L295
solidity payable(_to).transfer(_amount);
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L479
solidity payable(_to).transfer(_amount);
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Deposits/DepositQueue.sol#L268
solidity token.approve(address(restakeManager), balance - feeAmount);
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L303
solidity payable(msg.sender).transfer(_withdrawRequest.amountToRedeem);
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L305
solidity IERC20(_withdrawRequest.collateralToken).transfer(
nonReentrant
modifier
should occur before all other modifiersThis is a best-practice to protect against reentrancy in other modifiers.
```solidity ) external payable onlyPriceFeedSender nonReentrant { ```
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Deposits/DepositQueue.sol#L216
solidity ) external onlyNativeEthRestakeAdmin nonReentrant {
Solc compiler version 0.8.20 switches the default target EVM version to Shanghai, which means that the generated bytecode will include PUSH0 opcodes. Be sure to select the appropriate EVM version in case you intend to deploy on a chain other than mainnet like L2 chains that may not support PUSH0, otherwise deployment of your contracts will fail.
Found in the following contracts:
Use e
notation, for example: 1e18
, instead of its full numeric value.
```solidity uint32 public constant FEE_BASIS = 10000; ```
```solidity if (_feeBasisPoints > 10000) revert OverMaxBasisPoints(); ```
```solidity feeAmount = (msg.value * feeBasisPoints) / 10000; ```
```solidity feeAmount = (balance * feeBasisPoints) / 10000;
```solidity modifier onlyPriceFeedSender() { ```
```solidity modifier onlySource(address _originSender, uint32 _origin) { ```
```solidity modifier onlyERC20RewardsAdmin() { ```
```solidity modifier onlyOracleAdmin() { ```
```solidity modifier onlyDepositWithdrawPauserAdmin() { ```
```solidity modifier onlyNativeEthRestakeAdmin() { ```
```solidity modifier onlyRestakeManagerAdmin() { ```
```solidity modifier onlyTokenAdmin() { ```
indexed
fieldsIndex event fields make the field more quickly accessible to off-chain tools that parse events. However, note that each index field costs extra gas during emission, so it's not necessarily best to index the maximum allowed per event (three fields). Each event should use three indexed fields if there are three or more fields, and gas usage is not particularly of concern for the events in question. If there are fewer than three fields, all of the fields should be indexed.
```solidity event EzETHMinted( ```
```solidity event MessageSent( ```
```solidity event ConnextMessageSent( ```
```solidity event OracleAddressUpdated(address newOracle, address oldOracle); ```
```solidity event XRenzoBridgeL1Updated(address newBridgeAddress, address oldBridgeAddress); ```
```solidity event CCIPEthChainSelectorUpdated(uint64 newSourceChainSelector, uint64 oldSourceChainSelector); ```
```solidity event XRenzoDepositUpdated(address newRenzoDeposit, address oldRenzoDeposit); ```
```solidity event MessageReceived( ```
```solidity event XRenzoBridgeL1Updated(address newBridgeAddress, address oldBridgeAddress); ```
```solidity event ConnextEthChainDomainUpdated(uint32 newSourceChainDomain, uint32 oldSourceChainDomain); ```
```solidity event XRenzoDepositUpdated(address newRenzoDeposit, address oldRenzoDeposit); ```
```solidity event MessageReceived( ```
```solidity event PriceUpdated(uint256 price, uint256 timestamp); ```
```solidity event Deposit(address indexed user, uint256 amountIn, uint256 amountOut); ```
```solidity event BridgeSweeperAddressUpdated(address sweeper, bool allowed); ```
```solidity event BridgeSwept( ```
```solidity event OraclePriceFeedUpdated(address newOracle, address oldOracle); ```
```solidity event ReceiverPriceFeedUpdated(address newReceiver, address oldReceiver); ```
```solidity event SweeperBridgeFeeCollected(address sweeper, uint256 feeCollected); ```
```solidity event BridgeFeeShareUpdated(uint256 oldBridgeFeeShare, uint256 newBridgeFeeShare); ```
```solidity event SweepBatchSizeUpdated(uint256 oldSweepBatchSize, uint256 newSweepBatchSize); ```
```solidity event TokenStrategyUpdated(IERC20 token, IStrategy strategy); ```
```solidity event DelegationAddressUpdated(address delegateAddress); ```
```solidity event RewardsForwarded(address rewardDestination, uint256 amount); ```
```solidity event WithdrawStarted( ```
```solidity event WithdrawCompleted(bytes32 withdrawalRoot, IStrategy[] strategies, uint256[] shares); ```
```solidity event GasSpent(address admin, uint256 gasSpent); ```
```solidity event GasRefunded(address admin, uint256 gasRefunded); ```
```solidity event BaseGasAmountSpentUpdated(uint256 oldBaseGasAmountSpent, uint256 newBaseGasAmountSpent); ```
```solidity event RewardsDeposited(IERC20 token, uint256 amount); ```
```solidity event FeeConfigUpdated(address feeAddress, uint256 feeBasisPoints); ```
```solidity event RestakeManagerUpdated(IRestakeManager restakeManager); ```
```solidity event ETHDepositedFromProtocol(uint256 amount); ```
```solidity event ETHStakedFromQueue( ```
```solidity event ProtocolFeesPaid(IERC20 token, uint256 amount, address destination); ```
```solidity event GasRefunded(address admin, uint256 gasRefunded); ```
```solidity event WithdrawQueueUpdated(address oldWithdrawQueue, address newWithdrawQueue); ```
```solidity event BufferFilled(address token, uint256 amount); ```
```solidity event FullWithdrawalETHReceived(uint256 amount); ```
```solidity event OracleAddressUpdated(IERC20 token, AggregatorV3Interface oracleAddress); ```
```solidity event OperatorDelegatorAdded(IOperatorDelegator od); ```
```solidity event OperatorDelegatorRemoved(IOperatorDelegator od); ```
```solidity event OperatorDelegatorAllocationUpdated(IOperatorDelegator od, uint256 allocation); ```
```solidity event CollateralTokenAdded(IERC20 token); ```
```solidity event CollateralTokenRemoved(IERC20 token); ```
```solidity event Deposit( ```
```solidity event UserWithdrawStarted( ```
```solidity event UserWithdrawCompleted( ```
```solidity event CollateralTokenTvlUpdated(IERC20 token, uint256 tvl); ```
```solidity event RewardDestinationUpdated(address rewardDestination); ```
```solidity event CallScheduled( ```
```solidity event CallExecuted( ```
```solidity event CallSalt(bytes32 indexed id, bytes32 salt); ```
```solidity event MinDelayChange(uint256 oldDuration, uint256 newDuration); ```
```solidity event WithdrawBufferTargetUpdated(uint256 oldBufferTarget, uint256 newBufferTarget); ```
```solidity event CoolDownPeriodUpdated(uint256 oldCoolDownPeriod, uint256 newCoolDownPeriod); ```
```solidity event EthBufferFilled(uint256 amount); ```
```solidity event ERC20BufferFilled(address asset, uint256 amount); ```
```solidity event WithdrawRequestCreated( ```
```solidity event WithdrawRequestClaimed(WithdrawRequest withdrawRequest); ```
#0 - CloudEllie
2024-05-13T14:01:55Z
#1 - c4-judge
2024-05-24T10:01:24Z
alcueca marked the issue as grade-b