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: 12/108
Findings: 2
Award: $438.63
🌟 Selected for report: 1
🚀 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
403.2743 USDC - $403.27
address.isContract
checksupportsInterface
checkif
BlocksOn line 80, supportsInterface
can be rewritten to avoid the if/esle
branching:
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool interfaceSupported) { return ( interfaceId == type(IRoyaltyInfo).interfaceId || interfaceId == type(ITokenCreator).interfaceId || interfaceId == type(IGetRoyalties).interfaceId || interfaceId == type(IGetFees).interfaceId || super.supportsInterface(interfaceId) ); }
In ICollectionFactory
on line 6, IProxyCall
is never used and can safely be removed. Unless there is a plan to use it in the future. Maybe a comment explaining why it was imported here would be helpful.
On line 31, there is a check for _contractFactory
to see if it already has a code. I guess this is an extra check that can be removed. Since if _contractFactory
calls the constructor here in its own constructor by then _contractFactory.isContract() = _contractFactory.code.length == 0
. Also, it is possible that a wrong contract address is passed here, so the check would not really do anything. This will only check against accidental EOA addresses used for _contractFactory
. So we could possibly remove the following lines:
5 import "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"; 13 using AddressUpgradeable for address; 31 require(_contractFactory.isContract(), "ContractFactory: Factory is not a contract");
If there is a stricter condition for the allowed contractFactory
addresses, maybe we could use that instead. One possible idea is an array of implementation contract code hashes that we could check. Or maybe contracts that have a function similar to supportsInterface
that returns a magic number which we could check here.
4.1 Shorter inheritance list
The inheritance contracts on line 29-40 can be consolidated into a shorter list:
contract NFTCollection is INFTCollectionInitializer, ContractFactory, SequentialMintCollection, CollectionRoyalties {
Then you would need to adjust the overrides on lines 255 and 316
CID
need to be unique per tokenID
Different tokenID
s can not share the same CID
by design. Although it is possible to design the contract so that some tokens share the same CID
to save storage and also server space for off-chain contents.
.isContract()
On lines 182, and 203 instead of checking if addr.isContract()
to avoid setting the addresses to EOA by mistake it would be best to pass the code hash instead and check the code hash at those addresses. So for example:
Before:
constructor(address _rolesContract) { require(_rolesContract.isContract(), "NFTCollectionFactory: RolesContract is not a contract"); rolesContract = IRoles(_rolesContract); }
After:
constructor(address _rolesContract, bytes32 codehash) { require(_rolesContract.codehash == codehash, "NFTCollectionFactory: RolesContract is not a contract"); rolesContract = IRoles(_rolesContract); }
This is a stronger requirement since it would guarantee that the addresses are contracts and also they have the required code hash. For the functions to pass the require
statements you would need to make 2 mistakes, one for the address and the other for the code hash. The probability of making this mistake should be theoretically lower than just passing a wrong address.
versionNFTDropCollection
Doesn't have an initializer like versionNFTCollection
rolesContract
rolerManager
might be a better name for this immutable variable and would make it easier to remember what it does (ref. line 104).
supportsInterface
functionWe can rewrite supportsInterface
function (Lines 284-294) like the following block which would make it easier to read and possibly would save some gas.
function supportsInterface(bytes4 interfaceId) public view override(ERC165Upgradeable, AccessControlUpgradeable, ERC721Upgradeable, CollectionRoyalties) returns (bool) { return ( interfaceId == type(INFTDropCollectionMint).interfaceId || super.supportsInterface(interfaceId) ); }
So the current comment says:
If +1 overflows then +count would also overflow, unless count==0 in which case the loop would exceed gas limits
But count
can not be zero if we have reached this line. Since we have already checked for a non-zero count
on line 172
So we can change the comment to
// If +1 overflows then +count would also overflow, since count > 0.
Like NFTCollection
, the inheritence list for NFTDropCollection
contract on lines 28-46 can be consolidated more.
contract NFTDropCollection is INFTDropCollectionInitializer, INFTDropCollectionMint, ContractFactory, MinterRole, SequentialMintCollection, CollectionRoyalties {
The overrides
on lines 245 and 287 would also need to be modified accordingly.
In mintFromFixedPriceSale
we can avoid the nested if
blocks on lines 182-189. This would improve readability and analyze and it would have the same effect. On the plus side, it will also save gas for a reverting call where saleConfig.limitPerAccount
is zero by avoiding the outer if
block in the original code.
// Confirm that the collection has a sale in progress. if (saleConfig.limitPerAccount == 0) { revert NFTDropMarketFixedPriceSale_Must_Have_Sale_In_Progress(); } // Confirm that the buyer will not exceed the limit specified after minting. if (IERC721(nftContract).balanceOf(msg.sender) + count > saleConfig.limitPerAccount) { revert NFTDropMarketFixedPriceSale_Cannot_Buy_More_Than_Limit(saleConfig.limitPerAccount); }
In constructor
instead of passing _lockupDuration
pass _lockupInterval
to save on the exact division check.
So taking that into consideration the constructor
would look like this:
constructor( address payable _foundationMarket, address payable _foundationDropMarket, uint256 _lockupInterval ) { if (!_foundationMarket.isContract()) { revert FETH_Market_Must_Be_A_Contract(); } if (!_foundationDropMarket.isContract()) { revert FETH_Market_Must_Be_A_Contract(); } if (_lockupInterval == 0) { revert FETH_Invalid_Lockup_Duration(); } foundationMarket = _foundationMarket; foundationDropMarket = _foundationDropMarket; lockupInterval = _lockupInterval; lockupDuration = _lockupInterval * 24; }
Also, the _lockupInterval
check is moved up before the assignments to save gas in case of a revert. If there will be no revert, moving up the if
block would not introduce any gas changes, since the check will be performed eventually.
Keep line width to max 120 characters for better readability.
In contracts/mixins/shared/Constants.sol
we have 3 gas limit constants:
uint256 constant READ_ONLY_GAS_LIMIT = 40000; uint256 constant SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS = 210000; uint256 constant SEND_VALUE_GAS_LIMIT_SINGLE_RECIPIENT = 20000;
These numbers are not future-proof as some hardforks introduce changes to gas costs. These potential future changes to gas costs might break some of the functionalities of the smart contracts that use these constants. This is something to keep in mind. If some hardfork, would break a smart contract using these numbers you would need to deploy new contracts with adjusted gas limit constants. Or you can also have these gas limits be mutable by admins on-chain. For example, all these 3 values can be stored on-chain in 1 storage slot.
address.isContract
check <a id="is-contract"></a>Lots of the contracts in this project import import "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"
and use address.isContract()
to check if an address is a contranct and not a EOA. I guess this is only a check if the deployer by mistake provides the wrong address. I think this should be double-checked off-chain. If an on-chain check is needed, there are other checks that can be done that are even more strict than just checking against EOA mistakes. For example, we can provide the contract has as the second input to the constructor and check the address's codehash against that. Here is a template as an example:
constructor(address c, bytes32 h) { if( c.codehash != h) { revert CustomError(); } }
Not only this checks for address with code, but also pinpoints the contract hash to a specific hash. Another type of check that can be used is to check if the provided contract address supports a specific interfaceSupport
or call an endpoint of the contract expecting it to return a specific magic number.
Here is a list of places isContract
has been used:
supportsInterface
check <a id="support-interface"></a>NFTDropCollection.supportsInterface
(lines 284-295) can be changed to:
function supportsInterface(bytes4 interfaceId) public view override(ERC165Upgradeable, AccessControlUpgradeable, ERC721Upgradeable, CollectionRoyalties) returns (bool) { return ( interfaceId == type(INFTDropCollectionMint).interfaceId || super.supportsInterface(interfaceId); ); }
CollectionRoyalties.supportsInterface
(lines 80-91) can be changed to:
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return ( interfaceId == type(IRoyaltyInfo).interfaceId || interfaceId == type(ITokenCreator).interfaceId || interfaceId == type(IGetRoyalties).interfaceId || interfaceId == type(IGetFees).interfaceId || interfaceSupported = super.supportsInterface(interfaceId) ) }
It's best to use the same compiler version across all project files/team members. So having a fixed version pragma is a good practice. Most contracts use a floating pragma which would allow the patch number to be equal or higher than the specified patch number.
if
Blocks <a id="if-blocks"></a>For better readability and analysis it is better to avoid nested if
blocks. Here is an example:
After edit:
if (spenderAllowance == type(uint256).max) { return ; } if (spenderAllowance < amount) { revert FETH_Insufficient_Allowance(spenderAllowance); } // The check above ensures allowance cannot underflow. unchecked { spenderAllowance -= amount; } accountInfo.allowance[msg.sender] = spenderAllowance; emit Approval(from, msg.sender, spenderAllowance);
#0 - HardlyDifficult
2022-08-18T18:15:34Z
Very detailed and thoughtful feedback -- thank you!
supportsInterface can be rewritten to avoid the if/esle branching:
I think I do like this style more, will consider the change.
contracts/interfaces/ICollectionFactory.sol
Agree, fixed.
contracts/mixins/shared/ContractFactory.sol
Not sure I'm following this suggestion. There does not appear to be another .code.length type check included at the moment. Considering a stricter check is compelling but since this is an admin function call I think that may be overkill here.
Shorter inheritance list
True but for top-level contracts I like to expand all inherited contracts to make it clear what all the dependencies are and the lineriazation order they are included in.
4.2 CID need to be unique per tokenID
Agree. This is a primary goal of the NFTDropCollection. As you note there are other more flexible ways we could run with this type of approach and we may consider those in the future.
5.1 .isContract()
Fair feedback. Considering a stricter check is compelling but since this is an admin function call I think that may be overkill here.
5.2 versionNFTDropCollection
By design - the default value of 0 is correct there. NFTCollections were previously created by a different factory contract, we wanted the new factory to pick up version where that left off. But drops are new so starting at 0 is correct.
5.3 a better name can be chosen for rolesContract
Agree, I like that name more and will update.
6.2 The comment on line 175 needs a bit of correction
Good catch -- this was missed after adding a require against count == 0. Will fix.
- contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol
Although minor, this approach was used to save gas for the happy case scenario since it avoids a second if condition.
contracts/FETH.sol (Out of Scope)
Fair feedback, but I think the current approach is easier to reason about. And saving admin-only gas is not a goal for us.
- Line Width
Our linter is configured to require 120... although maybe you mean we are adding new lines too early in some instances (?)
- Hard-coded gas limits
Fair feedback. However the use case requires some gas limit to be defined and it's not clear there is a viable alternative here.
- address.isContract check
This is good feedback. ATM these checks are there to help avoid simple errors by the admin. I'm not sure that the stricter check is worth the complexity to maintain.
Use fixed pragma
Disagree. 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.
- Avoid Nested if Blocks
(out of scope) I agree that style is better, will fix.
#1 - liveactionllama
2022-09-23T03:46:10Z
Per discussion with @HickupHH3 (judge), regarding severity and validity of above items:
"Slightly disagree with #3. Agree with sponsor that the suggestion isn't clear. The rest is ok!"
🌟 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
35.3633 USDC - $35.36
require
= 0
assignment can be avoided for default valuesCan be improved a lot. Since out of scope, did not allocate time to write a report here.
replaceAtIf
functionWe can avoid abi.encodePacked
and also the loop in replaceAtIf
function, by implementing it in an assembly block. Also note that the assertion and replacement each can be done at once. So the lines 21-32 can be changed to:
assembly { // 0x14 = 0x20 - 0x0c = 12 index to the right of `startLocation` // so that `dataChunk` has the right most 20 bytes as the potential // `expectedAddress`. let dataPtr := add(data, add(0x14, startLocation)) let dataChunk := mload(dataPtr) if (eq(expectedAddress, and(dataChunk, expectedAddress))) { // the 12 left most bytes of `maskedDataChunk` will be the same as // `dataChunk` and the rest would be 0. let maskedDataChunk = and( dataChunk, 0xffffffffffffffffffffffff0000000000000000000000000000000000000000 ) mstore(dataPtr, or( maskedDataChunk, newAddress )) } else { // 0x370d3a6a00000000000000000000000000000000000000000000000000000000 // is the bytes32 right padded signature of BytesLibrary_Expected_Address_Not_Found() mstore(0, 0x370d3a6a00000000000000000000000000000000000000000000000000000000) revert(0, 0x20) } }
startsWith
functionAgain, we can avoid the loop, and also multiple memory reads. Here is the implementation using assembly
blocks:
function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool doesStartsWith) { // A signature is 4 bytes long if (callData.length < 4) { return false; } assembly { // peek 4 bytes after the memory pointer let callDataChunk := mload(add(callData, 4)) doesStartsWith := eq( and(callDataChunk, functionSig), functionSig ) } }
startsWith
is only used in contracts/PercentSplitETH.sol for the external function proxyCall
. So the gas savings would be noticed there.
adminUpdateNFTCollectionImplementation
functionThe unchecked
block in adminUpdateNFTCollectionImplementation can be rewritten as
unchecked { // Version cannot overflow 256 bits. - versionNFTCollection++; + ++versionNFTCollection; }
Also the versionNFTCollection.toString()
which is passed to INFTCollectionInitializer(_implementation).initialize
can be factored out into a variable to avoid the toString()
conversion twice.
string memory version = versionNFTCollection.toString(); // The implementation is initialized when assigned so that others may not claim it as their own. INFTCollectionInitializer(_implementation).initialize( payable(address(rolesContract)), string.concat("NFT Collection Implementation v", version), string.concat("NFTv", version) );
The loop (L181-L186) in mintCountTo
of NFTDropCollection
can be gas optimized by moving the loop condition inside the loop block:
for (uint256 i = firstTokenId; ; ) { _mint(to, i); unchecked { ++i; if (i > latestTokenId) { break; } } }
By moving the condition inside and modifying it to i > latestTokenId
instead of i <= latestTokenId
, the compiler would translate it into only one comparison (gt
) versus two (lt
or eq
). Therefore, it would say gas.
Note: with this change, the loop would run at least once, but this would also be the case in the original code, since
And this is because since we are passed line 172, we know that count
is greater than 0
.
The unchecked loop block in the _distributeFunds
(line 126-135) can be optimized by caching creatorRecipients.length
, it will avoid multiple mload
s per iteration. So here is how it would look like after modification (also note the @audit
comments):
unchecked { uint256 i; //@audit we don't need to assign 0, since 0 is the default value. uint256 creatorRecipientsLength = creatorRecipients.length; for (; i < creatorRecipientsLength; ) { _sendValueWithFallbackWithdraw( creatorRecipients[i], creatorShares[i], SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS ); // Sum the total creator rev from shares // creatorShares is in ETH so creatorRev will not overflow here. creatorRev += creatorShares[i]; ++i; } }
The same type of optimization can be applied to the for loop block in getFeesAndRecipients
function on line 198-200
uint256 i; //@audit we don't need to assign 0, since 0 is the default value. uint256 creatorSharesLength = creatorShares.length; for (; i < creatorSharesLength; ) { creatorRev += creatorShares[i]; ++i; }
internalGetImmutableRoyalties
functionaddress payable[] memory recipients, uint256[] memory splitPerRecipientInBasisPoints
can be combined into 1 dynamic array, it would save memory and therefore gas. IGetRoyalties
would also need to be modified.
In mintFromFixedPriceSale
we can cache saleConfig.limitPerAccount
(line 180) on the stack which has been used 3 times. This would remove 2 extra mloads
.
FixedPriceSaleConfig memory saleConfig = nftContractToFixedPriceSaleConfig[nftContract]; uint256 limitPerAccount = uint256(saleConfig.limitPerAccount); //@audit use this variable from this point on instead of `saleConfig.limitPerAccount`
In _updateMaxTokenId
we can change the condition in the 3rd require
from
Before:
require(latestTokenId <= _maxTokenId, "SequentialMintCollection: Max token ID must be >= last mint");
To:
require(!(latestTokenId > _maxTokenId), "SequentialMintCollection: Max token ID must be >= last mint");
aka:
- latestTokenId <= _maxTokenId + !(latestTokenId > _maxTokenId)
Since EVM doesn't have a less than or equal opcode the compiler has to translate that into 2 comparisons LT
and EQ
which both require 2 stack items and both results have to be true to pass. We can save gas here by using GT
and negate the result by NOT
opcode which would only require the result on the stack from GT
.
The following require
statements can be turned into custom errors to save gas. The list of locations require
statement can be changed to custom errors:
In CollectionRoyalties
, the functions getFeeRecipients
, getFeeBps
, getRoyalties
only return arrays of length 1. It would be best to redesign some contracts so that these return values can be instead just values and not arrays. This would prevent using memory and the gas costs associated with using memory (MLOAD
, MSTORE
, and memory expansion gas costs).
= 0
assignment can be avoided for default values <a id="zero-assign"></a>Since value types have by default the value of 0
, explicit 0
assignment can be avoided which actually would require more gas.
Example:
- uint256 i = 0; + uint256 i;
List of locations where this change can be applied:
#0 - HardlyDifficult
2022-08-19T16:22:26Z
Great report with a few well considered recs. Thanks
replaceAtIf function
Won't fix. While this may save a bit of gas, we reserve the use of assembly to areas where it would provide significant benefit or accomplish something that's otherwise not possible with Solidity alone.
startsWith function
We are looking into a variation of based this suggestion to realize some benefits while limiting the use of assembly.
adminUpdateNFTCollectionImplementation function
Agree, will fix
- contracts/NFTDropCollection.sol
This is an interesting suggestion, have not seen this one before. I'm inclined to leave it as-is though for readability.
- contracts/mixins/shared/MarketFees.sol
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.
internalGetImmutableRoyalties function
IGetRoyalties is an interface used by some external NFT contracts and marketplaces. So we are unable to change the return type.
- contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol
True, but the savings would only apply to reverting scenarios it seems. Thinking we'll keep it as-is for simplicity.
- contract/mixins/collections/SequentialMintCollection.sol
Valid, but it's small savings and I prefer the current form for readability.
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.
. contracts/mixins/collections/CollectionRoyalties.sol
Agree, but these are here for compatibility with other marketplaces. We cannot change them since they are defacto standards. Our marketplace will only hit .royaltyInfo so the rec wouldn't apply there.
Don't initialize variables with default values.
Invalid. This optimization technique is no longer applicable with the current version of Solidity.