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: 5/122
Findings: 7
Award: $2,364.91
🌟 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
An incorrect implementation of the getTokenBalanceFromStrategy
function in the OperatorDelegator
contract can result in inaccurate TVL (Total Value Locked) calculation, which opens up an opportunity for a sandwich attack.
The calculateTVLs
function in the RestakeManager
contract calculates TVL by calling the getTokenBalanceFromStrategy
function for each operator delegator.
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) { //...... uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy( collateralTokens[j] ); //...... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L302
The getTokenBalanceFromStrategy
function checks if queuedShares[address(this)]
is zero. If zero, it only considers the underlying token amount from the amount of shares. If not, it considers both the underlying token amount from the amount of shares and queued withdrawal shares.
/// @dev Gets the underlying token amount from the amount of shares + queued withdrawal shares 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)] ); }
The issue arises from using queuedShares[address(this)]
, which is always zero. This should be replaced with queuedShares[token]
.
Sandwich Attack Opportunity:
completeQueuedWithdrawal
to complete the queued withdrawal. This causes that the queued ERC20 be trasnferred to WithdrawQueue
to fill the buffer, and the remaining will be deposited into the strategy again. All in all, this leads to increase in the TVL, because in the calculation of TVL, both the ERC20 balance of WithdrawQueue
and ERC20 deposited in the strategy are taken into account./** function queueWithdrawals( IERC20[] calldata tokens, uint256[] calldata tokenAmounts ) external nonReentrant onlyNativeEthRestakeAdmin returns (bytes32) { // record gas spent uint256 gasBefore = gasleft(); if (tokens.length != tokenAmounts.length) revert MismatchedArrayLengths(); IDelegationManager.QueuedWithdrawalParams[] memory queuedWithdrawalParams = new IDelegationManager.QueuedWithdrawalParams[](1); // set strategies legth for 0th index only queuedWithdrawalParams[0].strategies = new IStrategy[](tokens.length); queuedWithdrawalParams[0].shares = new uint256[](tokens.length); // Save the nonce before starting the withdrawal uint96 nonce = uint96(delegationManager.cumulativeWithdrawalsQueued(address(this))); for (uint256 i; i < tokens.length; ) { if (address(tokens[i]) == IS_NATIVE) { // set beaconChainEthStrategy for ETH queuedWithdrawalParams[0].strategies[i] = eigenPodManager.beaconChainETHStrategy(); // set shares for ETH queuedWithdrawalParams[0].shares[i] = tokenAmounts[i]; } else { if (address(tokenStrategyMapping[tokens[i]]) == address(0)) revert InvalidZeroInput(); // set the strategy of the token queuedWithdrawalParams[0].strategies[i] = tokenStrategyMapping[tokens[i]]; // set the equivalent shares for tokenAmount queuedWithdrawalParams[0].shares[i] = tokenStrategyMapping[tokens[i]] .underlyingToSharesView(tokenAmounts[i]); } // set withdrawer as this contract address queuedWithdrawalParams[0].withdrawer = address(this); // track shares of tokens withdraw for TVL queuedShares[address(tokens[i])] += queuedWithdrawalParams[0].shares[i]; unchecked { ++i; } } // queue withdrawal in EigenLayer bytes32 withdrawalRoot = delegationManager.queueWithdrawals(queuedWithdrawalParams)[0]; // Emit the withdrawal started event emit WithdrawStarted( withdrawalRoot, address(this), delegateAddress, address(this), nonce, block.number, queuedWithdrawalParams[0].strategies, queuedWithdrawalParams[0].shares ); // update the gas spent for RestakeAdmin _recordGas(gasBefore); return withdrawalRoot; }
function completeQueuedWithdrawal( IDelegationManager.Withdrawal calldata withdrawal, IERC20[] calldata tokens, uint256 middlewareTimesIndex ) external nonReentrant onlyNativeEthRestakeAdmin { uint256 gasBefore = gasleft(); if (tokens.length != withdrawal.strategies.length) revert MismatchedArrayLengths(); // complete the queued withdrawal from EigenLayer with receiveAsToken set to true delegationManager.completeQueuedWithdrawal(withdrawal, tokens, middlewareTimesIndex, true); IWithdrawQueue withdrawQueue = restakeManager.depositQueue().withdrawQueue(); for (uint256 i; i < tokens.length; ) { if (address(tokens[i]) == address(0)) revert InvalidZeroInput(); // deduct queued shares for tracking TVL queuedShares[address(tokens[i])] -= withdrawal.shares[i]; // check if token is not Native ETH if (address(tokens[i]) != IS_NATIVE) { // Check the withdraw buffer and fill if below buffer target uint256 bufferToFill = withdrawQueue.getBufferDeficit(address(tokens[i])); // get balance of this contract uint256 balanceOfToken = tokens[i].balanceOf(address(this)); if (bufferToFill > 0) { bufferToFill = (balanceOfToken <= bufferToFill) ? balanceOfToken : bufferToFill; // update amount to send to the operator Delegator balanceOfToken -= bufferToFill; // safe Approve for depositQueue tokens[i].safeApprove(address(restakeManager.depositQueue()), bufferToFill); // fill Withdraw Buffer via depositQueue restakeManager.depositQueue().fillERC20withdrawBuffer( address(tokens[i]), bufferToFill ); } // Deposit remaining tokens back to eigenLayer if (balanceOfToken > 0) { _deposit(tokens[i], balanceOfToken); } } unchecked { ++i; } } // emits the Withdraw Completed event with withdrawalRoot emit WithdrawCompleted( delegationManager.calculateWithdrawalRoot(withdrawal), withdrawal.strategies, withdrawal.shares ); // record current spent gas _recordGas(gasBefore); }
The following change is needed:
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) { return queuedShares[address(token)] == 0 ? tokenStrategyMapping[token].userUnderlyingView(address(this)) : tokenStrategyMapping[token].userUnderlyingView(address(this)) + tokenStrategyMapping[token].sharesToUnderlyingView( queuedShares[address(token)] ); }
Context
#0 - c4-judge
2024-05-16T10:43:35Z
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
If the rate of ezETH
changes on L1 during the time the deposited amount on L2 is being bridged to L1, there will be a difference between the quantity of xezETH
minted on L2 and the quantity of ezETH
locked in XERC20Lockbox
on L1. This can happen by changing the TVL while total supply is remained constant, like: donation, or rewards being added. This can provide an opportunity for stealing fund from the protocol.
When the function sendPrice
on L1 is called by PriceFeedSender
, the price feed is sent to xRenzoDeposit
to update the price on L2.`
function sendPrice( CCIPDestinationParam[] calldata _destinationParam, ConnextDestinationParam[] calldata _connextDestinationParam ) external payable onlyPriceFeedSender nonReentrant { // call getRate() to get the current price of ezETH uint256 exchangeRate = rateProvider.getRate(); bytes memory _callData = abi.encode(exchangeRate, block.timestamp); // send price feed to renzo CCIP receivers for (uint256 i = 0; i < _destinationParam.length; ) { Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({ receiver: abi.encode(_destinationParam[i]._renzoReceiver), // ABI-encoded xRenzoDepsot contract address data: _callData, // ABI-encoded ezETH exchange rate with Timestamp tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array indicating no tokens are being sent extraArgs: Client._argsToBytes( // Additional arguments, setting gas limit Client.EVMExtraArgsV1({ gasLimit: 200_000 }) ), // Set the feeToken address, indicating LINK will be used for fees feeToken: address(linkToken) }); // 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 ); // Emit an event with message details emit MessageSent( messageId, _destinationParam[i].destinationChainSelector, _destinationParam[i]._renzoReceiver, exchangeRate, address(linkToken), fees ); unchecked { ++i; } } // send price feed to renzo connext receiver for (uint256 i = 0; i < _connextDestinationParam.length; ) { connext.xcall{ value: _connextDestinationParam[i].relayerFee }( _connextDestinationParam[i].destinationDomainId, _connextDestinationParam[i]._renzoReceiver, address(0), msg.sender, 0, 0, _callData ); emit ConnextMessageSent( _connextDestinationParam[i].destinationDomainId, _connextDestinationParam[i]._renzoReceiver, exchangeRate, _connextDestinationParam[i].relayerFee ); unchecked { ++i; } } }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L210-L261
function updatePrice(uint256 _price, uint256 _timestamp) external override { if (msg.sender != receiver) revert InvalidSender(receiver, msg.sender); _updatePrice(_price, _timestamp); }
Moreover, when a deposit is made on L2, the xezETH
is minted based on the recent rate updated on L2.
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(); //... // Calculate the amount of xezETH to mint - assumes 18 decimals for price and token uint256 xezETHAmount = (1e18 * amountOut) / _lastPrice; //... // Mint xezETH to the user IXERC20(address(xezETH)).mint(msg.sender, xezETHAmount); //... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L227
Then, the deposited amount (which is traded from WETH
to nextWETH
on L2) is bridged to L1 through calling the function sweep
by the allowedBridgeSweepers
.
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)); // If there is no balance, return if (balance == 0) { revert InvalidZeroOutput(); } // 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 ); // send collected bridge fee to sweeper _recoverBridgeFee(); // Emit the event emit BridgeSwept(bridgeDestinationDomain, bridgeTargetAddress, msg.sender, balance); }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L414
On L1, the bridged WETH
by Connext is deposited in RestakeManager
to mint ezETH
. The amount of minted ezETH
is based on the current rate of ezETH
on L1. This amount of ezETH
is deposited in XERC20Lockbox
, and 1:1 xezETH
will be minted. Then, the minted xezETH
will be burned, because the xezETH
is already minted on L2 to the user.
function xReceive( bytes32 _transferId, uint256 _amount, address _asset, address _originSender, uint32 _origin, bytes memory ) external nonReentrant returns (bytes memory) { // Only allow incoming messages from the Connext contract if (msg.sender != address(connext)) { revert InvalidSender(address(connext), msg.sender); } // Check that the token received is wETH if (_asset != address(wETH)) { revert InvalidTokenReceived(); } // Check that the amount sent is greater than 0 if (_amount == 0) { revert InvalidZeroInput(); } // Get the balance of ETH before the withdraw uint256 ethBalanceBeforeWithdraw = address(this).balance; // Unwrap the WETH IWeth(address(wETH)).withdraw(_amount); // Get the amount of ETH uint256 ethAmount = address(this).balance - ethBalanceBeforeWithdraw; // Get the amonut of ezETH before the deposit uint256 ezETHBalanceBeforeDeposit = ezETH.balanceOf(address(this)); // Deposit it into Renzo RestakeManager restakeManager.depositETH{ value: ethAmount }(); // Get the amount of ezETH that was minted uint256 ezETHAmount = ezETH.balanceOf(address(this)) - ezETHBalanceBeforeDeposit; // Approve the lockbox to spend the ezETH ezETH.safeApprove(address(xezETHLockbox), ezETHAmount); // Get the xezETH balance before the deposit uint256 xezETHBalanceBeforeDeposit = xezETH.balanceOf(address(this)); // Send to the lockbox to be wrapped into xezETH xezETHLockbox.deposit(ezETHAmount); // Get the amount of xezETH that was minted uint256 xezETHAmount = xezETH.balanceOf(address(this)) - xezETHBalanceBeforeDeposit; // Burn it - it was already minted on the L2 IXERC20(address(xezETH)).burn(address(this), xezETHAmount); // Emit the event emit EzETHMinted(_transferId, _amount, _origin, _originSender, ezETHAmount); // Return 0 for success bytes memory returnData = new bytes(0); return returnData; }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L139-L201
If the rate of ezETH
changes on L1 during the time the deposited amount on L2 is being bridged to L1, there will be a difference between the quantity of xezETH
minted on L2 and the quantity of ezETH
locked in XERC20Lockbox
on L1.
xezETH
to L1, after the amount is bridged to L1.Attack explanation
ezETH
.ezETH
in XERC20Lockbox
to wrap it into xezETH
.sendPrice
is called by priceFeedSender
on L1 to send the price feed to L2.xezETH
1:1 (because the stpe 4 sets the rate of ezETH
/eth
= 1).ezETH
does not change. So, on L1, each ezETH
worths more ETH
than before.sweep
is called on L2 by allowedBridgeSweeper
to bridge the deposited amount in step 5 to L1.xReceive
is called by Connext, the received WETH
from the L2 is deposited in RestakeManager
and mints ezETH
. The amount of minted ezETH
is lower than the amount of xezETH
minted on L2, because on step 6 the donation increased the TVL, so the received WETH
worths less ezETH
.ezETH
on L1 will be locked in the XERC20Lockbox
, and xezETH
will be minted 1:1 on L1, and then will be burned assuming the same amount of xezETH
is minted on L2.xezETH
on L2 is more than the amount of ezETH
locked in XERC20Lockbox
. This is because the ezETH
rate on L2 is lower than the rate on L1. In other words, ezETH
on L1 is worthier than ezETH
on L2 due to the donation.xezETH
on L2 to receive ezETH
on L1. Since on step 3, some ezETH
is wrapped into xezETH
, the XERC20Lockbox
now has enough ezETH
to redeem in exchange of bridged xezETH
.ezETH
and xezETH
on L1.ezETH
on RestakeManager
, the depoisted ETH on step 2 will be redeemed.xezETH
that are not backed with any ezETH
.ezETH
balance of XERC20Lockbox
, the attacker can steal them by burning his xezETH
.For better understanding, the numerical scenario is explained as follows:
Suppose the state is:
On L1
Attacker balance: 20 ETH
, 0 ezETH
, 0 xezETH
Protocol: TVL = 0, TotalSupply = 0, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 20 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker deposits 10 ETH
on L1:
On L1
Attacker balance: 10 ETH
, 10 ezETH
, 0 xezETH
Protocol: TVL = 10, TotalSupply = 10, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 20 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker deposits 10 ezETH
into XERC20Lockbox
and receives 10 xezETH
:
On L1
Attacker balance: 10 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 10, TotalSupply = 10, XERC20Lockbox = 10 ezETH
On L2
Attacker balance: 20 ETH
, 0 xezETH
Protocol: 0 nextWETH
The sendPrice
is called by priceFeedSender
on L1 to send the price feed to L2.
Attacker deposits 20 ETH
on L2, and receives 20 xezETH
.
On L1
Attacker balance: 10 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 10, TotalSupply = 10, XERC20Lockbox = 10 ezETH
On L2
Attacker balance: 0 ETH
, 20 xezETH
Protocol: 20 nextWETH
Attacker donates 10 ETH
on L1.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 20, TotalSupply = 10, XERC20Lockbox = 10 ezETH
On L2
Attacker balance: 0 ETH
, 20 xezETH
Protocol: 20 nextWETH
The sweep
is called on L2 by allowedBridgeSweeper
to bridge the deposited amount in step 5 to L1.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 20, TotalSupply = 10, XERC20Lockbox = 10 ezETH
On L2
Attacker balance: 0 ETH
, 20 xezETH
Protocol: 0 nextWETH
xReceive
is called by Connext to execute the bridging on the destination chain, so 20 WETH
will be deposited into the protocol. Since, TVL is 20 and total supply is 10, only 10 ezETH
will be minted and be locked in XERC20Lockbox
. So, the TVL increases to 40, and total supply increases to 20.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 40, TotalSupply = 20, XERC20Lockbox = 20 ezETH
On L2
Attacker balance: 0 ETH
, 20 xezETH
Protocol: 0 nextWETH
Attacker bridges 20 xezETH
on L2 to L1. Connext will transfer these 20 xezETH
to XERC20Lockbox
on L1 to be burned and redeem 20 ezETH
to the attacker.
On L1
Attacker balance: 0 ETH
, 20 ezETH
, 10 xezETH
Protocol: TVL = 40, TotalSupply = 20, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 0 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker requests to withdraw the underlying token by burning his 20 ezETH
. When the withdraw is completed, the attacker receives 40 ETH
, because 20*40/20
.
On L1
Attacker balance: 40 ETH
, 0 ezETH
, 10 xezETH
Protocol: TVL = 0, TotalSupply = 0, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 0 ETH
, 0 xezETH
Protocol: 0 nextWETH
Now the attacker has his own 40 ETH
as well as 10 xezETH
extra. So, the attacker can use these extra xezETH
to steal from next users.
ezETH
will be locked in XERC20Lockbox
. Now, the attacker burns his 10 xezETH
and receives 10 ezETH
which can be later burned to withdraw 10 underlying token (Like ETH
). So, the attacker now owns 40 ETH
as well as 10 stolen ETH
.The main root cause of this issue is that xezETH
is minted immediately when depositing on L2 with the recent updated rate. While, when the deposited amount on L2 is bridged to L1, the rate on L1 could be different (for example by maliciously donating into the protocol), so an amount of ezETH
locked on L1 will not be equal to the amount of minted xezETH
on L2.
This attack is not limited to only first deposits scenarios, it can have critical impact even if the protocol is not in its zero state. For example, suppose the TVL of project and total supply is 100 already.
Suppose the state is:
On L1
Attacker balance: 12 ETH
, 0 ezETH
, 0 xezETH
Protocol: TVL = 100, TotalSupply = 100, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 200 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker deposits 8 ETH
on L1:
On L1
Attacker balance: 4 ETH
, 8 ezETH
, 0 xezETH
Protocol: TVL = 108, TotalSupply = 108, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 200 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker deposits 8 ezETH
into XERC20Lockbox
and receives 8 xezETH
:
On L1
Attacker balance: 4 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 108, TotalSupply = 108, XERC20Lockbox = 8 ezETH
On L2
Attacker balance: 200 ETH
, 0 xezETH
Protocol: 0 nextWETH
The sendPrice
is called by priceFeedSender
on L1 to send the price feed to L2.
Attacker deposits 200 ETH
on L2, and receives 200 xezETH
.
On L1
Attacker balance: 4 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 108, TotalSupply = 108, XERC20Lockbox = 8 ezETH
On L2
Attacker balance: 0 ETH
, 200 xezETH
Protocol: 200 nextWETH
Attacker donates 4 ETH
on L1.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 112, TotalSupply = 108, XERC20Lockbox = 8 ezETH
On L2
Attacker balance: 0 ETH
, 200 xezETH
Protocol: 200 nextWETH
The sweep
is called on L2 by allowedBridgeSweeper
to bridge the deposited amount in step 5 to L1.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 112, TotalSupply = 108, XERC20Lockbox = 8 ezETH
On L2
Attacker balance: 0 ETH
, 200 xezETH
Protocol: 0 nextWETH
xReceive
is called by Connext to execute the bridging on the destination chain, so 200 WETH
will be deposited into the protocol. Since, TVL is 112 and total supply is 108, only 192 ezETH
will be minted and locked in XERC20Lockbox
. So, the TVL increases to 312, and total supply increases to 300.
On L1
Attacker balance: 0 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 312, TotalSupply = 300, XERC20Lockbox = 200 ezETH
On L2
Attacker balance: 0 ETH
, 200 xezETH
Protocol: 0 nextWETH
Attacker bridges 200 xezETH
on L2 to L1. Connext will transfer these 200 xezETH
to XERC20Lockbox
on L1 to be burned and redeem 200 ezETH
to the attacker.
On L1
Attacker balance: 0 ETH
, 200 ezETH
, 8 xezETH
Protocol: TVL = 312, TotalSupply = 300, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 0 ETH
, 0 xezETH
Protocol: 0 nextWETH
Attacker requests to withdraw the underlying token by burning his 200 ezETH
. When the withdraw is completed, the attacker receives 208 ETH
, because 200*312/300
On L1
Attacker balance: 208 ETH
, 0 ezETH
, 8 xezETH
Protocol: TVL = 104, TotalSupply = 100, XERC20Lockbox = 0 ezETH
On L2
Attacker balance: 0 ETH
, 0 xezETH
Protocol: 0 nextWETH
Now the attacker owns 208 ETH
as well as 8 xezETH
, while the attacker at first had only 212 ETH
. So, the attacker can use these 8 xezETH
to steal from next users.
ezETH
will be locked in XERC20Lockbox
, because 50*100/104
. Now, the attacker burns his 8 xezETH
in XERC20Lockbox
and receives 8 ezETH
which can be burned to withdraw 8 underlying token (Like ETH
), because 8*(104+50)/(100+48)
. Now the attacker owns 208 ETH
as well as 8 stolen ETH
, in total 216 ETH
, while at first he owned 212 ETH
. So, the attacker could steal 4 ETH
from the protocol.Two potential solutions are suggested:
One possible soultion is that xezETH
should not be minted on L2 immediately. When the deposited amount is bridged to L1, then a message should be sent from L1 to L2 stating that how much ezETH
is locked, so the same amount of xezETH
should be minted on L2. In other words, delay minting xezETH
on L2 until the corresponding amount of ezETH
is confirmed to be locked on L1.
Second possible solution is that the value of minted xezETH
on L2 should be sent to L1 as a message to declare that how much ezETH
should be locked on L1 to match the minted xezETH
on L2.
Context
#0 - c4-judge
2024-05-16T08:48:11Z
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
Using the wrong index for the collateral token array causes incorrect calculation of TVL or blocks deposits into the protocol.
When calculating TVL, the code includes ERC20 token balances from the WithdrawQueue. However, this isn't done correctly. Instead of getting the balance of collateralTokens[j]
, it uses the price of collateralTokens[i]
for calculation. Since the prices of these tokens can differ, this leads to inaccurate calculations.
if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue) ); }
Additionally, if the number of operator delegators exceeds the number of collateral tokens, it causes an out-of-bounds error for collateralTokens[i]
. As a result, the calculateTVLs
function always reverts, preventing any deposits into the protocol.
Update the code to use the correct index for collateral tokens:
if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue) ); }
Context
#0 - c4-judge
2024-05-16T10:27:33Z
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:26Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-23T13:47:21Z
alcueca changed the severity to 3 (High Risk)
173.6175 USDC - $173.62
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L175
If the xReceive
function fails to handle reverts properly, it could result in funds getting stuck. This can happen for example when:
ezETH
rate on L1 and L2 could be different.Failure to handle errors in the xReceive
function as outlined by Connext can lead to funds becoming inaccessible.
https://docs.connext.network/developers/guides/handling-failures#reverts-on-receiver-contract
This can happen in several scenarios within the xRenzoBridge
contract:
maxDepositTVL
limit set on L1, the deposit will fail when bridged to L1. This could occur if an attacker uses a large flash loan of WETH
to deposit on L2 and then swaps the minted xezETH
to repay the flash loan.function xReceive( bytes32 _transferId, uint256 _amount, address _asset, address _originSender, uint32 _origin, bytes memory ) external nonReentrant returns (bytes memory) { // ... restakeManager.depositETH{ value: ethAmount }(); //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L175
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { //.... if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) { revert MaxTVLReached(); } //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L510
xezETH
will be minted, but when it is bridged to L1, it will fail. This is because no pausing mechanism is defined on L2 to be aligned with L1.function depositETH( uint256 _minOut, uint256 _deadline ) external payable nonReentrant returns (uint256) { //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L168
function deposit( uint256 _amountIn, uint256 _minOut, uint256 _deadline ) external nonReentrant returns (uint256) { //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L204
ezETH
becomes zero, it will fail. This can happen if between the deposit on L2 and bridging to L1, the TVL increases while total supply of ezETH
does not change. By doing so, each ezETH
is worth more than before. So, when that small amount of WETH
is going to be deposited into the protocol on L1, it mints less ezETH
than the amount of xezETH
minted on L2. If it is zero, it will revert.function xReceive( bytes32 _transferId, uint256 _amount, address _asset, address _originSender, uint32 _origin, bytes memory ) external nonReentrant returns (bytes memory) { // ... restakeManager.depositETH{ value: ethAmount }(); //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L175
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { //.... uint256 ezETHToMint = renzoOracle.calculateMintAmount( totalTVL, collateralTokenValue, ezETH.totalSupply() ); //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L565
function calculateMintAmount( uint256 _currentValueInProtocol, uint256 _newValueAdded, uint256 _existingEzETHSupply ) external pure returns (uint256) { //.... if (mintAmount == 0) revert InvalidTokenAmount(); //.... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L146
To address these potential failures, it is suggested to:
implement error handling in the xReceive
function. Specifically, placing the depositETH
function call within a try/catch block could help manage these failures. Additionally, only authenticated addresses should be allowed to handle these errors.
enforce the deposited amount on L2 to be less than maxDepositTVL
.
define the pausing mechanism on L2.
allow the BrdigeSweepr
to define the amount of token to be bridged to L1. By doing so, it can handle the situations where a large amount is deposited on L2.
function sweep(uint256 _amount) public payable nonReentrant { //... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L414
Context
#0 - c4-judge
2024-05-17T13:37:19Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-17T13:37:25Z
alcueca marked the issue as selected for report
#2 - alcueca
2024-05-17T13:37:39Z
Mitigation of #372 is good as well.
#3 - blckhv
2024-05-25T10:31:43Z
Hey @alcueca,
I think this issue should be of low severity because we can see that xRenzoBridge has functions to retrieve the funds that are "stuck", which is exactly what the first paragraph of the provided Connext documentation advises - https://docs.connext.network/developers/guides/handling-failures#reverts-on-receiver-contract. In all matters, manually distributing failed transactions funds is not an optimal solution, but in the end, funds are not really stuck, since the admin can still process them from the Connext directly:
Thanks!
#4 - s1n1st3r01
2024-05-25T18:14:24Z
Hey @alcueca
https://github.com/code-423n4/2024-04-renzo-findings/issues/287 should be marked as a duplicate of this as well.
@blckhv I'm afraid this is factually incorrect. Connext allows you to re-submit / retry a failed TX only if it failed due to insufficient relayer fee (which is what the sponsor said in the screenshot).
In the case it failed due to the xReceive()
on the destination chain reverting, then the funds will be stuck. Not to mention that this failure is silent. Meaning that admins may be notified that this call failed on the destination chain very late.
That's why the Connext docs says that the IXReceiver contract should be implemented defensively.
If the call on the receiver contract (also referred to as "target" contract) reverts, funds sent in with the call will end up on the receiver contract. To avoid situations where user funds get stuck on the receivers, developers should build any contract implementing IXReceive defensively.
Ultimately, the goal should be to handle any revert-susceptible code and ensure that the logical owner of funds always maintains agency over them.
Bridging silently failing + admins having to manually recover funds and manually redistribute to other parts of the system is surely Medium severity worthy.
#5 - JeffCX
2024-05-25T21:34:36Z
Hey @alcueca
#287 should be marked as a duplicate of this as well.
@blckhv I'm afraid this is factually incorrect. Connext allows you to re-submit / retry a failed TX only if it failed due to insufficient relayer fee (which is what the sponsor said in the screenshot).
In the case it failed due to the
xReceive()
on the destination chain reverting, then the funds will be stuck. Not to mention that this failure is silent. Meaning that admins may be notified that this call failed on the destination chain very late.That's why the Connext docs says that the IXReceiver contract should be implemented defensively.
If the call on the receiver contract (also referred to as "target" contract) reverts, funds sent in with the call will end up on the receiver contract. To avoid situations where user funds get stuck on the receivers, developers should build any contract implementing IXReceive defensively.
Ultimately, the goal should be to handle any revert-susceptible code and ensure that the logical owner of funds always maintains agency over them.
Bridging silently failing + admins having to manually recover funds and manually redistribute to other parts of the system is surely Medium severity worthy.
Agree with this comments
my submission explains that there are several revert conditions
https://github.com/code-423n4/2024-04-renzo-findings/issues/374
🌟 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
Depositing ERC20 collateral tokens below the withdrawal buffer-to-fill amount results in a revert. This means the deposited amount of collateral tokens must be at least equal to the buffer amount. It's an unexpected situation because the protocol doesn't define a limit for a minimum deposit amount.
When depositing collateral tokens into the protocol, it first checks if there's a withdrawal queue buffer to fill. If so, part of the deposited amount is transferred to the DepositQueue
and then to the WithdrawQueue
. However, if the deposited amount is less than the buffer-to-fill, the entire deposited amount is transferred to the WithdrawQueue
, leaving nothing to be transferred to the OperatorDelegator
.
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); }
The problem arises because the function deposit
in the OperatorDelegator
contract expects a non-zero amount. Since the remaining amount after transferring to the WithdrawQueue
becomes zero, calling deposit
reverts.
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, before calling deposit
on the OperatorDelegator
contract, ensure that the remaining amount is greater than zero. Only if there's a non-zero amount remaining should it be deposited into the OperatorDelegator
. This prevents reverts caused by deposits below the buffer-to-fill amount.
Modify the deposit function to check if the remaining amount is greater than zero before calling deposit
on the OperatorDelegator
:
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); } //.... }
Context
#0 - c4-judge
2024-05-20T05:14:23Z
alcueca marked the issue as satisfactory
🌟 Selected for report: fyamf
Also found by: Fassi_Security
1841.3657 USDC - $1,841.37
Failure to store the fetched price from the oracle in the storage variables of the xRenzoDeposit
contract can result in several important issues.
Price Fetching on Deposit: When a deposit is made in xRenzoDeposit
, the getMintRate
function fetches the price of ezETH
.
function deposit( uint256 _amountIn, uint256 _minOut, uint256 _deadline ) external nonReentrant returns (uint256) { //.... return _deposit(_amountIn, _minOut, _deadline); } 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(); //...... }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L204
Oracle vs. Storage: If an oracle is defined, the function retrieves the price from the oracle. Otherwise, it reads the price from the storage variable lastPrice
, which is updated by the updatePrice
function.
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); } }
function updatePrice(uint256 _price, uint256 _timestamp) external override { if (msg.sender != receiver) revert InvalidSender(receiver, msg.sender); _updatePrice(_price, _timestamp); } function _updatePrice(uint256 _price, uint256 _timestamp) internal { //..... lastPrice = _price; lastPriceTimestamp = _timestamp; //..... }
First Issue
Inaccurate Price Usage: The first issue arises when the oracle is used to fetch the price but is not stored in the storage variables. Subsequent calls to getMintRate
after the oracle is undefined may use the outdated price from storage instead of the most recent one fetched from the oracle. This leads to inaccurate pricing and potential financial losses. For example:
lastPrice = 1 ether
and lastPriceTimestamp = k
(k is the timestamp of now)getMintRate
uses the oracle to fetch the price and returns the values from the oracle. For example the oracle returns oraclePrice = 1.02 ether
and oracleTimestamp = k + 3 hours
.getMintRate
uses the storage variable again (because the oracle address is zero) to fetch the price and reads the storage variables. So, it returns lastPrice = 1 ether
and lastPriceTimestamp = k
, instead of the recent fetched price by the oracle 1.02 ether
and k + 3 hours
.It shows that although the recent fetched price and timestamp is 1.02 ether
and k + 3 hours
, respectively, getMintRate
returns the price of the time when they were stored in the storage variables during updating the price about 5 hourse before.
Second Issue
Divergence Check: Another issue is that when the price is updated, the to-be-updated price is compared with the lastPrice
. If, the oracle is used to fetch the price in between the updates, since the fetched price from the oracle is not stored in lastPrice
variable, the to-be-updated price is compared with price stored in the storage variable before the oracle fetch. This undermines the trustworthiness of the divergence check mechanism.
function _updatePrice(uint256 _price, uint256 _timestamp) internal { //.... // Check for price divergence - more than 10% if ( (_price > lastPrice && (_price - lastPrice) > (lastPrice / 10)) || (_price < lastPrice && (lastPrice - _price) > (lastPrice / 10)) ) { revert InvalidOraclePrice(); } //... }
Third Issue Manual Price Updates: The third issue arises when the price update fails due to inaccurate divergence checks. For example:
lastPrice = 1 ether
and lastPriceTimestamp = k
(k is the timestamp now)getMintRate
uses the oracle to fetch the price and returns the values from the oracle. For example: oraclePrice = 1.11 ether
and oracleTimestamp = k + 5 hours
.1.12 ether
. But, since it compares 1.12 ether
with 1 ether
at time k
, its divergence is %12 percent, and it will revert. While, in reality, the divergence is between 1.12 ether
and 1.11
ether (the recent price fetched from the oracle) which is %0.9.In such cases, manual intervention is required by the admin to update the price, which adds complexity and overhead.
function updatePriceByOwner(uint256 price) external onlyOwner { return _updatePrice(price, block.timestamp); }
To address these issues, it's recommended to modify the getMintRate
function to store the fetched price from the oracle in the storage variables. This ensures that the most recent price is used for calculations.
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(); if(oracleTimestamp > lastPriceTimestamp){ lastPrice = oraclePrice; lastPriceTimestamp = oracleTimestamp; return (oraclePrice, oracleTimestamp); } else { return (lastPrice, lastPriceTimestamp); } } else { return (lastPrice, lastPriceTimestamp); } }
Oracle
#0 - c4-judge
2024-05-17T11:59:37Z
alcueca marked the issue as not a duplicate
#1 - c4-judge
2024-05-17T12:06:26Z
alcueca marked the issue as primary issue
#2 - jatinj615
2024-05-21T13:17:15Z
Two independent price feeds to provide the latest price to user if configured.
#3 - alcueca
2024-05-23T10:21:24Z
@jatinj615, please review more carefully. The warden is stating that:
#4 - jatinj615
2024-05-23T20:05:52Z
In practical, we are not using both configurations together. But yeah, can be acknowledged. IMO this is a low severity not a high.
#5 - alcueca
2024-05-24T09:00:19Z
The first issue described by the warden can be described as a governance error.
#6 - c4-judge
2024-05-24T09:00:25Z
alcueca changed the severity to 2 (Med Risk)
#7 - c4-judge
2024-05-24T09:00:46Z
alcueca marked the issue as satisfactory
#8 - c4-judge
2024-05-24T10:22:56Z
alcueca marked the issue as selected for report
#9 - 0xEVom
2024-05-26T20:18:51Z
@alcueca I agree with @jatinj615 here that this is a QA issue.
The first issue can be described as a governance issue as you said.
The second issue would be an issue in case of concurrent use of both the pull and push oracle configurations together as @jatinj615 pointed out, but that was never the intention here. The contract is working as designed, which is by only performing the divergence check on the push mechanism, against previous updates via the same mechanism.
The third issue is an inherent tradeoff of the divergence check - there is no way to prevent this issue while keeping the check, and it can be easily mitigated by the owner calling updatePriceByOwner()
in incremental steps of 10% as pointed out by the warden.
#10 - alcueca
2024-05-27T06:00:56Z
The first issue is a governance issue, as agreed.
There is no documentation on the intended use of the code. The assumption from the original warden that both oracles would be used simultaneously is not an outrageous one, I assumed the same. Under that assumption, the second issue pointed out is valid.
On the third issue, I can see how the code is expected to halt due to large price changes, with an admin calling updatePriceByOwner
to validate that the changes are real.
🌟 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/main/contracts/Deposits/DepositQueue.sol#L283-L289
The administrator has the ability to drain the ETH balance of the DepositQueue
contract by calling certain functions with a high gas price. This can lead to:
NATIVE_ETH_RESTAKE_ADMIN
, calls the stakeEthFromQueue
or stakeEthFromQueueMulti
functions, any consumed gas is refunded to the msg.sender
.function stakeEthFromQueue( IOperatorDelegator operatorDelegator, bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot ) external onlyNativeEthRestakeAdmin { uint256 gasBefore = gasleft(); // Send the ETH and the params through to the restake manager restakeManager.stakeEthInOperatorDelegator{ value: 32 ether }( operatorDelegator, pubkey, signature, depositDataRoot ); emit ETHStakedFromQueue(operatorDelegator, pubkey, 32 ether, address(this).balance); // Refund the gas to the Admin address if enough ETH available _refundGas(gasBefore); }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L187-L206
function stakeEthFromQueueMulti( IOperatorDelegator[] calldata operatorDelegators, bytes[] calldata pubkeys, bytes[] calldata signatures, bytes32[] calldata depositDataRoots ) external onlyNativeEthRestakeAdmin nonReentrant { uint256 gasBefore = gasleft(); // Verify all arrays are the same length if ( operatorDelegators.length != pubkeys.length || operatorDelegators.length != signatures.length || operatorDelegators.length != depositDataRoots.length ) revert MismatchedArrayLengths(); // Iterate through the arrays and stake each one uint256 arrayLength = operatorDelegators.length; for (uint256 i = 0; i < arrayLength; ) { // Send the ETH and the params through to the restake manager restakeManager.stakeEthInOperatorDelegator{ value: 32 ether }( operatorDelegators[i], pubkeys[i], signatures[i], depositDataRoots[i] ); emit ETHStakedFromQueue( operatorDelegators[i], pubkeys[i], 32 ether, address(this).balance ); unchecked { ++i; } } // Refund the gas to the Admin address if enough ETH available _refundGas(gasBefore); }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L211-L250
DepositQueue
contract and is calculated based on (initialGas - gasleft()) * tx.gasprice
.function _refundGas(uint256 initialGas) internal { uint256 gasUsed = (initialGas - gasleft()) * tx.gasprice; uint256 gasRefund = address(this).balance >= gasUsed ? gasUsed : address(this).balance; (bool success, ) = payable(msg.sender).call{ value: gasRefund }(""); if (!success) revert TransferFailed(); emit GasRefunded(msg.sender, gasRefund); }
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L283-L289
tx.gasprice
, it can lead to a substantial drain on the contract's balance. For example the transaction https://etherscan.io/tx/0xc5ae7b766c0186590d4baf648a17b1e59f96c01a540e3b5aee675e87a882555f
shows that calling the function stakeEthFromQueueMulti
for two operator delegators consumes almost 286745 gas. If the admin sets the gas price in the transation to 1000 Gwei, almost 0.287 ETH will be wasted from the contract. The admin does not gain financially, but it can adversly impacts on the protocol.Modify the _refundGas
function to allocate only a small percentage of the contract's balance for gas refunds:
function _refundGas(uint256 initialGas) internal { uint256 gasUsed = (initialGas - gasleft()) * tx.gasprice; uint256 refundPool = address(this).balance / 1000; // %0.1 of the balance is dedicated to the gas refund uint256 gasRefund = refundPool >= gasUsed ? gasUsed : refundPool; (bool success, ) = payable(msg.sender).call{ value: gasRefund }(""); if (!success) revert TransferFailed(); emit GasRefunded(msg.sender, gasRefund); }
Context
#0 - C4-Staff
2024-05-15T14:20:02Z
CloudEllie marked the issue as duplicate of #504
#1 - c4-judge
2024-05-20T02:32:04Z
alcueca marked the issue as not a duplicate
#2 - c4-judge
2024-05-20T02:35:13Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-05-24T09:03:52Z
alcueca marked the issue as grade-a
#4 - c4-judge
2024-05-24T09:20:22Z
alcueca marked the issue as grade-b