Platform: Code4rena
Start Date: 22/09/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 133
Period: 3 days
Judge: 0xean
Total Solo HM: 2
Id: 165
League: ETH
Rank: 29/133
Findings: 4
Award: $101.10
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: ladboy233
Also found by: 0xSmartContract, 8olidity, Aymen0909, Chom, IllIllI, OptimismSec, PaludoX0, TomJ, ayeslick, cccz, csanuragjain, joestakey, neko_nyaa, pashov, peritoflores, rbserver, rvierdiiev
19.982 USDC - $19.98
A malicious timelock_address
and owner
can withdraw all of the ETH submitted to frxETHMinter
contract at anytime through recoverEther
function.
Rug pull vector available in the contract might negatively impact the protocol's reputation.
function recoverEther(uint256 amount) external onlyByOwnGov { (bool success,) = address(owner).call{ value: amount }(""); require(success, "Invalid transfer"); emit EmergencyEtherRecovered(amount); }
Manual Analysis
Consider removing the function or settting timelock_address
and owner
addresses as timelock DAO or multisig.
#0 - FortisFortuna
2022-09-25T21:14:50Z
We are well aware of the permission structure. The owner will most likely be a large multisig. We mentioned the Frax Multisig in the scope too.
#1 - FortisFortuna
2022-09-25T21:16:40Z
We are well aware of the permission structure. The owner will most likely be a large multisig. We mentioned the Frax Multisig in the scope too.
#2 - joestakey
2022-09-26T16:16:07Z
Duplicate of #107
39.1574 USDC - $39.16
There are no upper bounds on how many times depositEther
function of frxETHMinter
contract will make a deposit.
Since gas requirements can change over time, if frxETHMinter
contract receive enormous amount of ETH in a short time,
it may cause the function to fail due to out of gas error.
Therefore, frxETHMinter
contract might end up not being able to execute depositEther
forever unless admin temporary withdraw
some of ETH out of the contract.
No upper bounds set on how many times it will deposit.
uint256 numDeposits = (address(this).balance - currentWithheldETH) / DEPOSIT_SIZE; require(numDeposits > 0, "Not enough ETH in contract"); // Give each deposit chunk to an empty validator for (uint256 i = 0; i < numDeposits; ++i) { // Get validator information ( bytes memory pubKey, bytes memory withdrawalCredential, bytes memory signature, bytes32 depositDataRoot ) = getNextValidator(); // Will revert if there are not enough free validators // Make sure the validator hasn't been deposited into already, to prevent stranding an extra 32 eth // until withdrawals are allowed require(!activeValidators[pubKey], "Validator already has 32 ETH"); // Deposit the ether in the ETH 2.0 deposit contract depositContract.deposit{value: DEPOSIT_SIZE}( pubKey, withdrawalCredential, signature, depositDataRoot );
Manual Analysis
Add upper bound on how much depositEther
function will deposit at once or add function that will deposit only once at each transaction.
#0 - FortisFortuna
2022-09-26T16:30:15Z
Adding a maxLoops parameter or similar can help mitigate this for sure.
#1 - FortisFortuna
2022-09-26T17:22:57Z
🌟 Selected for report: rotcivegaf
Also found by: 0x040, 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, 0xf15ers, 8olidity, Aymen0909, B2, Bahurum, Bnke0x0, Ch_301, CodingNameKiki, Deivitto, Diana, Funen, IllIllI, JC, JLevick, KIntern_NA, Lambda, OptimismSec, PaludoX0, RockingMiles, Rolezn, Sm4rty, Soosh, Tagir2003, Tointer, TomJ, Triangle, Trust, V_B, Waze, Yiko, __141345__, a12jmx, ajtra, asutorufos, ayeslick, aysha, bbuddha, bharg4v, bobirichman, brgltd, bytera, c3phas, cryptostellar5, cryptphi, csanuragjain, datapunk, delfin454000, durianSausage, exd0tpy, gogo, got_targ, jag, joestakey, karanctf, ladboy233, leosathya, lukris02, mics, millersplanet, natzuu, neko_nyaa, obront, oyc_109, parashar, peritoflores, rbserver, ret2basic, rokinot, ronnyx2017, rvierdiiev, sach1r0, seyni, sikorico, slowmoses, tnevler, yasir, yongskiws
28.0172 USDC - $28.02
 
