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: 18/133
Findings: 3
Award: $189.89
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Lambda
Also found by: 0x1f8b, 0x5rings, 0xSky, 0xSmartContract, 8olidity, Chom, CodingNameKiki, IllIllI, Ruhum, Sm4rty, brgltd, hansfriese, m9800, magu, pashov, pedroais, peritoflores, prasantgupta52, rokinot, seyni
12.4859 USDC - $12.49
frxETHMinter
contract is incompatible with major tokens such as USDT, BNB or OMG. The recoverERC20
method could not work as expected, and some tokens can be lost.
The following problems are found:
transfer
is performed without checking the result, so it could return false and result in a loss of funds.safeX
methods in order to avoid the boolean result when it is not defined, for example in USDT.Some tokens do not return a bool (e.g. USDT, BNB, OMG) on ERC20 methods, (e.g. BNB) may return a bool for some methods, but fail to do so for others. This resulted in stuck BNB tokens in Uniswap v1 (details).
Reference:
NOTES: The following specifications use syntax from Solidity 0.4.17 (or above). Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!
This is the code of USDT:
contract ERC20Basic { uint public _totalSupply; function totalSupply() public constant returns (uint); function balanceOf(address who) public constant returns (uint); + function transfer(address to, uint value) public; event Transfer(address indexed from, address indexed to, uint value); } contract ERC20 is ERC20Basic { function allowance(address owner, address spender) public constant returns (uint); + function transferFrom(address from, address to, uint value) public; + function approve(address spender, uint value) public; event Approval(address indexed owner, address indexed spender, uint value); }
As you can see, it lacks the return defined in the ERC20 standard. So calling approve
, transfer
or transferFrom
methods directly will check for a return that will cause the transaction to fail.
Here you have a list with tokens (outdated) that don't return a boolean in transfer:
#0 - FortisFortuna
2022-09-25T21:30:28Z
Not really medium risk. Technically you could use safeTransfer, but if someone were to accidentally send something to this contract, it would most likely be either ETH, FRAX, frxETH, or sfrxETH, all of which are transfer compliant.
#1 - joestakey
2022-09-26T18:02:19Z
Duplicate of #18
🌟 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
164.5274 USDC - $164.53
frxETHMinter
contract is incompatible with major tokens such as USDT, that it's because when performing an 'approve', it is required to do an approve(0)
first, to avoid some front-running token protections.
Affected source code:
The pragma version used are:
pragma solidity ^0.8.0; pragma solidity >=0.8.0;
Note that mixing pragma is not recommended. Because different compiler versions have different meanings and behaviors, it also significantly raises maintenance costs. As a result, depending on the compiler version selected for any given file, deployed contracts may have security issues.
The minimum required version must be 0.8.16; otherwise, contracts will be affected by the following important bug fixes:
abi.encodeCall
in place of fixed bytes arguments.calldatasize()
in all cases.bytes
arrays.Apart from these, there are several minor bug fixes and improvements.
The following methods have a lack of checks if the received argument is an address, it's good practice in order to reduce human error to check that the address specified in the constructor or initialize is different than address(0)
.
Affected source code for address(0)
:
Minters
can burn any amount of tokens from an arbitrary addressmiters
role can burn any amount of tokens from an arbitrary address.
This is needless and poses a severe risk of centralization. A malicious or compromised owner can take advantage of this.
Affected source code:
Minters
can mint any amount of tokens to an arbitrary addressThe minters
can mint an arbitrary amount of tokens to an arbitrary address.
This is needless and poses a severe risk of centralization. A malicious or compromised owner can take advantage of this.
Affected source code:
The advantages provided by the timelock in terms of decentralization disappear by having an owner who can manage the contract in the same way, so the contract is centralized to the actions of the owner
Affected source code:
minters_array
When an entry from the ERC20PermitPermissionedMint.minters_array
array is deleted, the last element of the array is not placed in the deleted position and the size of the array is reduced, so there is a gap that is not necessary and can cause problems in dApps.
Affected source code:
🌟 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
12.8681 USDC - $12.87
It's cheaper to store the length of the array inside a local variable and iterate over it.
Affected source code:
If a variable is not set/initialized, the default value is assumed (0, false
, 0x0 ... depending on the data type). You are simply wasting gas if you directly initialize it with its default value.
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testInit() public view returns (uint) { uint a = 0; return a; } } contract TesterB { function testNoInit() public view returns (uint) { uint a; return a; } }
Gas saving executing: 8 per entry
TesterA.testInit: 21392 TesterB.testNoInit: 21384
Affected source code:
Compare a boolean value using == true
or == false
instead of using the boolean value is more expensive.
NOT
opcode it's cheaper than using EQUAL
or NOTEQUAL
when the value it's false, or just the value without == true
when it's true, because it will use less opcodes inside the VM.
Proof of concept (without optimizations):
pragma solidity 0.8.16; contract TesterA { function testEqual(bool a) public view returns (bool) { return a == true; } } contract TesterB { function testNot(bool a) public view returns (bool) { return a; } }
Gas saving executing: 18 per entry for == true
TesterA.testEqual: 21814 TesterB.testNot: 21796
pragma solidity 0.8.16; contract TesterA { function testEqual(bool a) public view returns (bool) { return a == false; } } contract TesterB { function testNot(bool a) public view returns (bool) { return !a; } }
Gas saving executing: 15 per entry for == false
TesterA.testEqual: 21814 TesterB.testNot: 21799
Affected source code:
Use the value instead of == true
:
Use the value instead of == false
:
Using compound assignment operators for state variables (like State += X
or State -= X
...) it's more expensive than using operator assignment (like State = State + X
or State = State - X
...).
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { uint private _a; function testShort() public { _a += 1; } } contract TesterB { uint private _a; function testLong() public { _a = _a + 1; } }
Gas saving executing: 13 per entry
TesterA.testShort: 43507 TesterB.testLong: 43494
Affected source code:
calldata
instead of memory
Some methods are declared as external
but the arguments are defined as memory
instead of as calldata
.
By marking the function as external
it is possible to use calldata
in the arguments shown below and save significant gas.
Recommended change:
- function setWithdrawalCredential(bytes memory _new_withdrawal_pubkey) external onlyByOwnGov { + function setWithdrawalCredential(bytes calldata _new_withdrawal_pubkey) external onlyByOwnGov { require(numValidators() == 0, "Clear validator array first"); curr_withdrawal_pubkey = _new_withdrawal_pubkey; emit WithdrawalCredentialSet(_new_withdrawal_pubkey); }
Affected source code:
++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integers, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1; i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1; ++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
I suggest using ++i
instead of i++
to increment the value of an uint variable. Same thing for --i
and i--
Keep in mind that this change can only be made when we are not interested in the value returned by the operation, since the result is different, you only have to apply it when you only want to increase a counter.
Affected source code:
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testShortRevert(bool path) public view { require(path, "test error"); } } contract TesterB { function testLongRevert(bool path) public view { require(path, "test big error message, more than 32 bytes"); } }
Gas saving executing: 18 per entry
TesterA.testShortRevert: 21886 TesterB.testLongRevert: 21904
Affected source code:
Custom errors from Solidity 0.8.4
are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source Custom Errors in Solidity:
Starting from Solidity v0.8.4
, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testRevert(bool path) public view { require(path, "test error"); } } contract TesterB { error MyError(string msg); function testError(bool path) public view { if(path) revert MyError("test error"); } }
Gas saving executing: 9 per entry
TesterA.testRevert: 21611 TesterB.testError: 21602
Affected source code:
xERC4626.totalAssets
It is possible to reduce the definition of the variables in this way:
function totalAssets() public view override returns (uint256) { - // cache global vars - uint256 storedTotalAssets_ = storedTotalAssets; - uint192 lastRewardAmount_ = lastRewardAmount; uint32 rewardsCycleEnd_ = rewardsCycleEnd; - uint32 lastSync_ = lastSync; if (block.timestamp >= rewardsCycleEnd_) { // no rewards or rewards fully unlocked // entire reward amount is available - return storedTotalAssets_ + lastRewardAmount_; + return storedTotalAssets + lastRewardAmount; } + uint32 lastSync_ = lastSync; // rewards not fully unlocked // add unlocked rewards to stored total - uint256 unlockedRewards = (lastRewardAmount_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); - return storedTotalAssets_ + unlockedRewards; + uint256 unlockedRewards = (lastRewardAmount * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); + return storedTotalAssets + unlockedRewards; }
bool
to uint256
can save gasBecause each write operation requires an additional SLOAD
to read the slot's contents, replace the bits occupied by the boolean, and then write back, booleans
are more expensive than uint256
or any other type that uses a complete word. This cannot be turned off because it is the compiler's defense against pointer aliasing and contract upgrades.
Reference:
Also, this is applicable to integer types different from uint256
or int256
.
Affected source code for booleans
:
The approveMax
argument is not necessary since the maximum amount can already be set in the assets
or shares
argument by the caller.
Affected source code:
Remove bool approveMax
: