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: 49/108
Findings: 2
Award: $62.14
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
41.2005 USDC - $41.20
Each event should use three indexed fields if there are three or more fields.
contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol 79-84: event CreateFixedPriceSale( address indexed nftContract, address indexed seller, uint256 price, uint256 limitPerAccount ); contracts/mixins/shared/MarketFees.sol 71-77: event BuyReferralPaid( address indexed nftContract, uint256 indexed tokenId, address buyReferrer, uint256 buyReferrerFee, uint256 buyReferrerSellerFee );
Some contracts inherit from the base contracts and child contracts at the same time. For example
contract NFTDropMarket is Initializable, FoundationTreasuryNode, FETHNode, MarketSharedCore, NFTDropMarketCore, ReentrancyGuardUpgradeable, SendValueWithFallbackWithdraw, MarketFees, Gap10000, NFTDropMarketFixedPriceSale {} abstract contract NFTDropMarketFixedPriceSale is MarketFees {}
It can be seen that NFTDropMarket
inherits from MarketFees
and NFTDropMarketFixedPriceSale
, however, NFTDropMarketFixedPriceSale
also inherits from MarketFees
, which makes the inheritance of NFTDropMarket
from MarketFees
redundant.
The same applies to many base and inherited sub-base contracts here, MarketSharedCore
and FETHNode
is another instance. And the NFTDropCollection
, NFTCollection
contracts and so on..., all have this issue.
The duplicate inheritance is prone to make confusion and lower the readability for future maintenance.
Another issue is the order of the multiple inheritance introduces ambiguity called Diamond Problem: if two or more base contracts define the same function, which one should be called in the child contract? Function shadowing could cause unexpected behavior. Refer to SWC-125.
Suggestion:
#0 - HardlyDifficult
2022-08-18T19:45:45Z
BuyReferralPaid event should index buyReferrer
Agree but won't fix at this time. We have already started emitting this event on mainnet, to simplify integrations such as subgraph we are going to continue with this event as is. However it's a good catch and I agree that buyReferrer ideally would have been indexed here.
For CreateFixedPriceSale
I believe this is invalid. index should be reserved for params that are likely to be requested as a filter. In these examples those params are data not really filter candidates. And for the string specifically, indexed would prevent the full information from being communicated, requiring a second unindexed version which is a bit redundant and increases gas costs.
Redundant inheritance
This is intentional, I like to expand all inheritance in the top-level contracts. This makes it clear what all the dependencies are and the linearization order in which they will be included.
🌟 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
20.9449 USDC - $20.94
The for loop length can be cached to memory before the loop, even for memory variables. The demo of the loop gas comparison can be seen here.
contracts/mixins/shared/MarketFees.sol 126: for (uint256 i = 0; i < creatorRecipients.length; ++i) { 198: for (uint256 i = 0; i < creatorShares.length; ++i) { 484: for (uint256 i = 0; i < creatorRecipients.length; ++i) { 503: for (uint256 i = 1; i < creatorRecipients.length; ) {
Suggestion: Cache the length before the loop.
uint length = names.length;
unchecked{++i}
COSTS LESS GAS COMPARED TO i++
OR i += 1
Use ++i
instead of i++
to increment the value of an uint variable, and for guaranteed non-overflow/underflow, it can be unchecked.
And the optimizer need to be turned on.
The demo of the loop gas comparison can be seen here.
contracts/mixins/shared/MarketFees.sol 198: for (uint256 i = 0; i < creatorShares.length; ++i) {
If a variable is not set/initialized, it is assumed to have the default value (0 for uint, false for bool, address(0) for address…). Explicitly initializing it with its default value is an anti-pattern and wastes gas.
contracts/libraries/BytesLibrary.sol 25: for (uint256 i = 0; i < 20; ++i) { 44: for (uint256 i = 0; i < 4; ++i) { contracts/mixins/shared/MarketFees.sol 126: for (uint256 i = 0; i < creatorRecipients.length; ++i) { 198: for (uint256 i = 0; i < creatorShares.length; ++i) { 484: for (uint256 i = 0; i < creatorRecipients.length; ++i) { 503: for (uint256 i = 1; i < creatorRecipients.length; ) {
The demo of the loop gas comparison can be seen here.
X = X + Y / X = X - Y
IS CHEAPER THAN X += Y / X -= Y
The demo of the gas comparison can be seen here.
Consider use X = X + Y / X = X - Y
to save gas
contracts/mixins/shared/MarketFees.sol 134: creatorRev += creatorShares[i]; 150: totalFees += buyReferrerFee; 199: creatorRev += creatorShares[i]; 490: totalShares += creatorShares[i]; 505: totalRoyaltiesDistributed += royalty; 527: totalFees -= buyReferrerFee;
keccak256()
,` SHOULD USE IMMUTABLE RATHER THAN CONSTANTreference: https://github.com/ethereum/solidity/issues/9232
Constant expressions are left as expressions, not constants.
a constant declared as:
contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol 70: bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); contracts/mixins/roles/MinterRole.sol 19: bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); contracts/mixins/treasury/OperatorRole.sol
It is expected that the value should be converted into a constant value at compile time. But the expression is re-calculated each time the constant is referenced.
consequences:
Suggestion: Change these expressions from constant to immutable and implement the calculation in the constructor or hardcode these values in the constants and add a comment to say how the value was calculated.
In DEFAULT_ADMIN_ROLE
and ROYALTY_IN_BASIS_POINTS
can be placed next to each other, as a result, 1 slot storage can be saved. According to the currently layout, they both occupy 1 slot, but after re-arrangement, they can be packed into 1 slot.
contracts/mixins/shared/Constants.sol
Reference: Layout of State Variables in Storage.
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.
contracts/NFTCollection.sol 263-264: require(bytes(tokenCID).length != 0, "NFTCollection: tokenCID is required"); require(!cidToMinted[tokenCID], "NFTCollection: NFT was already minted"); 268: require(maxTokenId == 0 || tokenId <= maxTokenId, "NFTCollection: Max token count has already been minted"); 327: require(_exists(tokenId), "NFTCollection: URI query for nonexistent token"); contracts/NFTCollectionFactory.sol 173: require(rolesContract.isAdmin(msg.sender), "NFTCollectionFactory: Caller does not have the Admin role"); 182: require(_rolesContract.isContract(), "NFTCollectionFactory: RolesContract is not a contract"); 203: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); 227: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); 262: require(bytes(symbol).length != 0, "NFTCollectionFactory: Symbol is required"); contracts/NFTDropCollection.sol 88: require(bytes(_baseURI).length > 0, "NFTDropCollection: `_baseURI` must be set"); 93: require(postRevealBaseURIHash != bytes32(0), "NFTDropCollection: Already revealed"); 130-131: require(bytes(_symbol).length > 0, "NFTDropCollection: `_symbol` must be set"); require(_maxTokenId > 0, "NFTDropCollection: `_maxTokenId` must be set"); 172: require(count != 0, "NFTDropCollection: `count` must be greater than 0"); 179: require(latestTokenId <= maxTokenId, "NFTDropCollection: Exceeds max tokenId"); 238: require(_postRevealBaseURIHash != bytes32(0), "NFTDropCollection: use `reveal` instead"); contracts/libraries/AddressLibrary.sol 31: require(contractAddress.isContract(), "InternalProxyCall: did not return a contract"); contracts/mixins/collections/SequentialMintCollection.sol 58: require(msg.sender == owner, "SequentialMintCollection: Caller is not creator"); 63: require(_creator != address(0), "SequentialMintCollection: Creator cannot be the zero address"); 74: require(totalSupply() == 0, "SequentialMintCollection: Any NFTs minted must be burned first"); 87-89: require(_maxTokenId != 0, "SequentialMintCollection: Max token ID may not be cleared"); require(maxTokenId == 0 || _maxTokenId < maxTokenId, "SequentialMintCollection: Max token ID may not increase"); require(latestTokenId <= _maxTokenId, "SequentialMintCollection: Max token ID must be >= last mint"); contracts/mixins/roles/AdminRole.sol 19: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/roles/MinterRole.sol 22: require(isMinter(msg.sender) || isAdmin(msg.sender), "MinterRole: Must have the minter or admin role"); contracts/mixins/shared/ContractFactory.sol 22: require(msg.sender == contractFactory, "ContractFactory: Caller is not the factory"); 31: require(_contractFactory.isContract(), "ContractFactory: Factory is not a contract"); contracts/mixins/treasury/AdminRole.sol 20: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/treasury/OZAccessControlUpgradeable.sol 137: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to grant"); 152: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to revoke");
Many of these require()
statements are in a modifier which might be frequently called, optimizing the gas can be beneficial.
Suggestion: Shortening the revert strings to fit in 32 bytes, or using custom errors as described next.
Custom errors from Solidity 0.8.4 are more gas efficient than revert strings (lower deployment cost and runtime cost when the revert condition is met) reference
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).
The demo of the gas comparison can be seen here.
Many of these require()
statements are in a modifier which might be frequently called, optimizing the gas can be beneficial.
Just like in contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol, the following files have multiple require()
statements can use custom errors:
contracts/NFTCollection.sol 263-264: require(bytes(tokenCID).length != 0, "NFTCollection: tokenCID is required"); require(!cidToMinted[tokenCID], "NFTCollection: NFT was already minted"); 268: require(maxTokenId == 0 || tokenId <= maxTokenId, "NFTCollection: Max token count has already been minted"); 327: require(_exists(tokenId), "NFTCollection: URI query for nonexistent token"); contracts/NFTCollectionFactory.sol 173: require(rolesContract.isAdmin(msg.sender), "NFTCollectionFactory: Caller does not have the Admin role"); 182: require(_rolesContract.isContract(), "NFTCollectionFactory: RolesContract is not a contract"); 203: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); 227: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); 262: require(bytes(symbol).length != 0, "NFTCollectionFactory: Symbol is required"); contracts/NFTDropCollection.sol 88: require(bytes(_baseURI).length > 0, "NFTDropCollection: `_baseURI` must be set"); 93: require(postRevealBaseURIHash != bytes32(0), "NFTDropCollection: Already revealed"); 130-131: require(bytes(_symbol).length > 0, "NFTDropCollection: `_symbol` must be set"); require(_maxTokenId > 0, "NFTDropCollection: `_maxTokenId` must be set"); 172: require(count != 0, "NFTDropCollection: `count` must be greater than 0"); 179: require(latestTokenId <= maxTokenId, "NFTDropCollection: Exceeds max tokenId"); 238: require(_postRevealBaseURIHash != bytes32(0), "NFTDropCollection: use `reveal` instead"); contracts/libraries/AddressLibrary.sol 31: require(contractAddress.isContract(), "InternalProxyCall: did not return a contract"); contracts/mixins/collections/SequentialMintCollection.sol 58: require(msg.sender == owner, "SequentialMintCollection: Caller is not creator"); 63: require(_creator != address(0), "SequentialMintCollection: Creator cannot be the zero address"); 74: require(totalSupply() == 0, "SequentialMintCollection: Any NFTs minted must be burned first"); 87-89: require(_maxTokenId != 0, "SequentialMintCollection: Max token ID may not be cleared"); require(maxTokenId == 0 || _maxTokenId < maxTokenId, "SequentialMintCollection: Max token ID may not increase"); require(latestTokenId <= _maxTokenId, "SequentialMintCollection: Max token ID must be >= last mint"); contracts/mixins/roles/AdminRole.sol 19: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/roles/MinterRole.sol 22: require(isMinter(msg.sender) || isAdmin(msg.sender), "MinterRole: Must have the minter or admin role"); contracts/mixins/shared/ContractFactory.sol 22: require(msg.sender == contractFactory, "ContractFactory: Caller is not the factory"); 31: require(_contractFactory.isContract(), "ContractFactory: Factory is not a contract"); contracts/mixins/treasury/AdminRole.sol 20: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/treasury/OZAccessControlUpgradeable.sol 137: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to grant"); 152: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to revoke");
REQUIRE()
CHECKS COULD BE REFACTORED TO A MODIFIER OR FUNCTIONThe following require()
checks are the same, they can be made into a common modifier, and providing an error message as parameter.
The followings all check hasRole()
:
contracts/mixins/roles/AdminRole.sol 19: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/treasury/AdminRole.sol 20: require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "AdminRole: caller does not have the Admin role"); contracts/mixins/treasury/OZAccessControlUpgradeable.sol 137: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to grant"); 152: require(hasRole(_roles[role].adminRole, _msgSender()), "AccessControl: sender must be an admin to revoke");
The followings all check for isContract()
:
contracts/NFTCollectionFactory.sol 182: require(_rolesContract.isContract(), "NFTCollectionFactory: RolesContract is not a contract"); 203: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); 227: require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); contracts/libraries/AddressLibrary.sol 31: require(contractAddress.isContract(), "InternalProxyCall: did not return a contract"); contracts/mixins/shared/ContractFactory.sol 31: require(_contractFactory.isContract(), "ContractFactory: Factory is not a contract");
If a function modifier such as onlyOwner()
is used, the function will revert if a normal user tries to pay the function. Marking the function as payable will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided.
The extra opcodes avoided are
CALLVALUE(2), DUP1(3), ISZERO(3), PUSH2(3), JUMPI(10), PUSH1(3), DUP1(3), REVERT(0), JUMPDEST(1), POP(2)
which costs an average of about 21 gas per call to the function, in addition to the extra deployment cost.
contracts/NFTCollectionFactory.sol 202: function adminUpdateNFTCollectionImplementation(address _implementation) external onlyAdmin { 226: function adminUpdateNFTDropCollectionImplementation(address _implementation) external onlyAdmin { contracts/NFTDropCollection.sol 159: function burn(uint256 tokenId) public override onlyAdmin { 195: function reveal(string calldata _baseURI) external onlyAdmin validBaseURI(_baseURI) onlyWhileUnrevealed { 209: function selfDestruct() external onlyAdmin { 220: function updateMaxTokenId(uint32 _maxTokenId) external onlyAdmin { 232: function updatePreRevealContent(string calldata _baseURI, bytes32 _postRevealBaseURIHash) external validBaseURI(_baseURI) onlyWhileUnrevealed onlyAdmin contracts/mixins/treasury/CollateralManagement.sol 36: function withdrawFunds(address payable to, uint256 amount) external onlyAdmin { contracts/NFTCollection.sol 230: function selfDestruct() external onlyCreator { 238: function updateBaseURI(string calldata baseURIOverride) external onlyCreator { 251: function updateMaxTokenId(uint32 _maxTokenId) external onlyCreator { 262: function _mint(string calldata tokenCID) private onlyCreator returns (uint256 tokenId) { contracts/NFTDropCollection.sol 171: function mintCountTo(uint16 count, address to) external onlyMinterOrAdmin returns (uint256 firstTokenId) { contracts/NFTCollection.sol 105-109: function initialize() external initializer onlyContractFactory { contracts/NFTDropCollection.sol 120-129: function initialize() external initializer onlyContractFactory
onlyFoundationAdmin()
and onlyFoundationOperator()
are never used across the contacts and can consider remove them.
contracts/mixins/shared/FoundationTreasuryNode.sol modifier onlyFoundationAdmin() { if (!IAdminRole(treasury).isAdmin(msg.sender)) { revert FoundationTreasuryNode_Caller_Not_Admin(); } _; } modifier onlyFoundationOperator() { if (!IOperatorRole(treasury).isOperator(msg.sender)) { revert FoundationTreasuryNode_Caller_Not_Operator(); } _; }
When arguments are read-only on external functions, the data location can be calldata.
The demo of the gas comparison can be seen here.
contracts/NFTCollection.sol 282: function baseURI() external view returns (string memory uri) { contracts/NFTCollectionFactory.sol 284-288: function createNFTDropCollectionWithPaymentFactory(..., CallWithoutValue memory paymentAddressFactoryCall ) external returns () {
#0 - HardlyDifficult
2022-08-17T19:48:39Z
FOR-LOOP LENGTH COULD BE CACHED
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.
OR-LOOP unchecked{++i}
Invalid - the example provided is inside an unchecked block already.
Don't initialize variables with default values.
Invalid. This optimization technique is no longer applicable with the current version of Solidity.
EXPRESSIONS FOR CONSTANT VALUES SUCH AS A CALL TO keccak256()
While it seems this should help, changing to immutable shows a regression in our test suite. Gas costs go up by ~50 gas.
Variable re-arrangement by storage packing
Invalid - packing does not apply to constants.
REDUCE THE SIZE OF 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.
DUPLICATED REQUIRE() CHECKS COULD BE REFACTORED TO A MODIFIER OR FUNCTION
Fair feedback - however the first two are separate classes due to different dependencies. We will be renaming one of these to make that more clear. The other is a copy paste from OZ with minimal modifications, we don't want to diverge from the source more than we need to. For the isContract references, many of those messages vary a bit which can be helpful for debugging.
Functions guaranteed to revert when called by normal users can be marked payable
Disagree. This could save gas but 1) this seems like a poor practice to encourage and could be error prone. and 2) we don't care about optimizing admin-only actions.
Unused modifier
Good catch. However they are used in other contracts which were out of scope for this contest.
USE CALLDATA INSTEAD OF MEMORY
Invalid for the return value.
Valid for the create call, saves ~60 gas on createNFTDropCollectionWithPaymentFactory