I recommend adding check of 0-address for input validation of critical address parameters. Not doing so might lead to non-functional contract and have to redeploy the contract, when it is updated to 0-address accidentally. For functions other than constructor(), lack of 0-address check might cause loss of funds for the user.
Total of 9 instances found.
ERC20PermitPermissionedMint.sol:constructor(): "timelock_address" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/ERC20/ERC20PermitPermissionedMint.sol#L34
OperatorRegistry.sol:constructor(): "timelock_address" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/OperatorRegistry.sol#L41
frxETHMinter.sol:constructor(): "depositContract" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L60
frxETHMinter.sol:constructor(): "frxETHAddress" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L61
frxETHMinter.sol:constructor(): "sfrxETHToken" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L62
frxETHMinter.sol:submitAndDeposit(): "recipient" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L70
frxETHMinter.sol:moveWithheldETH(): "to" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L170
sfrxETH.sol:depositWithSignature(): "receiver" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L70
sfrxETH.sol:mintWithSignature(): "receiver" address https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L86
Add 0-address check for above addresses.
 
it is best practice to lock your pragma instead of using floating pragma. the use of floating pragma has a risk of accidentally get deployed using latest complier which may have higher risk of undiscovered bugs. Reference: https://consensys.github.io/smart-contract-best-practices/development-recommendations/solidity-specific/locking-pragmas/
Total of 6 instances found.
./sfrxETH.sol:2:pragma solidity ^0.8.0; ./ERC20PermitPermissionedMint.sol:2:pragma solidity ^0.8.0; ./frxETHMinter.sol:2:pragma solidity ^0.8.0; ./OperatorRegistry.sol:2:pragma solidity ^0.8.0; ./frxETH.sol:2:pragma solidity ^0.8.0; ./xERC4626.sol:4:pragma solidity ^0.8.0;
I suggest to lock your pragma and aviod using floating pragma.
// bad pragma solidity ^0.8.10; // good pragma solidity 0.8.10;
 
It is best practice to emit an event when we there is important state changes like update a dynamic array or mapping because it allows monitoring activities with off-chain monitoring tools.
Total of 1 instance found.
Emit an event when there is important state changes.
 
Each event should have 3 indexed fields if there are 3 or more fields.
Total of 19 instances found.
./ERC20PermitPermissionedMint.sol:102: event TokenMinterBurned(address indexed from, address indexed to, uint256 amount); ./ERC20PermitPermissionedMint.sol:103: event TokenMinterMinted(address indexed from, address indexed to, uint256 amount); ./ERC20PermitPermissionedMint.sol:104: event MinterAdded(address minter_address); ./ERC20PermitPermissionedMint.sol:105: event MinterRemoved(address minter_address); ./ERC20PermitPermissionedMint.sol:106: event TimelockChanged(address timelock_address); ./frxETHMinter.sol:205: event EmergencyEtherRecovered(uint256 amount); ./frxETHMinter.sol:206: event EmergencyERC20Recovered(address tokenAddress, uint256 tokenAmount); ./frxETHMinter.sol:207: event ETHSubmitted(address indexed sender, address indexed recipient, uint256 sent_amount, uint256 withheld_amt); ./frxETHMinter.sol:208: event DepositEtherPaused(bool new_status); ./frxETHMinter.sol:209: event DepositSent(bytes indexed pubKey, bytes withdrawalCredential); ./frxETHMinter.sol:210: event SubmitPaused(bool new_status); ./frxETHMinter.sol:211: event WithheldETHMoved(address indexed to, uint256 amount); ./frxETHMinter.sol:212: event WithholdRatioSet(uint256 newRatio); ./OperatorRegistry.sol:208: event TimelockChanged(address timelock_address); ./OperatorRegistry.sol:209: event WithdrawalCredentialSet(bytes _withdrawalCredential); ./OperatorRegistry.sol:210: event ValidatorAdded(bytes pubKey, bytes withdrawalCredential); ./OperatorRegistry.sol:212: event ValidatorRemoved(bytes pubKey, uint256 remove_idx, bool dont_care_about_ordering); ./OperatorRegistry.sol:213: event ValidatorsPopped(uint256 times); ./OperatorRegistry.sol:214: event ValidatorsSwapped(bytes from_pubKey, bytes to_pubKey, uint256 from_idx, uint256 to_idx);
Add up to 3 indexed fields when possible.
 
