Platform: Code4rena
Start Date: 11/08/2022
Pot Size: $40,000 USDC
Total HM: 8
Participants: 108
Period: 4 days
Judge: hickuphh3
Total Solo HM: 2
Id: 152
League: ETH
Rank: 13/108
Findings: 3
Award: $196.91
๐ Selected for report: 0
๐ Solo Findings: 0
๐ Selected for report: itsmeSTYJ
Also found by: 0x1f8b, 0x52, 0xDjango, Ch_301, Chom, KIntern_NA, PwnedNoMore, Treasure-Seeker, auditor0517, byndooa, cccz, csanuragjain, ladboy233, nine9, shenwilly, thank_you, yixxas, zkhorse
42.8343 USDC - $42.83
The NFTDropMarketFixedPriceSale
contract contains a very easy to bypass constraint.
The mintFromFixedPriceSale method checks that the balance after the user's mint is not greater than that configured in saleConfig.limitPerAccount
.
// Confirm that the buyer will not exceed the limit specified after minting. if (IERC721(nftContract).balanceOf(msg.sender) + count > saleConfig.limitPerAccount) { revert ...; }
But this is easy to avoid if prior to the call of mintFromFixedPriceSale
the balance is sent to another account, since control of how much said account carries is out of our control, since the counter is controlled by the sender, not by the contract.
#0 - HardlyDifficult
2022-08-17T20:58:12Z
๐ Selected for report: Saw-mon_and_Natalie
Also found by: 0x1f8b, 0x52, 0xDjango, 0xNazgul, 0xSmartContract, 0xSolus, 0xackermann, 0xmatt, 0xsolstars, Aymen0909, Bnke0x0, Chom, Deivitto, DevABDee, Dravee, ElKu, IllIllI, JC, Kumpa, Lambda, LeoS, MiloTruck, PwnedNoMore, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, TomJ, Treasure-Seeker, Vexjon, Waze, Yiko, __141345__, apostle0x01, auditor0517, berndartmueller, bin2chen, bobirichman, brgltd, bulej93, c3phas, carlitox477, cccz, cryptphi, csanuragjain, d3e4, danb, delfin454000, durianSausage, erictee, fatherOfBlocks, gogo, iamwhitelights, joestakey, jonatascm, ladboy233, mics, oyc_109, rbserver, ret2basic, robee, rokinot, rvierdiiev, shenwilly, sikorico, simon135, thank_you, wagmi, yash90, zeesaw, zkhorse
71.8108 USDC - $71.81
The pragma version used is:
pragma solidity ^0.8.12;
The minimum required version must be 0.8.15; 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.
Some contract does not implement a good upgradeable logic.
Proxied contracts MUST include an empty reserved space in storage, usually named __gap
, in all the inherited contracts.
For example, if it is an interface, you have to use the interface
keyword, otherwise it is a contract that requires your GAP to be correctly updated, so if a new variable is created it could lead in storage collisions that will produce a contract dissaster, including loss of funds.
Reference:
MarketSharedCore
has a gap because it will be upgradeable but the base class FETHNode
don't.
Affected source code:
The same problem occurred with AdminRole
and MinterRole
, both should have a gap, but only AdminRole
has.
Affected source code:
Other upgradable contracts without a gap have been found:
The inheritance of Gap10000
is a bad design to have gaps, the gaps have to be defined in the contract itself, otherwise if a variable is added, it has to be modified in the base contract, affecting all the contracts that inherit it.
Affected source code:
The same behavior is observed with NFTDropMarketCore
:
Using the burn
method, the admin can burn an arbitrary amount of tokens from any address.
I believe this is unnecessary and poses a serious centralization risk. A malicious or compromised admin can take advantage of this.
Consider limiting the burn function to the token owner.
Affected source code:
tokenURI
could return a wrong uriThe baseURI
state variable allows it to be hidden by the admin and revealed via the reveal
method.
If the baseURI
has not been revealed, the tokenURI
method will return an incorrect URL.
The following modifications are recommended:
function tokenURI(uint256 tokenId) public view override returns (string memory uri) { + require(bytes(_baseURI).length != 0, "NFTDropCollection: require reveal"); _requireMinted(tokenId); return string.concat(baseURI, tokenId.toString(), ".json"); }
Affected source code:
The design of the replaceAtIf
method forces the transaction to be reversed if the content to be replaced is not found. It would be more useful and versatile if a boolean was returned indicating whether the replacement was successful or not.
Affected source code:
BytesLibrary
Although it is not vulnerable, it is not convenient to make such large unchecked blocks, on this occasion the only thing that prevents the overflow in startLocation + i
is that it will not be possible to send a data
long enough to exploit it.
The following modifications are recommended:
function replaceAtIf( bytes memory data, uint256 startLocation, address expectedAddress, address newAddress ) internal pure { bytes memory expectedData = abi.encodePacked(expectedAddress); bytes memory newData = abi.encodePacked(newAddress); - unchecked { // An address is 20 bytes long - for (uint256 i = 0; i < 20; ++i) { + for (uint256 i; i < 20;) { uint256 dataLocation = startLocation + i; if (data[dataLocation] != expectedData[i]) { revert BytesLibrary_Expected_Address_Not_Found(); } data[dataLocation] = newData[i]; + unchecked { ++i; } } - } }
Affected source code:
The code that contains "open todos" reflects that the development is not finished and that the code can change a posteriori, prior release, with or without audit.
Affected source code:
#0 - HardlyDifficult
2022-08-18T21:12:43Z
Outdated compiler
None of those bugs seem to apply to our code base. We intentionally use a floating pragma in order to make integrating with contracts easier. Other contract developers are looking to interact with our contracts and they may be on a different version than we use. The pragma selected for our contracts is the minimum required in order to correctly compile and function. This way integration is easier if they lag a few versions behind, or if they use the latest but we don't bump our packages frequently enough, and when we do upgrade versions unless there was a breaking solidity change -- it should just swap in by incrementing our npm package version.
Upgradable contracts without GAP can lead a upgrade disaster
Some of our stateless contracts do not include gaps because we intend on keeping them stateless.
Collections are not upgradeable so gaps are not required.
Wrong gap desing
This is just an approach to leave a large amount of space for unknown mixins to be added in the middle of our inheritance tree in the future.
- Admin can burn tokens
Invalid. NFTs can only be burned if it is owned by the admin
tokenURI could return a wrong uri
Invalid. We want tokenURI to return the pre-reveal image provided in this scenario. baseURI is always required.
- Improvable library design
That's fair feedback, but for the use case we have for that function -- reverting is desired.
Integer overflow in BytesLibrary
Good feedback, agree and will fix.
Unresolved TODO comments
Agree, will fix.
๐ Selected for report: Dravee
Also found by: 0x040, 0x1f8b, 0xDjango, 0xHarry, 0xNazgul, 0xSmartContract, 0xbepresent, 0xkatana, Amithuddar, Aymen0909, Bnke0x0, Chom, CodingNameKiki, Deivitto, DevABDee, Diraco, ElKu, Fitraldys, Funen, IllIllI, JC, LeoS, Metatron, MiloTruck, Noah3o6, ReyAdmirado, Rohan16, Rolezn, Saw-mon_and_Natalie, Sm4rty, SpaceCake, TomJ, Tomio, Trabajo_de_mates, Waze, Yiko, __141345__, ajtra, apostle0x01, bobirichman, brgltd, bulej93, c3phas, cRat1st0s, carlitox477, d3e4, durianSausage, erictee, fatherOfBlocks, gerdusx, gogo, hakerbaya, ignacio, jag, joestakey, ladboy233, medikko, mics, newfork01, oyc_109, pfapostol, robee, rvierdiiev, sach1r0, saian, samruna, sikorico, simon135, wagmi, zeesaw, zkhorse, zuhaibmohd
82.268 USDC - $82.27
Importing pointless files costs gas during deployment and is a bad coding practice that is important to ignore.
Remove the following imports:
ICollectionFactory.sol
from ContractFactory.sol#L7constants
expressions are expressions, not constants
Due to how constant variables are implemented (replacements at compile-time), an expression assigned to a constant variable is recomputed each time that the variable is used, which wastes some gas.
If the variable was immutable instead: the calculation would only be done once at deploy time (in the constructor), and then the result would be saved and read directly at runtime rather than being recalculated.
Reference:
Consequences: each usage of a "constant" costs ~100gas more on each access (it is still a little better than storing the result in storage, but not much..). since these are not real constants, they can't be referenced from a real constant environment (e.g. from assembly, or from another library )
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:
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:
unckecked
regionIt's possible to save gas using the unckecked
keyword around a math operation. This will avoid the required checks to ensure that the variable won't overflow.
Reference:
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testUnChecked(uint a, uint b) public returns (uint) { unchecked { return a - b; } } } contract TesterB { function testChecked(uint a, uint b) public returns (uint) { return a - b; } }
Gas saving executing: 182 per entry
TesterA.testUnChecked: 22103 TesterB.testChecked: 22285
Affected source code:
index
The following values are considered unnecessary to index and may incur unnecessary overhead.
Affected source code:
maxTokenId
in SequentialMintCollection.sol#L49admin
in SequentialMintCollection.sol#L55implementation
and version
in NFTCollectionFactory.sol#L111version
in NFTCollectionFactory.sol#L120 and NFTCollectionFactory.sol#L136The NFTDropMarketCore
contract does not provide any logic, only a gap in the style of the Gap10000
contract, these gaps are badly designed since if the class that inherits them modifies the number of slots used, modifying the base class will affect all the contracts that inherit them.
It's recommended to delete both base contracts:
Affected source code:
NFTDropMarketFixedPriceSale
The _getSellerOf method calls the getFixedPriceSale method and runs totally unnecessary logic like checking if you have the right to mint (marketCanMint
).
The following modifications are recommended:
function _getSellerOf( address nftContract, uint256 /* tokenId */ ) internal view virtual override returns (address payable seller) { - (seller, , , , ) = getFixedPriceSale(nftContract); + try INFTDropCollectionMint(nftContract).numberOfTokensAvailableToMint() returns (uint256 count) { + if (count != 0) { + seller = nftContractToFixedPriceSaleConfig[nftContract].seller; + } + } catch // solhint-disable-next-line no-empty-blocks + { + // Contract not supported - return default values + } }
Affected source code:
calldata
instead of memory
The method createNFTDropCollectionWithPaymentFactory is external
, but the argument paymentAddressFactoryCall
was defined as memory
instead of as calldata
.
The following modifications are recommended:
function createNFTDropCollectionWithPaymentFactory( string calldata name, string calldata symbol, string calldata baseURI, bytes32 postRevealBaseURIHash, uint32 maxTokenId, address approvedMinter, uint256 nonce, - CallWithoutValue memory paymentAddressFactoryCall + CallWithoutValue calldata paymentAddressFactoryCall ) external returns (address collection) {
Affected source code:
abi.encode
instead of abi.encodePacked
abi.encodePacked
has a cost difference with respect to abi.encode
depending on the types it uses, in the case of the reference shown below you can see that the type address
is affected, so I have decided to do a test on it.
Also there is a discussion about removing abi.encodePacked
from future versions of Solidity (ethereum/solidity#11593), so using abi.encode
now will ensure compatibility in the future.
Reference:
Proof of concept (without optimizations):
pragma solidity ^0.8.12; contract TesterA { function getSaltEncode(address creator, uint256 nonce) public pure returns (bytes32) { return keccak256(abi.encode(creator, nonce)); } } contract TesterB { function getSaltEncodePacked(address creator, uint256 nonce) public pure returns (bytes32) { return keccak256(abi.encodePacked(creator, nonce)); } }
Gas saving: 188
TesterA.getSaltEncode: 22774 TesterB.getSaltEncodePacked: 22962
It's recommended to apply the following changes:
function _getSalt(address creator, uint256 nonce) private pure returns (bytes32) { - return keccak256(abi.encodePacked(creator, nonce)); + return keccak256(abi.encode(creator, nonce)); }
Affected source code:
> 0
is less efficient than != 0
for unsigned integersAlthough it would appear that > 0
is less expensive than != 0,
this is only true in the absence of the optimizer and outside of a need statement. Gas will be saved if the optimizer is enabled at 10k and you're in a require statement.
Reference:
Affected source code:
It's cheaper to store the length of the array inside a local variable and iterate over it.
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 integer, 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--
Affected source code:
#0 - HardlyDifficult
2022-08-17T14:31:34Z
Good report! Well formatted, easy to read & has useful supporting remarks.
- Unused import
Agree, fixed. This probably should have been in your QA report.
- constants expressions are expressions, not constants
While it seems this should help, changing to immutable shows a regression in our test suite. Gas costs go up by ~50 gas.
- Use short error messages
Agree but won't fix. We use up to 64 bytes, aiming to respect the incremental cost but 32 bytes is a bit too short to provide descriptive error messages for our users.
- Custom errors
Agree but won't fix at this time. We use these in the market but not in collections. Unfortunately custom errors are still not as good of an experience for users (e.g. on etherscan). We used them in the market originally because we were nearing the max contract size limit and this was a good way to reduce the bytecode. We'll consider this in the future as tooling continues to improve.
- Don't initialize variables with default values.
Invalid. This optimization technique is no longer applicable with the current version of Solidity.
- Unchecked
2 of the 3 links are already unchecked - so those are invalid. getFeesAndRecipients
is a read only function not intended to be used on-chain, but as a best practice we will add unchecked there as well.
- Remove event index
Good consideration. We'll remove indexed for MaxTokenIdUpdated
but keep the others, I could envision use cases for those.
- Remove unnecessary inheritance
Disagree. Having free slots helps to maintain compatibility when adding new functionality to an upgradeable contract. Also this is QA not gas feedback.
- Reduce logic in NFTDropMarketFixedPriceSale
Valid but won't fix for now. This getter is just for frontend app consumption ATM so gas is not important and acknowledging scenarios like right to mint may be a nice to have there. We may rework this in the future.
- calldata
Valid & will fix. This saves ~60 gas on createNFTDropCollectionWithPaymentFactory
- encode
Invalid... while it seems like this should help, changing to .encode
makes creating collections slightly more expensive. createNFTCollection
increased by 9 gas and createNFTDropCollection
increased by 8-10 gas
- Use != 0 instead of > 0
Invalid. We tested the recommendation and got the following results:
createNFTDropCollection gas reporter results: using > 0 (current): - 319246 ยท 319578 ยท 319361 using != 0 (recommendation): - 319252 ยท 319584 ยท 319367 impact: +6 gas
- Cache Array Length Outside of Loop
May be theoretically valid, but won't fix. I tested this: gas-reporter and our gas-stories suite is reporting a small regression using this technique. It also hurts readability a bit so we wouldn't want to include it unless it was a clear win.
- ++i costs less than i++
Agree and will fix.