Platform: Code4rena
Start Date: 03/05/2022
Pot Size: $30,000 USDC
Total HM: 6
Participants: 93
Period: 3 days
Judge: gzeon
Id: 118
League: ETH
Rank: 75/93
Findings: 1
Award: $35.02
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: defsec
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0x52, 0xDjango, 0xf15ers, 0xkatana, 0xliumin, AuditsAreUS, BowTiedWardens, CertoraInc, Cr4ckM3, Funen, GimelSec, Hawkeye, IllIllI, Kulk0, M0ndoHEHE, MaratCerby, Picodes, Ruhum, TerrierLover, TrungOre, VAD37, WatchPug, berndartmueller, broccolirob, catchup, cccz, cryptphi, csanuragjain, delfin454000, dirk_y, eccentricexit, ellahi, fatherOfBlocks, gzeon, hake, hansfriese, hickuphh3, horsefacts, hubble, hyh, ilan, joestakey, kebabsec, kenta, kenzo, leastwood, m9800, marximimus, minhquanym, oyc_109, p4st13r4, pauliax, pedroais, peritoflores, plotchy, rajatbeladiya, reassor, rfa, robee, rotcivegaf, samruna, shenwilly, shung, simon135, sorrynotsorry, sseefried, teddav, throttle, tintin, unforgiven, z3s
35.025 USDC - $35.02
This report contains links to a private fork of the contest repo. The GitHub user code423n4
has been added as a collaborator so that the judges can view the code.
dropPerStep
loses precisionThe following code in the currentDaPrice function loses precision because a divide is performed too early.
uint256 dropPerStep = (startPrice - lowestPrice) / (daPriceCurveLength / daDropInterval);
An equivalent expression which loses less precision is:
uint256 dropPerStep = (startPrice - lowestPrice) * daDropInterval / daPriceCurveLength
It just so happens that these two expressions are equal for the values which are specified in the contract:
startPrice = 2.5 ether
lowestPrice = 0.6 ether
daPriceCurveLength = 380 minutes
daDropInterval = 10 minutes
However, if you set daPriceCurveLength = 379 minutes
you immediately get two different values. The value using the existing expression is 51351351351351351
while the new, more precise, value is 50131926121372031
. This is a difference of approximately 2.4%, which is quite large.
This affects how much money that can be made from the NFT auction as the dropPerStep
is calculated to be higher than it otherwise would have been.
As long as daDropPerInterval
evenly divides into daPriceCurveLength
there is no problem with the original calculation.
Use the more precise calculation of dropPerStep
uint256 dropPerStep = (startPrice - lowestPrice) * daDropInterval / daPriceCurveLength
set*
functionsFIXME:sseefried
constructor(IForgottenRunesWarriorsGuild _warriors, address _weth) { setWarriorsAddress(_warriors); setWethAddress(_weth); setVaultAddress(msg.sender); }
At a minimum I would recommend
require(_warriors.MAX_WARRIORS >= maxForSale); require(maxForSale + maxForClaim <= _warriors.MAX_WARRIORS); require(startPrice == finalPrice); // should be equal at beginning require(daStartTime < mintlistStartTime); require(mintlistStartTime < publicStartTime); require(publicStartTime < claimsStartTime);
Add suitable error messages so the reason for a failure to a call to the constructor is clear.
I would also recommend similar checks in the set*
functions e.g. setStartPrice
, setLowestPrice
etc. as one might be under pressure when one has to use these functions.
For example something like this might be a good idea.
function setLowestPrice(uint256 _newPrice) public onlyOwner { require(_newPrice <= startPrice); lowestPrice = _newPrice; }
In general, it would be worth spending some time thinking about the invariants. i.e. those things that should always be true of the state of the contracts storage locations.
finalPrice
not set when self refunds startThe refundOwed
function is defined as:
function refundOwed(address minter) public view returns (uint256) { uint256 totalCostOfMints = finalPrice * daNumMinted[minter]; uint256 refundsPaidAlready = daAmountRefunded[minter]; return daAmountPaid[minter] - totalCostOfMints - refundsPaidAlready; }
It's possible that finalPrice
has not been set to a different value than its initial value. This is stated in the following code which is part of bidSummon
:
if (numSold == maxDaSupply) { // optimistic: save gas by not setting on every mint, but will // require manual `setFinalPrice` before refunds if da falls short finalPrice = currentPrice; }
It is also mentioned in the YouTube video.
In the case that the user minted an NFT for less than finalPrice
this will lead to totalCostOfMints
being larger than daAmountPaid[minter]
, which will lead to an integer underflow bug. Fortunately, this is caught by Solidity 0.8.0 and will just lead to a revert.
Although refundOwed
is just a view
function it is called by function _refundAddress
, which means that calls to function selfRefund
would revert. This means that users can't be refunded until the correct finalPrice
has been set.
This has been classified as a low risk issue because the fix is just to call function setFinalPrice
and set it to the correct value. However, until this is done the user is denied service.
This hardhat test shows that a revert does occur under the conditions specified above, and also that the owner calling setFinalPrice
fixes the problem.
Add a require
statement similar to the following in setSelfRefundsStartTime
function setSelfRefundsStartTime(uint256 _newTime) public onlyOwner { require(finalPrice < startPrice, "finalPrice hasn't been set yet"); selfRefundsStartTime = _newTime; }
and similarly for selfRefund
function selfRefund() public nonReentrant { require(selfRefundsStarted(), 'Self refund period not started'); require(finalPrice < startPrice, "finalPrice hasn't been set yet"); _refundAddress(msg.sender); }
This will also lead to a revert but at least the user knows why.
ERC20.transfer
are not checkedThe return values of ERC20 transfers are not checked at the following locations:
Not all ERC20 tokens have the same behaviour on transfer
. Some revert on failure but others just return false
. In this particular case the owner of the contract has control over the weth
contract address so this bug can only be assessed as being low risk.
The first occurrence of this issue is important since _safeTransferETHWithFallback
is indirectly called by selfRefund
and the other refunding functions. This could mean that users don't end up receiving their funds.
The last two occurrences are only used to to transfer out tokens that are accidentally sent to to the contracts so they are of less consequence.
See lines of code above
The mitigation is to rewrite all uses of transfer to check the return value. For example:
function _safeTransferETHWithFallback(address to, uint256 amount) internal { if (!_safeTransferETH(to, amount)) { IWETH(weth).deposit{value: amount}(); (bool success, _) = IERC20(weth).transfer(to, amount); require(success, "Transfer failed"); } }
startPrice
and finalPrice
match at beginningIt seems intended behaviour that startPrice
be equal to finalPrice
at contract initialisation. To make sure this is the case just set the initial value of finalPrice
equal to startPrice
uint256 public finalPrice = startPrice;
As it currently stands, it's error prone to use a repeated value of 2.5 ether
currentPrice
calculation could be simplifiedThe code is currently:
if (stepDeduction > startPrice) { return lowestPrice; } uint256 currentPrice = startPrice - stepDeduction; return currentPrice > lowestPrice ? currentPrice : lowestPrice;
However, this would allow currentPrice = startPrice - stepDeduction
to be very close to 0
which you don't really want.
You could replace the code with the following which is closer to your intent
if (stepDeduction > startPrice - lowestPrice) { return lowestPrice; } return startPrice - stepDeduction;
The code is not incorrect as it is. It could just be a bit clearer.
The following code appears in bidSummon:
require( numWarriors > 0 && numWarriors <= 20, 'You can summon no more than 20 Warriors at a time' );
Define MIN_WARRIORS
and MAX_WARRIORS
constants instead or perhaps even make them public constants that the owner
can set. e.g. just like setDaStartTime
and the other set*
functions.
_safeTransferETH
should check that to
address existsThe Solidity documentation states that:
Warning
The low-level functions
call
,delegatecall
andstaticcall
return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.
_safeTransferETH
is indirectly called from _refundAddress
which, in turn, is called from three functions: issueRefunds
, refundAddress
and selfRefund
.
The only possibly dangerous call is refundAddress
which has signature function refundAddress(address minter)
. However, if called with the wrong minter
address owed
would be calculated as 0
in _refundAddress
and no call to _safeTransferETH
.
For this reason this issue is classified as Non-critical.
Function _safeTransferETH
is safe only because it is called from functions that meet the precondition that the to
address exists. However, if the code is modified at a future date, this might not hold anymore. It is best to check for the existence of the to
address within function _safeTransferETH
.