🌟 Selected for report: pfapostol
Also found by: 0x040, 0x1f8b, 0x4non, 0x5rings, 0xA5DF, 0xNazgul, 0xSmartContract, 0xmatt, 0xsam, Amithuddar, Aymen0909, B2, Ben, Bnke0x0, Chom, CodingNameKiki, Deivitto, Diana, Fitraldys, Funen, IllIllI, JAGADESH, JC, Metatron, Ocean_Sky, PaludoX0, Pheonix, RaymondFam, ReyAdmirado, RockingMiles, Rohan16, Rolezn, Satyam_Sharma, Sm4rty, SnowMan, SooYa, Tagir2003, TomJ, Tomio, Triangle, V_B, Waze, __141345__, ajtra, albincsergo, asutorufos, aysha, beardofginger, bobirichman, brgltd, bulej93, bytera, c3phas, ch0bu, cryptostellar5, cryptphi, d3e4, delfin454000, dharma09, drdr, durianSausage, emrekocak, erictee, fatherOfBlocks, gogo, got_targ, imare, jag, karanctf, ladboy233, leosathya, lukris02, medikko, mics, millersplanet, natzuu, neko_nyaa, oyc_109, peanuts, prasantgupta52, rbserver, ret2basic, rokinot, ronnyx2017, rotcivegaf, sach1r0, samruna, seyni, slowmoses, tnevler, wagmi, zishansami
13.9366 USDC - $13.94
Total of 11 issues found
 
X = X + Y costs less gass than X += Y for state variables. It saves 8 gas.
Total of 2 instance found.
moveWithheldETH
function of frxETHMinter.sol
https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L168
Change it tocurrentWithheldETH = currentWithheldETH - amount;
_submit
function of frxETHMinter.sol
https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L97
Change it tocurrentWithheldETH = currentWithheldETH + withheld_amt;
 
Since below require checks are used more than once, I recommend making these to a modifier or a function.
Total of 2 instances found.
./ERC20PermitPermissionedMint.sol:66: require(minter_address != address(0), "Zero address detected"); ./ERC20PermitPermissionedMint.sol:77: require(minter_address != address(0), "Zero address detected");
./frxETHMinter.sol:171: require(success, "Invalid transfer"); ./frxETHMinter.sol:193: require(success, "Invalid transfer");
I recommend making duplicate require statement into modifier or a function.
 
If the function is not called internally, it is cheaper to set your function visibility to external instead of public.
Total of 3 instances found.
sfrxETH.sol:pricePerShare() https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L54-L56
OperatorRegistry.sol:popValidators() https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/OperatorRegistry.sol#L82-L89
OperatorRegistry.sol:removeValidators() https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/OperatorRegistry.sol#L93-L122
Change the function visibility to external.
 
