Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 39/246
Findings: 5
Award: $166.09
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: CodingNameKiki
Also found by: 0xd1r4cde17a, Franfran, MadWookie, MiloTruck, Moliholy, adriro, ast3ros, bin2chen, giovannidisiena, gjaldon, igingu, koxuan, rbserver, rvierdiiev, shaka, slippopz
81.3214 USDC - $81.32
https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/deposit/RocketDepositPool.sol#L78 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/deposit/RocketDepositPool.sol#L80 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/token/RocketTokenRETH.sol#L39 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L63 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L186-L203 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L92-L94
When users are staking funds using the stake()
function, the value of their newly minted derivative tokens is calculated according to their underlying values in ETH by calling the ethPerDerivative()
view function, which is common to all derivatives. This one takes into account the amount of derivates that have been deposited just previously.
The issue is that this value calculation is done too late. It is done for the "next staker", leading to wrong values calculations.
Currently, assymetry finance has added 3 derivatives:
In order to calculate their value in ETH, it uses the ethPerDerivative()
function.
For SfrxEth
and WstEth
, it's more straightforward. SfrxEth
price is calculated using the curve price TWAP, whereas WstEth
is a ratio between 1 ETH and 1 WstEth.
Reth
, though, is different. We first need to check if the asset can be deposited according to two rules from RocketPool:
If those two rules pass, then we ask the rETH contract for its exchange rate. Else, we get the spot price from Uniswap. The issue is that those two price sources will never be 100% the same, altough they will tend to with arbitrage opportunities which won't stay for long.
Let's consider this scenario (all the values have been chosen arbitrarly and for the sake of this POC).
stake()
, it's taking the "poolCanDeposit()" path, worthing a price X (the rocket deposit pool current exchange rate)ethPerDerivative()
is then called, the pool cannot contain so much ETH, or would be exceeded by 50 ETH. So it takes the "Uniswap spot price" path and return a price YIf Bob receives too much tokens, then there is a leak of value for the protocol. If Bob receives too few tokens, Bob won't be happy.
Manual inspection
Make sure to get the value in ETH from ethPerDerivative()
before depositing
#0 - c4-pre-sort
2023-04-04T17:48:10Z
0xSorryNotSorry marked the issue as duplicate of #1004
#1 - c4-judge
2023-04-21T14:03:52Z
Picodes marked the issue as duplicate of #1125
#2 - c4-judge
2023-04-21T14:20:22Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2023-04-21T14:20:27Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-04-21T14:20:47Z
Picodes marked the issue as duplicate of #1004
#5 - c4-judge
2023-04-24T21:40:08Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: adriro
Also found by: 0xMirce, 0xRajkumar, 0xepley, BPZ, Bahurum, Bauer, Co0nan, Emmanuel, Franfran, HollaDieWaldfee, IgorZuk, MiloTruck, NoamYakov, RedTiger, Ruhum, T1MOH, Tricko, ad3sh_, auditor0517, bin2chen, carrotsmuggler, eyexploit, handsomegiraffe, igingu, jasonxiale, koxuan, lukris02, monrel, nadin, peanuts, rbserver, rvierdiiev, shaka, sinarette, tnevler, y1cunhui
4.5426 USDC - $4.54
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L57 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L60
When a user wants to withdraw some derivative assets for their ETH
back from the wstETH
derivative (The wrapped token of the stETH
from Lido), the derivative contract is unwrapping
the assets (convert from wstETH
=> stETH
), and then using the ETH
<> stETH
curve pool in order to swap them back to ETH
.
As a slippage protection, the minOut
variable is calculated with a 1% slippage to avoid any malicious attacker to frontrun the pool swap, or to unadvertly buy the token after that a whale would have dumped it.
The issue lies in the fact that this variable is calculated from the stETH
balance, while it should be calculated from an amount in ETH
. The assumption that 1 stETH
== 1 ETH
is wrong because the peg cannot be perfect, and in some stormy market conditions it can get worse.
For reference, the 24h price range of the stETH
went as low as 0.9865 ETH
and as high as 1.0431 ETH
from the coinmarketcap website on 03/28/23. This is a 1.3%
undershoot compared to a perfect peg of 1:1
while in some fairly stable market conditions, and is over the 1%
slippage check by 30%
. This means that once the stETH
price is not in peg, any curve swap will fail and that will DOS any withdrawals. We can thus assert about the likelyhood of this issue.
Manual inspection
Get the undelying value in ETH of the stETH, and apply the slippage calculation on that.
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol
function withdraw(uint256 _amount) external onlyOwner { IWStETH(WST_ETH).unwrap(_amount); uint256 stEthBal = IERC20(STETH_TOKEN).balanceOf(address(this)); IERC20(STETH_TOKEN).approve(LIDO_CRV_POOL, stEthBal); uint256 stToEthBal = ethPerDerivative(stEthBal); uint256 minOut = (stToEthBal * (10 ** 18 - maxSlippage)) / 10 ** 18; IStEthEthPool(LIDO_CRV_POOL).exchange(1, 0, stEthBal, minOut); // solhint-disable-next-line (bool sent, ) = address(msg.sender).call{value: address(this).balance}( "" ); require(sent, "Failed to send Ether"); }
#0 - c4-pre-sort
2023-04-04T17:15:05Z
0xSorryNotSorry marked the issue as duplicate of #588
#1 - c4-judge
2023-04-23T11:07:05Z
Picodes changed the severity to 3 (High Risk)
#2 - c4-judge
2023-04-24T20:45:36Z
Picodes marked the issue as satisfactory
🌟 Selected for report: silviaxyz
Also found by: 0xMirce, 0xbepresent, CodingNameKiki, Franfran, HollaDieWaldfee, MiloTruck, Tricko, adriro, codeislight, cryptonue, d3e4, ladboy233, rbserver, shaka, volodya
28.8013 USDC - $28.80
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L198 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/deposit/RocketDepositPool.sol#L77 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/dao/protocol/settings/RocketDAOProtocolSettings.sol#L54-L57 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L120-L150
When depositing rETH
assets, the Rocket deposit pool is called.
From the source code, we can see that there is a check for the current state of deposits.
This feature from RocketPool is to make sure that no-one will ever deposit into the deposit pool if deposits are disabled.
As we can see from the deposit()
function of the rETH derivative, it first check if the pool cannot deposit, i.e if the amount sent to deposit is correct (not too high, not too small), and that the pool capacity hasn't been reached yet. If it cannot deposit, then it proceeds to use the rETH/ETH liquidity pool not to break the contract if the pool is full, but it's missing this disabled pool check.
As this is not checked, then in the case if the pool isn't full and that it's still disabled (which is a value that can be arbitrarly set by the rocket dao and retrieved here), the check will pass and will try to deposit on the rocket pool. This will fail and will completely freeze any deposit from the SafEth
contract.
Manual inspection
Make sure to check if the rocket pool is still enabled in order to proceed to the deposit. Else, swap as the other path.
function poolCanDeposit(uint256 _amount) private view returns (bool) { address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); address rocketProtocolSettingsAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked( "contract.address", "rocketDAOProtocolSettingsDeposit" ) ) ); RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface( rocketProtocolSettingsAddress ); return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit() && rocketDAOProtocolSettingsDeposit.getDepositEnabled(); }
#0 - c4-pre-sort
2023-04-04T18:56:43Z
0xSorryNotSorry marked the issue as duplicate of #812
#1 - c4-judge
2023-04-24T19:42:54Z
Picodes marked the issue as satisfactory
🌟 Selected for report: RaymondFam
Also found by: 0xepley, BPZ, Franfran, Parad0x, RedTiger, d3e4, fyvgsk, handsomegiraffe, ladboy233, rbserver, silviaxyz, whoismatthewmc1, yac
40.6368 USDC - $40.64
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/deposit/RocketDepositPool.sol#L82 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/dao/protocol/settings/RocketDAOProtocolSettings.sol#L39-L43
The deposit()
function in the rETH
derivative contract calls the RocketPool pool deposit()
function in order to deposit the ETH
tokens to the pool if the latter still have some space in it.
This deposit function charges a fee which is currently at 0.0005 ETH
as can be read from the RocketDAOProtocolSettings's getDepositFee()
that is deducted from the staked amount.
This may seem to be a negligible amount, but keep in mind that this fee can be arbitrarly set by the Rocket DAO to any value. That means that if the fee gets too high, then depositors may receive much less rETH
than expected because those would be deducted as fees from the pool.
Manual inspection
There could be a slippage check provided by the user in each deposit of derivative in the stake()
function.
#0 - c4-pre-sort
2023-04-04T20:33:58Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-04-07T19:58:51Z
toshiSat marked the issue as sponsor acknowledged
#2 - c4-judge
2023-04-23T12:21:32Z
Picodes changed the severity to QA (Quality Assurance)
#3 - Picodes
2023-04-23T12:22:04Z
Downgrading to low as adding a slippage for a fixed fee only covers edge cases
#4 - c4-judge
2023-04-24T18:22:07Z
This previously downgraded issue has been upgraded by Picodes
#5 - c4-judge
2023-04-24T18:22:17Z
Picodes marked the issue as duplicate of #365
#6 - c4-judge
2023-04-24T18:22:22Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Rolezn
Also found by: 0x3b, 0xGordita, 0xSmartContract, 0xhacksmithh, 0xnev, 0xpanicError, 4lulz, Angry_Mustache_Man, ArbitraryExecution, Aymen0909, Bason, BlueAlder, EvanW, Franfran, HHK, Haipls, IgorZuk, JCN, KrisApostolov, Madalad, MiksuJak, MiniGlome, RaymondFam, ReyAdmirado, Rickard, Sathish9098, Udsen, adriro, alexzoid, anodaram, arialblack14, c3phas, carlitox477, ch0bu, chaduke, codeslide, d3e4, dicethedev, ernestognw, fatherOfBlocks, georgits, hunter_w3b, inmarelibero, lukris02, mahdirostami, maxper, pavankv, pixpi, rotcivegaf, smaul, tank, tnevler, wen, yac
10.7864 USDC - $10.79
Name | Finding | Instances | Gas saved |
---|---|---|---|
[G-1] | Duplicated require()/revert()/assert() checks should be refactored to a modifier or an internal function | 3 | 60282 |
[G-2] | Use custom errors instead of revert strings | 10 | 2540 |
[G-3] | Setting the constructor to payable | 4 | 864 |
[G-4] | Avoid using public for immutable/constant state variables | 11 | 272899 |
[G-5] | Use calldata instead of memory for external function parameters | 2 | / |
[G-6] | Functions guaranteed to revert for normal users should be marked as payable | 17 | 408 |
[G-7] | use ternary operators rather than if/else | 3 | 39 |
[G-8] | No need to initialize variables to their default values | 7 | / |
[G-9] | Cache rocket-style string encoded into immutables | 6 | 864 |
Duplicated require, revert, or assert messages should be refactored to an internal or private function in order to save some gas on deployment. The more duplicated it is, the greater the savings, but this always saves gas starting from two duplication.
contracts/SafEth/derivatives/Reth.sol
200:12
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L200
uint256 rethBalance2 = rocketTokenRETH.balanceOf(address(this)); > require(rethBalance2 > rethBalance1, "No rETH was minted"); uint256 rethMinted = rethBalance2 - rethBalance1;
20094
<details> <summary>Locations</summary> <br>Solidity 0.8.4 added the custom errors functionality, which can be used instead of revert strings, resulting in big gas savings on errors since they all just consist of the 4 bytes signature of the error, similar to functions. They can then be decoded thanks to the ABI.
contracts/SafEth/SafEth.sol
64:8
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L64
function stake() external payable { > require(pauseStaking == false, "staking is paused"); require(msg.value >= minAmount, "amount too low");
254
<details> <summary>Locations</summary> <br>https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L200
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L113
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L66
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L109
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L65
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L127
Marking the constructor as payable removes any value check from the compiler and saves some gas on deployment.
contracts/SafEth/derivatives/WstEth.sol
24:4
/// @custom:oz-upgrades-unsafe-allow constructor > constructor() { > _disableInitializers(); > }
216
<details> <summary>Locations</summary> <br>https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L38
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L33
Public state variable are generating a getter function which costs more gas on deployment. Some variables may not need to require a getter function.
contracts/SafEth/derivatives/SfrxEth.sol
18:4
0x5E8422345238F34275888049021821E8E08CAa1f; > address public constant FRX_ETH_CRV_POOL_ADDRESS = > 0xa1F8A6807c402E4A15ef4EBa36528A3FED24E577; address public constant FRX_ETH_MINTER_ADDRESS =
24809
<details> <summary>Locations</summary> <br>https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L26
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L20
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L24
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L22
By the use of the memory
keyword, all of the variables from the function parameter are copied to the memory by using the opcode CALLDATACOPY
. This opcode gas cost grows linearly as a function of the number of slots to copy plus the memory expansion cost which can grow quadratically if there are a lot of slots to copy. If there is no need to alter the variables and store them somewhere, then we can safely load them from the calldata. See on evm.codes for more informations: https://www.evm.codes/#37
contracts/SafEth/SafEth.sol
50:8
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L50
<details> <summary>Locations</summary> <br> </details>string memory _tokenName, > string memory _tokenSymbol ) external initializer {
payable
When a function is restricted to only one account, it's less expensive to mark it as payable
since it's going to remove any opcode relative to the msg.value check
contracts/SafEth/derivatives/SfrxEth.sol
60:48
*/ > function withdraw(uint256 _amount) external onlyOwner { IsFrxEth(SFRX_ETH_ADDRESS).redeem(
24
<details> <summary>Locations</summary> <br>https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L214
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L232
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L185
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L138
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L107
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L205
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L58
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L223
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L241
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L168
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L156
if/else
The if/else
statement runtime gas cost is higher than a ternary operator in some cases.
contracts/SafEth/derivatives/Reth.sol
212:8
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L212
function ethPerDerivative(uint256 _amount) public view returns (uint256) { > if (poolCanDeposit(_amount)) > return > RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); > else return (poolPrice() * 10 ** 18) / (10 ** 18); }
13
<details> <summary>Locations</summary> <br>https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L79
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/derivatives/Reth.sol#L170
In the EVM, everything is an empty word (0) of 32 bytes. Variables in Solidity does not need to be assigned explicitely to their default value as they already have these values and incurs a higher gas overhead without the optimizer turned on.
contracts/SafEth/SafEth.sol
71:13
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L71
<details> <summary>Locations</summary> <br>// Getting underlying value in terms of ETH for each derivative > for (uint i = 0; i < derivativeCount; i++) underlyingValue +=
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L113
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L84
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L140
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L191
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L147
https://github.com/code-423n4/2023-03-asymmetry/tree/main/contracts/SafEth/SafEth.sol#L171
RocketPool introduced the use of keccak-ed concatenated strings for their eternal storage. This ensure not to have any conflict with any other contract because those are unique. They also also allow to set any key to any value and their library provide helpers to cast the variables to the correct types. But this can be optimized by writing it as an immutable, since Solidity supports it and will be evaluated at compile-time.
contracts/SafEth/derivatives/Reth.sol
70:25
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L70
RocketStorageInterface(ROCKET_STORAGE_ADDRESS).getAddress( > keccak256( > abi.encodePacked("contract.address", "rocketTokenRETH") > ) );
144
<details> <summary>Locations</summary> <br>#0 - c4-sponsor
2023-04-10T19:33:41Z
toshiSat marked the issue as sponsor acknowledged
#1 - c4-judge
2023-04-23T19:05:40Z
Picodes marked the issue as grade-a
#2 - c4-judge
2023-04-23T19:05:44Z
Picodes marked the issue as grade-b