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: 10/133
Findings: 4
Award: $725.29
š Selected for report: 1
š Solo Findings: 0
39.1574 USDC - $39.16
https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L118-L155 https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/OperatorRegistry.sol#L124-L148
Could be the case where the validators available are lower than the total deposit chunks and when try use the depositEther()
function won't be able to deposit into the depositContract because the getNextValidator()
reverts when the validators array it's empty
For example:
We have 2 validators available and 32 * 4 ETH balance in frxETHMinter
The depositEther()
try create 4 deposits but only have validators for 2 and in the third cycle it broke
) = getNextValidator(); // Will revert if there are not enough free validators
require(numVals != 0, "Validator stack is empty");
Review
Send the numDeposits
as a parameter to control the deposits
/// @notice Deposit batches of ETH to the ETH 2.0 deposit contract /// @dev Usually a bot will call this periodically function depositEther(uint256 numDeposits) external nonReentrant { // Initial pause check require(!depositEtherPaused, "Depositing ETH is paused"); if (numDeposits == 0) { // See how many deposits can be made. Truncation desired. 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 ); // Set the validator as used so it won't get an extra 32 ETH activeValidators[pubKey] = true; emit DepositSent(pubKey, withdrawalCredential); } }
#0 - FortisFortuna
2022-09-26T16:30:46Z
Adding a maxLoops parameter or similar can help mitigate this for sure.
#1 - FortisFortuna
2022-09-26T17:22:48Z
š Selected for report: cccz
Also found by: Trust, rotcivegaf, wagmi
393.0581 USDC - $393.06
https://github.com/corddry/ERC4626/blob/643cd044fac34bcbf64e1c3790a5126fec0dbec1/src/xERC4626.sol#L43-L62 https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L73-L87
To use mintWithSignature
function need make a signature and send the transaction
There are a window time since the signer make the signature, send the mintWithSignature
transaction and this one it's confirmed
In this window time the previewMint
calculation could be change because the previewMint
calculation use totalSupply
and totalAssets
in his equation and this variables are changed everytime with an deposit, mint, withdraw, etc
The signer was signed the permit
with an old amount
and this one change, invalidating the signature, and revert the transaction
uint256 amount = approveMax ? type(uint256).max : previewMint(shares);
Review
approveMax
logic for a assetsApproved
assetsApproved
was the amount of assets who the contract has the permitassetsApproved
with the previewMint
function offchain and permit an extra percentage amountfunction mintWithSignature( uint256 assetsApproved, uint256 shares, address receiver, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external nonReentrant returns (uint256 assets) { asset.permit(msg.sender, address(this), assetsApproved, deadline, v, r, s); return (mint(shares, receiver)); }
#0 - FortisFortuna
2022-09-25T23:38:30Z
Technically correct, though in practice, we will allow user-defined slippage on the UI
#1 - joestakey
2022-09-26T16:04:20Z
Duplicate of #35
#2 - 0xean
2022-10-12T14:11:49Z
closing as dupe of #35
š 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
274.2124 USDC - $274.21
L-N | Issue | Instances |
---|---|---|
[Lā01] | Missing zero address checks | 9 |
[Lā02] | Draft OpenZeppelin Dependencies | 1 |
[Lā03] | Don't use owner and timelock | 2 |
Total: 12 instances over 3 issues
N-N | Issue | Instances |
---|---|---|
[Nā01] | Unused imports | 2 |
[Nā02] | Non-library/interface files should use fixed compiler versions, not floating ones | 6 |
[Nā03] | Lint | 11 |
[Nā04] | Event is missing indexed fields | 19 |
[Nā05] | Functions, parameters and variables in snake case | 31 |
[Nā06] | Wrong event parameter name | 2 |
[Nā07] | Simplify depositWithSignature function | 1 |
Total: 72 instances over 7 issues
File: /src/ERC20/ERC20PermitPermissionedMint.sol 26 address _timelock_address,
File: /src/sfrxETH.sol 42 constructor(ERC20 _underlying, uint32 _rewardsCycleLength)
File: /src/frxETHMinter.sol 53 address depositContractAddress, 54 address frxETHAddress, 55 address sfrxETHAddress, 57 address _timelock_address, 70 function submitAndDeposit(address recipient) external payable returns (uint256 shares) { 166 function moveWithheldETH(address payable to, uint256 amount) external onlyByOwnGov {
File: /src/OperatorRegistry.sol /*_timelock_address parameter*/ 40 constructor(address _owner, address _timelock_address, bytes memory _withdrawal_pubkey) Owned(_owner) {
The ERC20PermitPermissionedMint.sol
contract heredit from an OpenZeppelin contract who is still a draft and is not considered ready for mainnet use. OpenZeppelin contracts may be considered draft contracts if they have not received adequate security auditing or are liable to change with future development.
Ensure the development team is aware of the risks of using a draft contract or consider waiting until the contract is finalised.
Otherwise, make sure that development team are aware of the risks of using a draft OpenZeppelin contract and accept the risk-benefit trade-off.
Also could evaluate changing to the solmate contracts since his ERC20 implementation already has the EIP-2612 permit
File: /src/ERC20/ERC20PermitPermissionedMint.sol 6 import "openzeppelin-contracts/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";
owner
and timelock
Using a timelock
contract gives confidence to the user, but when check onlyByOwnGov
allow the owner
and the timelock
The owner
manipulate the contract without a lock time period
Owned
permissiontimelock_address
timelock
contractFile: /src/frxETH.sol 38 address _timelock_address 40 ERC20PermitPermissionedMint(_creator_address, _timelock_address, "Frax Ether", "frxETH")
File: /src/ERC20/ERC20PermitPermissionedMint.sol 16 address public timelock_address; 26 address _timelock_address, 34 timelock_address = _timelock_address; 41 require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); 94 function setTimelock(address _timelock_address) public onlyByOwnGov { 95 require(_timelock_address != address(0), "Zero address detected"); 96 timelock_address = _timelock_address; 97 emit TimelockChanged(_timelock_address); 98 } 106 event TimelockChanged(address timelock_address);
File: /src/frxETH.sol 38 address _timelock_address 40 ERC20PermitPermissionedMint(_creator_address, _timelock_address, "Frax Ether", "frxETH")
File: /src/OperatorRegistry.sol 38 address public timelock_address; 40 constructor(address _owner, address _timelock_address, bytes memory _withdrawal_pubkey) Owned(_owner) { 41 timelock_address = _timelock_address; 46 require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); 202 function setTimelock(address _timelock_address) external onlyByOwnGov { 203 require(_timelock_address != address(0), "Zero address detected"); 204 timelock_address = _timelock_address; 205 emit TimelockChanged(_timelock_address); 206 } 208 event TimelockChanged(address timelock_address);
File: /src/frxETHMinter.sol 57 address _timelock_address, 59 ) OperatorRegistry(_owner, _timelock_address, _withdrawalCredential) {
File: /src/ERC20/ERC20PermitPermissionedMint.sol 4 import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 5 import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
File: /src/ERC20/ERC20PermitPermissionedMint.sol 2 pragma solidity ^0.8.0;
File: /src/frxETH.sol 2 pragma solidity ^0.8.0;
File: /src/sfrxETH.sol 2 pragma solidity ^0.8.0;
File: /src/frxETHMinter.sol 2 pragma solidity ^0.8.0;
File: /src/OperatorRegistry.sol 2 pragma solidity ^0.8.0;
File: /src/xERC4626.sol 4 pragma solidity ^0.8.0;
Wrong identation:
File: /src/ERC20/ERC20PermitPermissionedMint.sol From: 30 ERC20(_name, _symbol) 31 ERC20Permit(_name) 32 Owned(_creator_address) To: 30 ERC20(_name, _symbol) 31 ERC20Permit(_name) 32 Owned(_creator_address)
File: /src/frxETH.sol From: 37 address _creator_address, 38 address _timelock_address To: 37 address _creator_address, 38 address _timelock_address From: 40 ERC20PermitPermissionedMint(_creator_address, _timelock_address, "Frax Ether", "frxETH") To: 40 ERC20PermitPermissionedMint(_creator_address, _timelock_address, "Frax Ether", "frxETH")
Don't use extra parenthesis:
File: /src/sfrxETH.sol 70 return (deposit(assets, receiver)); 86 return (mint(shares, receiver));
Miss space:
File: /src/ERC20/ERC20PermitPermissionedMint.sol 84:56 for (uint i = 0; i < minters_array.length; i++){
Remove space:
File: /src/ERC20/ERC20PermitPermissionedMint.sol 63 \n
File: /src/frxETH.sol 34 \n 42 \n
File: /src/sfrxETH.sol 88 \n
File: /src/OperatorRegistry.sol 29 \n
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.
File: /src/frxETHMinter.sol 205 event EmergencyEtherRecovered(uint256 amount); 206 event EmergencyERC20Recovered(address tokenAddress, uint256 tokenAmount); 207 event ETHSubmitted(address indexed sender, address indexed recipient, uint256 sent_amount, uint256 withheld_amt); 208 event DepositEtherPaused(bool new_status); 209 event DepositSent(bytes indexed pubKey, bytes withdrawalCredential); 210 event SubmitPaused(bool new_status); 211 event WithheldETHMoved(address indexed to, uint256 amount); 212 event WithholdRatioSet(uint256 newRatio);
File: /src/OperatorRegistry.sol 208 event TimelockChanged(address timelock_address); 209 event WithdrawalCredentialSet(bytes _withdrawalCredential); 210 event ValidatorAdded(bytes pubKey, bytes withdrawalCredential); 212 event ValidatorRemoved(bytes pubKey, uint256 remove_idx, bool dont_care_about_ordering); 213 event ValidatorsPopped(uint256 times); 214 event ValidatorsSwapped(bytes from_pubKey, bytes to_pubKey, uint256 from_idx, uint256 to_idx);
File: /src/ERC20/ERC20PermitPermissionedMint.sol 102 event TokenMinterBurned(address indexed from, address indexed to, uint256 amount); 103 event TokenMinterMinted(address indexed from, address indexed to, uint256 amount); 104 event MinterAdded(address minter_address); 105 event MinterRemoved(address minter_address); 106 event TimelockChanged(address timelock_address);
Use camel case for all functions, parameters and variables and snake case for constants
File: /src/ERC20/ERC20PermitPermissionedMint.sol 16 address public timelock_address; 19 address[] public minters_array; // Allowed to mint 25 address _creator_address, 26 address _timelock_address, 53 function minter_burn_from(address b_address, uint256 b_amount) public onlyMinters { 59 function minter_mint(address m_address, uint256 m_amount) public onlyMinters { 65 function addMinter(address minter_address) public onlyByOwnGov { 76 function removeMinter(address minter_address) public onlyByOwnGov { 94 function setTimelock(address _timelock_address) public onlyByOwnGov { 104 event MinterAdded(address minter_address); 105 event MinterRemoved(address minter_address); 106 event TimelockChanged(address timelock_address);
File: /src/frxETH.sol 37 address _creator_address, 38 address _timelock_address
File: /src/frxETHMinter.sol 57 address _timelock_address, 78 uint256 sfrxeth_recieved = sfrxETHToken.deposit(msg.value, recipient); 94 uint256 withheld_amt = 0; 208 event DepositEtherPaused(bool new_status); 210 event SubmitPaused(bool new_status);
File: /src/OperatorRegistry.sol 37 bytes curr_withdrawal_pubkey; // Pubkey for ETH 2.0 withdrawal creds. If you change it, you must empty the validators array 38 address public timelock_address; 40 constructor(address _owner, address _timelock_address, bytes memory _withdrawal_pubkey) Owned(_owner) { 69 function swapValidator(uint256 from_idx, uint256 to_idx) public onlyByOwnGov { 93 function removeValidator(uint256 remove_idx, bool dont_care_about_ordering) public onlyByOwnGov { 95 bytes memory removed_pubkey = validators[remove_idx].pubKey; 108 Validator[] memory original_validators = validators; 181 function setWithdrawalCredential(bytes memory _new_withdrawal_pubkey) external onlyByOwnGov { 202 function setTimelock(address _timelock_address) external onlyByOwnGov { 208 event TimelockChanged(address timelock_address); 212 event ValidatorRemoved(bytes pubKey, uint256 remove_idx, bool dont_care_about_ordering); 214 event ValidatorsSwapped(bytes from_pubKey, bytes to_pubKey, uint256 from_idx, uint256 to_idx);
event
parameter nameReplace to
parameter of TokenMinterBurned
event to minter
Replace from
parameter of TokenMinterMinted
event to minter
File: /src/ERC20/ERC20PermitPermissionedMint.sol 102 event TokenMinterBurned(address indexed from, address indexed to, uint256 amount); 103 event TokenMinterMinted(address indexed from, address indexed to, uint256 amount);
depositWithSignature
functionThe parameter approveMax
of depositWithSignature
function could be remove, the permit assets
should be always equal to deposit assets
File: /src/sfrxETH.sol
/// @notice Approve and deposit() in one transaction function depositWithSignature( uint256 assets, address receiver, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external nonReentrant returns (uint256 shares) { asset.permit(msg.sender, address(this), assets, deadline, v, r, s); return (deposit(assets, receiver)); }
#0 - 0xean
2022-10-14T17:29:29Z
L-01 - Non-critical otherwise agree on severities.
š 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
18.8604 USDC - $18.86
G-N | Issue | Instances |
---|---|---|
[Gā01] | No need to initialize variables with default values | 8 |
[Gā02] | ++i costs less gas than i++ , especially when it's used in for -loops (--i /i-- too) | 1 |
[Gā03] | ++i /i++ should be unchecked{++i} /unchecked{i++} when it is not possible for them to overflow, as is the case when used in for - and while -loops | 5 |
[Gā04] | <array>.length should not be looked up in every loop of a for -loop | 2 |
[Gā05] | Don't compare boolean expressions to boolean literals | 3 |
[Gā06] | Unused minters_array variable | 1 |
[Gā07] | Overemited event parameter | 2 |
[Gā08] | Redundant require | 1 |
[Gā09] | Redundant onlyByOwnGov | 1 |
[Gā10] | Using calldata instead of memory for read-only arguments in external functions saves gas | 3 |
[Gā11] | public functions to external functions | 8 |
[Gā12] | Use directly validators.length | 1 |
Total: 36 instances over 12 issues
In solidity all variables initialize in 0, address(0), false, etc.
File: /src/frxETHMinter.sol 63 withholdRatio = 0; // No ETH is withheld initially 64 currentWithheldETH = 0; 94 uint256 withheld_amt = 0; 129 for (uint256 i = 0; i < numDeposits; ++i) {
File: /src/OperatorRegistry.sol 63 for (uint256 i = 0; i < arrayLength; ++i) { 84 for (uint256 i = 0; i < times; ++i) { 114 for (uint256 i = 0; i < original_validators.length; ++i) {
File: /src/ERC20/ERC20PermitPermissionedMint.sol 84 for (uint i = 0; i < minters_array.length; i++){
++i
costs less gas than i++
, especially when it's used in for
-loops (--i
/i--
too)Saves 5 gas per loop
File: /src/ERC20/ERC20PermitPermissionedMint.sol 84 for (uint i = 0; i < minters_array.length; i++){
++i
/i++
should be unchecked{++i}
/unchecked{i++}
when it is not possible for them to overflow, as is the case when used in for
- and while
-loopsThe unchecked
keyword is new in solidity version 0.8.0, so this only applies to that version or higher, which these instances are. This saves 30-40 gas per loop
File: /src/frxETHMinter.sol 129 for (uint256 i = 0; i < numDeposits; ++i) {
File: /src/OperatorRegistry.sol 63 for (uint256 i = 0; i < arrayLength; ++i) { 84 for (uint256 i = 0; i < times; ++i) { 114 for (uint256 i = 0; i < original_validators.length; ++i) {
File: /src/ERC20/ERC20PermitPermissionedMint.sol 84 for (uint i = 0; i < minters_array.length; i++){
<array>.length
should not be looked up in every loop of a for
-loopThe overheads outlined below are PER LOOP, excluding the first loop
MLOAD
(3 gas)CALLDATALOAD
(3 gas)Caching the length changes each of these to a DUP<N>
(3 gas), and gets rid of the extra DUP<N>
needed to store the stack offset
File: /src/OperatorRegistry.sol 114 for (uint256 i = 0; i < original_validators.length; ++i) {
File: /src/ERC20/ERC20PermitPermissionedMint.sol 84 for (uint i = 0; i < minters_array.length; i++){
require(<x> == true)
=> require(<x>)
require(<x> == false)
=> require(!<x>)
File: /src/ERC20/ERC20PermitPermissionedMint.sol 46 require(minters[msg.sender] == true, "Only minters"); 68 require(minters[minter_address] == false, "Address already exists"); 78 require(minters[minter_address] == true, "Address nonexistant");
minters_array
variableThe data storage in minters_array
it's never used also the minters
mapping storage the same data
Remove the minters_array
and his logic and use MinterAdded
event to get off-chain the minters array
File: /src/ERC20/ERC20PermitPermissionedMint.sol 19 address[] public minters_array; // Allowed to mint 70 minters_array.push(minter_address); 83 // 'Delete' from the array by setting the address to 0x0 84 for (uint i = 0; i < minters_array.length; i++){ 85 if (minters_array[i] == minter_address) { 86 minters_array[i] = address(0); // This will leave a null in the array and keep the indices the same 87 break; 88 } 89 }
In the Transfer
event of openzeppelin-contracts/contracts/token/ERC20/ERC20.sol contract, the from
, to
and amount
parameters are emited
No need to emit them in the TokenMinterBurned
and TokenMinterMinted
events
Combime this with N-06
File: /src/ERC20/ERC20PermitPermissionedMint.sol From: 102 event TokenMinterBurned(address indexed from, address indexed to, uint256 amount); To: 102 event TokenMinterBurned(address indexed to); From: 103 event TokenMinterMinted(address indexed from, address indexed to, uint256 amount); To: 103 event TokenMinterMinted(address indexed from);
require
The address(0)
can be able to mint for the require
in line 66
File: /src/ERC20/ERC20PermitPermissionedMint.sol 77 require(minter_address != address(0), "Zero address detected");
onlyByOwnGov
This alaready cheks in addValidator
functione in line 53
File: File: /src/OperatorRegistry.sol 61 function addValidators(Validator[] calldata validatorArray) external onlyByOwnGov {
calldata
instead of memory
for read-only arguments in external
functions saves gasWhen a function with a memory
array is called externally, the abi.decode()
step has to use a for-loop to copy each index of the calldata
to the memory
index. Each iteration of this for-loop costs at least 60 gas (i.e. 60 * <mem_array>.length
). Using calldata
directly, obliviates the need for such a loop in the contract code and runtime execution. Note that even if an interface defines a function as having memory
arguments, it's still valid for implementation contracs to use calldata
arguments instead.
If the array is passed to an internal
function which passes the array to another internal
function where the array is modified and therefore memory
is used in the external
call, it's still more gass-efficient to use calldata
when the external
function uses modifiers, since the modifiers may prevent the internal
functions from being called. Structs have the same overhead as an array of length one
File: /src/OperatorRegistry.sol 172 bytes memory pubKey, 173 bytes memory signature, 181 function setWithdrawalCredential(bytes memory _new_withdrawal_pubkey) external onlyByOwnGov {
public
functions to external
functionsFile: /src/ERC20/ERC20PermitPermissionedMint.sol 53 function minter_burn_from(address b_address, uint256 b_amount) public onlyMinters { 59 function minter_mint(address m_address, uint256 m_amount) public onlyMinters { 65 function addMinter(address minter_address) public onlyByOwnGov { 76 function removeMinter(address minter_address) public onlyByOwnGov { 94 function setTimelock(address _timelock_address) public onlyByOwnGov {
File: /src/OperatorRegistry.sol 82 function popValidators(uint256 times) public onlyByOwnGov { 93 function removeValidator(uint256 remove_idx, bool dont_care_about_ordering) public onlyByOwnGov {
File: /src/sfrxETH.sol 54 function pricePerShare() public view returns (uint256) {
validators.length
This enables mark as
external
thenumValidators
function
File: /src/OperatorRegistry.sol 136 uint numVals = numValidators(); 182 require(numValidators() == 0, "Clear validator array first");