It is cheaper gas to use calldata than memory if the function parameter is read only. Calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory. More details on following link. link: https://docs.soliditylang.org/en/v0.8.15/types.html#data-location
Total of 3 instances found.
./OperatorRegistry.sol:172: bytes memory pubKey, ./OperatorRegistry.sol:173: bytes memory signature, ./OperatorRegistry.sol:181: function setWithdrawalCredential(bytes memory _new_withdrawal_pubkey) external onlyByOwnGov {
Change memory to calldata
 
It is more gas expensive to compare boolean with "variable == true" or "variable == false" than directly checking the returned boolean value.
Total of 3 instances found.
./ERC20PermitPermissionedMint.sol:68: require(minters[minter_address] == false, "Address already exists"); ./ERC20PermitPermissionedMint.sol:46: require(minters[msg.sender] == true, "Only minters"); ./ERC20PermitPermissionedMint.sol:78: require(minters[minter_address] == true, "Address nonexistant");
Simply check by returned boolean value. Example:
For false: require(!somethinghere); For true: require(somethinghere);
 
Since EVM operates on 32 bytes at a time, if the element is smaller than that, the EVM must use more operations in order to reduce the elements from 32 bytes to specified size.
Reference: https://docs.soliditylang.org/en/v0.8.15/internals/layout_in_storage.html
Total of 8 instances found.
./sfrxETH.sol:64: uint8 v, ./sfrxETH.sol:80: uint8 v, ./xERC4626.sol:48: uint192 lastRewardAmount_ = lastRewardAmount; ./xERC4626.sol:49: uint32 rewardsCycleEnd_ = rewardsCycleEnd; ./xERC4626.sol:50: uint32 lastSync_ = lastSync; ./xERC4626.sol:79: uint192 lastRewardAmount_ = lastRewardAmount; ./xERC4626.sol:80: uint32 timestamp = block.timestamp.safeCastTo32(); ./xERC4626.sol:89: uint32 end = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength;
I suggest using uint256 instead of anything smaller and downcast where needed.
 
By storing an array's length as a variable before the for-loop, can save 3 gas per iteration.
Total of 2 instances found.
./ERC20PermitPermissionedMint.sol:84: for (uint i = 0; i < minters_array.length; i++){ ./OperatorRegistry.sol:114: for (uint256 i = 0; i < original_validators.length; ++i) {
Store array's length as a variable before looping it. For example, I suggest changing it to
uint length = minters_array.length; for (uint i = 0; i < minters_array.length; i++){
 
Prefix increments/decrements (++i or --i) costs cheaper gas than postfix increment/decrements (i++ or i--).
Total of 1 instance found.
./ERC20PermitPermissionedMint.sol:84: for (uint i = 0; i < minters_array.length; i++){
Change it to postfix increments/decrements. It saves 6 gas per loop.
 
!= 0 costs less gas when optimizer is enabled and is used for unsigned integers in require statement.
Total of 2 instances found.
./frxETHMinter.sol:79: require(sfrxeth_recieved > 0, 'No sfrxETH was returned'); ./frxETHMinter.sol:126: require(numDeposits > 0, "Not enough ETH in contract");
I suggest changing > 0 to != 0
 
Since each storage slot is size of 32 bytes, error messages that is longer than this will need extra storage slot leading to more gas cost.
Total of 1 instance found.
./frxETHMinter.sol:167: require(amount <= currentWithheldETH, "Not enough withheld ETH in contract");
Simply keep the error messages within 32 bytes to avoid extra storage slot cost.
 
Custom errors from Solidity 0.8.4 are cheaper than revert strings. Details are explained here: https://blog.soliditylang.org/2021/04/21/custom-errors/
Total of 22 instances found.
./ERC20PermitPermissionedMint.sol:41: require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); ./ERC20PermitPermissionedMint.sol:46: require(minters[msg.sender] == true, "Only minters"); ./ERC20PermitPermissionedMint.sol:66: require(minter_address != address(0), "Zero address detected"); ./ERC20PermitPermissionedMint.sol:68: require(minters[minter_address] == false, "Address already exists"); ./ERC20PermitPermissionedMint.sol:77: require(minter_address != address(0), "Zero address detected"); ./ERC20PermitPermissionedMint.sol:78: require(minters[minter_address] == true, "Address nonexistant"); ./ERC20PermitPermissionedMint.sol:95: require(_timelock_address != address(0), "Zero address detected"); ./frxETHMinter.sol:79: require(sfrxeth_recieved > 0, 'No sfrxETH was returned'); ./frxETHMinter.sol:87: require(!submitPaused, "Submit is paused"); ./frxETHMinter.sol:88: require(msg.value != 0, "Cannot submit 0"); ./frxETHMinter.sol:122: require(!depositEtherPaused, "Depositing ETH is paused"); ./frxETHMinter.sol:126: require(numDeposits > 0, "Not enough ETH in contract"); ./frxETHMinter.sol:140: require(!activeValidators[pubKey], "Validator already has 32 ETH"); ./frxETHMinter.sol:160: require (newRatio <= RATIO_PRECISION, "Ratio cannot surpass 100%"); ./frxETHMinter.sol:167: require(amount <= currentWithheldETH, "Not enough withheld ETH in contract"); ./frxETHMinter.sol:171: require(success, "Invalid transfer"); ./frxETHMinter.sol:193: require(success, "Invalid transfer"); ./frxETHMinter.sol:200: require(IERC20(tokenAddress).transfer(owner, tokenAmount), "recoverERC20: Transfer failed"); ./OperatorRegistry.sol:46: require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); ./OperatorRegistry.sol:137: require(numVals != 0, "Validator stack is empty"); ./OperatorRegistry.sol:182: require(numValidators() == 0, "Clear validator array first"); ./OperatorRegistry.sol:203: require(_timelock_address != address(0), "Zero address detected");
I suggest implementing custom errors to save gas.