Platform: Code4rena
Start Date: 06/12/2022
Pot Size: $36,500 USDC
Total HM: 16
Participants: 119
Period: 3 days
Judge: berndartmueller
Total Solo HM: 2
Id: 189
League: ETH
Rank: 5/119
Findings: 4
Award: $1,805.27
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: RaymondFam
Also found by: 0xdeadbeef0x, 0xhacksmithh, AkshaySrivastav, Awesome, Bnke0x0, CRYP70, HollaDieWaldfee, JC, Parth, Rahoz, Tutturu, __141345__, ahmedov, ajtra, asgeir, aviggiano, bin2chen, btk, carrotsmuggler, cccz, chaduke, cryptonue, dic0de, fatherOfBlocks, fs0c, hansfriese, jonatascm, karanctf, ladboy233, lumoswiz, martin, obront, pashov, pauliax, rvierdiiev, shark, simon135, supernova, tourist, yellowBirdy, zapaz, zaskoh
0.7976 USDC - $0.80
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L105 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L85-L86 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol#L109 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/OpenEdition.sol#L92
The protocol uses Solidity’s transfer()
when transferring ETH to the recipients. This has some notable shortcomings when the recipient is a smart contract, which can render ETH impossible to transfer. Specifically, the transfer will inevitably fail when the smart contract:
85: ISaleFactory(factory).feeReceiver().transfer(fee); 86: temp.saleReceiver.transfer(totalSale - fee); 105: payable(msg.sender).transfer(owed);
109: ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20);
92: ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20);
Issues pertaining to the use of transfer()
in the code blocks above may be referenced further via:
Manual inspection
Using call
with its returned boolean checked in combination with re-entrancy guard is highly recommended after December 2019.
For instance, line 105 in LPDA.sol
may be refactored as follows:
- payable(msg.sender).transfer(owed); + (bool success, ) = payable(msg.sender).call{ value: owed }(''); + require(success, " Transfer of ETH Failed");
Alternatively, Address.sendValue()
available in OpenZeppelin Contract’s Address library can be used to transfer the Ether without being limited to 2300 gas units.
And again, in either of the above measures adopted, the risks of re-entrancy stemming from the use of this function can be mitigated by tightly following the “Check-effects-interactions” pattern and/or using OpenZeppelin Contract’s ReentrancyGuard contract.
#0 - c4-judge
2022-12-10T00:26:48Z
berndartmueller marked the issue as primary issue
#1 - c4-sponsor
2022-12-22T18:12:38Z
mehtaculous marked the issue as sponsor confirmed
#2 - mehtaculous
2022-12-22T18:13:33Z
Agree with severity. Solution would be to attempt to transfer ETH, and if that is unsuccessful, transfer WETH instead.
#3 - c4-judge
2023-01-03T12:52:10Z
berndartmueller marked the issue as selected for report
🌟 Selected for report: RaymondFam
Also found by: Josiah
1289.1356 USDC - $1,289.14
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L68
A buyer could plan on buying early at higher prices to make sure he would secure a portion (say 50%) of NFTs he desired. When the number of NFTs still available got smaller and that sale.endTime
were yet to hit, he would then watch the mempool and repeatedly attempt to thwart the final group of buyers from successfully completing their respective transactions amidst the efforts to prolong the Dutch auction till sale.endTime
was reached.
Assuming this particular edition pertained to a 100 NFT collection that would at most last for 60 minutes, and Bob planned on minting 10 of them. At the beginning of the dutch auction, he would first mint 5 NFTs at higher prices no doubt. At 50th minute, sale.currentId == 95
. Alice, upon seeing this, made up her mind and proceeded to buying the remaining NFTs. Bob, seeing this transaction queuing in the mempool, invoked buy()
to mint 1 NFT by sending in higher amount of gas to front run Alice. Needless to say, Alice's transaction was going to revert on line 68 because newId == 101
:
68: require(newId <= temp.finalId, "TOO MANY");
Noticing the number of NFTs still available had become 4, Alice attempted to mint the remaining 4 NFTs this time. Bob, upon seeing the similar queue in the mempool again, front ran Alice with another mint of 1 NFT.
These steps were repeatedly carried out until Bob managed to get all NFTs he wanted where the last one was minted right after sale.endTime
hit. At this point, every successful buyers was happy to get the biggest refund possible ensuring that each NFT was only paid for the lowest price. This intended goal, on the contrary, was achieved at the expense of the seller getting the lowest amount of revenue and that the front run buyers minting nothing.
Manual inspection
Considering refactoring the affected code line as follows:
- require(newId <= temp.finalId, "TOO MANY"); + if(newId > temp.finalId) { + uint256 diff = newId - temp.finalId; + newId = temp.finalId; + amountSold -= diff; + amount -= diff; + }
#0 - c4-judge
2022-12-14T10:36:39Z
berndartmueller marked the issue as primary issue
#1 - c4-sponsor
2022-12-22T22:11:55Z
stevennevins marked the issue as sponsor confirmed
#2 - c4-judge
2023-01-03T13:29:19Z
berndartmueller marked the issue as selected for report
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0x4non, 0xA5DF, 0xNazgul, 0xRobocop, 0xfuje, Bnke0x0, HollaDieWaldfee, Lambda, RaymondFam, TomJ, _Adam, adriro, ajtra, carrotsmuggler, cccz, danyams, gasperpre, hansfriese, helios, immeas, joestakey, ladboy233, nameruse, obront, oyc_109, rvierdiiev, saian, sakshamguruji, seyni, slvDev, yixxas, zaskoh
480.3105 USDC - $480.31
In Base.sol
, the contract inherits from OwnableUpgradeable
where _transferOwnership()
is invoked in initialize()
allowing the oldOwner to transfer ownership to the newOwner. This function does not implement zero address check (like transferOwnership
does) and proceeds directly to write the new owner's address into the owner's state variable, _owner
. If the nominated account is not a valid account, it is entirely possible the contract deployer may accidentally transfer ownership to an uncontrolled account or a zero address, breaking all functions with the onlyOwner() modifier.
Consider implementing a two step process where the deployer nominates an account and the nominated account needs to call an acceptOwnership() function for the transfer of ownership to fully succeed (using OpenZeppelin's Ownable2StepUpgradeable.sol
if need be). This will ensure the new owner is going to be fully aware of the ownership assigned/transferred other than having the above mistake avoided.
All other instances entailed:
_transferOwnership(_sale.saleReceiver);
_transferOwnership(sale.saleReceiver);
_transferOwnership(sale.saleReceiver);
The _owner
state variable inherited from OwnableUpgradeable.sol
is shadowed in initialize()
of Base.sol
.
function initialize(address _owner) public initializer { _transferOwnership(_owner); }
Consider renaming this parameter to owner_
to avoid shadowing the inherited variable.
As per Openzeppelin's recommendation:
https://forum.openzeppelin.com/t/uupsupgradeable-vulnerability-post-mortem/15680/6
The guidelines are now to prevent front-running of initialize() on an implementation contract, by adding an empty constructor with the initializer modifier. Hence, the implementation contract gets initialized atomically upon deployment.
This feature is readily incorporated in the Solidity Wizard since the UUPS vulnerability discovery. You would just need to check UPGRADEABILITY to have the following constructor code block added to the contract:
/// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); }
Here are the contract instances entailed:
function initialize(address _owner) public initializer {
constructor() {}
Solidity contracts can use a special form of comments, i.e., the Ethereum Natural Language Specification Format (NatSpec) to provide rich documentation for functions, return variables and more. Please visit the following link for further details:
https://docs.soliditylang.org/en/v0.8.16/natspec-format.html
Here are the contract instances lacking NatSpec mostly in its entirety:
File: Unique.sol File: Base.sol
Here are the contract instances partially lacking NatSpec:
File: Generative.sol File: Escher721Factory.sol File: OpenEditionFactory.sol File:LPDAFactory.sol File: Escher.sol File: Escher721.sol File: OpenEdition.sol File: LPDA.sol
Consider implementing an optional codehash check for immutable contract addresses at the constructor that will assure matching inputs of constructor parameters.
For instance, the following code block may be refactored as follows:
File: Escher721Factory.sol#L17-L18
- constructor(address _escher) { + constructor(address _escher, bytes32 _escherCodeHash) { + require(_escher.codehash == _escherCodeHas, "INVALID ADDRESS"); escher = Escher(_escher); ...
Critical operations not triggering events will make it difficult to review the correct behavior of the deployed contracts. Users and blockchain monitoring systems will not be able to detect suspicious behaviors at ease without events. Consider adding events where appropriate for all critical operations for better support of off-chain logging API.
Here are the instances entailed:
10: function setBaseURI(string memory _baseURI) external onlyOwner {
14: function setScriptPiece(uint256 _id, string memory _data) external onlyOwner { 19: function setSeedBase() external onlyOwner {
File: FixedPriceFactory.sol#L31
42: function setFeeReceiver(address payable fees) public onlyOwner {
42: function setFeeReceiver(address payable fees) public onlyOwner {
46: function setFeeReceiver(address payable fees) public onlyOwner {
57: function updateURIDelegate(address _uriDelegate) public onlyRole(URI_SETTER_ROLE) {
According to README.md, only assigned roles as a Curator or a Creator are ERC1155 soulbound tokens. However, the contract deployer, msg.sender
, would also join the party as a soulbound token that could create confusion to the users and the protocol.
Consider having the function call in the constructor refactored as follows:
constructor() ERC1155("") { - _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + super._grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
No default CURATOR_ROLE
was assigned upon the contract deployment of Escher.sol
despite this could separately be done outside the contract.
Consider having the following code line added to the constructor, serving also as an emergency measure by allowing the contract owner to assume the curator role when need be:
_grantRole(CURATOR_ROLE, msg.sender);
Additionally, it is recommended implementing addCurator()
in the contract that could look something as follows:
function addCurator(address _account) public onlyRole(DEFAULT_ADMIN_ROLE) { _grantRole(CURATOR_ROLE, _account); }
Throughout the code bases, it is noted that the protocol always skip the preliminary functions when making inherited calls to AccessControl.sol
. For instance, it would call _transferOwnership()
instead of transferOwnership()
, _grantRole()
instead of grantRole()
, and _revokeRole()
instead of revokeRole()
.
While it is understandable that the adoption of _grantRole()
would free up more roles other than the admin to carry out their respective duties, care must be taken for the other two calls.
As earlier explained at the top of this report, _transferOwnership()
skips zero address check which is duly acknowledged as outside of the scope of this contest.
_revokeRole()
, as the name of the function suggests, could sometimes trick developer into thinking an admin modifier would have been taken care of at AccessControl.sol
when it is not. It is recommended using revokeRole()
as a double measure to better prevent this critical call from being invoked by anyone.
_safeMint()
Over _mint()
for ERC721 TokensConsider using _safeMint()
instead of _mint()
since the former would determine whether or not the recipient is a contract that has a logic implemented to support ERC721 standard via onERC721Received()
. This is to prevent any token from getting trapped in a smart contract that cannot feature future token transfer.
Here is the instance entailed:
_mint(to, tokenId);
#0 - stevennevins
2022-12-22T22:38:02Z
Great submission ty!
#1 - c4-judge
2023-01-04T10:34:20Z
berndartmueller marked the issue as grade-a
🌟 Selected for report: slvDev
Also found by: 0x4non, Bnke0x0, Diana, Dinesh11G, RaymondFam, ReyAdmirado, adriro, ahmedov, ajtra, c3phas, cryptostellar5, nicobevi, pfapostol, sakshamguruji, tnevler, zaskoh
35.0246 USDC - $35.02
In OpenEdition.sol
, buy()
has the struct, sale
, cached on line 59 as temp
. However, on line 63, the require statement is using the state variable
equivalent instead of the stack local variable
. Consider having the affected code line refactored as follows to save gas as originally intended:
- require(amount * sale.price == msg.value, "WRONG PRICE"); + require(amount * temp.price == msg.value, "WRONG PRICE");
In OpenEdition.sol
, buy()
has its parameter, _amount
, cast into uint24 prior to getting it assigned to amount
on line 58. Caching a function parameter to use it twice has very minimal gas saving benefit unless this pertains to the length of an array getting into a for loop. Consider having the affected code block refactored as follows:
58: - uint24 amount = uint24(_amount); ... 64: - uint24 newId = amount + temp.currentId; + uint24 newId = uint24(_amount) + temp.currentId; ... 71: - emit Buy(msg.sender, amount, msg.value, temp); + emit Buy(msg.sender, _amount, msg.value, temp);
You can have further advantages in term of gas cost by simply using named return values as temporary local variable.
As an example, the following instance of code block can refactored as follows:
- function tokenURI(uint256 _tokenId) external view virtual override returns (string memory) { - return string.concat(baseURI, _tokenId.toString()); + function tokenURI(uint256 _tokenId) external view virtual override returns (string memory _tokenURI) { + _tokenURI string.concat(baseURI, _tokenId.toString());
All other instances entailed:
14: function tokenURI(uint256) external view virtual returns (string memory) {
27: function tokenToSeed(uint256 _tokenId) internal view returns (bytes32) {
33: ) public view override(ERC1155, AccessControl) returns (bool) {
80: ) public view override(ERC721Upgradeable) returns (string memory) { 90: returns (bool)
86: function getPrice() public view returns (uint256) { 91: function startTime() public view returns (uint256) { 96: function editionContract() public view returns (address) { 101: function available() public view returns (uint256) {
97: function getPrice() public view returns (uint256) { 102: function startTime() public view returns (uint256) { 107: function timeLeft() public view returns (uint256) { 112: function editionContract() public view returns (address) { 116: function available() public view returns (uint256) {
117: function getPrice() public view returns (uint256) { 128: function startTime() public view returns (uint256) { 133: function editionContract() public view returns (address) { 138: function available() public view returns (uint256) { 142: function lowestPrice() public view returns (uint256) {
Consider marking functions with access control as payable
. This will save 20 gas on each call by their respective permissible callers for not needing to have the compiler check for msg.value
.
Here are the instances entailed:
10: function setBaseURI(string memory _baseURI) external onlyOwner {
14: function setScriptPiece(uint256 _id, string memory _data) external onlyOwner { 19: function setSeedBase() external onlyOwner {
42: function setFeeReceiver(address payable fees) public onlyOwner {
42: function setFeeReceiver(address payable fees) public onlyOwner {
46: function setFeeReceiver(address payable fees) public onlyOwner {
21: function setURI(string memory _newuri) public onlyRole(DEFAULT_ADMIN_ROLE) { 27: function addCreator(address _account) public onlyRole(CURATOR_ROLE) {
51: function mint(address to, uint256 tokenId) public virtual onlyRole(MINTER_ROLE) { 57: function updateURIDelegate(address _uriDelegate) public onlyRole(URI_SETTER_ROLE) { 67: ) public onlyRole(DEFAULT_ADMIN_ROLE) { 72: function resetDefaultRoyalty() public onlyRole(DEFAULT_ADMIN_ROLE) {
50: function cancel() external onlyOwner {
75: function cancel() external onlyOwner {
92: function cancel() external onlyOwner {
In the EVM, there is no opcode for non-strict inequalities (>=, <=) and two operations are performed (> + = or < + =).
As an example, consider replacing >=
with the strict counterpart >
in the following inequality instance:
File: FixedPriceFactory.sol#L31
- require(_sale.startTime >= block.timestamp, "START TIME IN PAST"); + require(_sale.startTime > block.timestamp - 1, "START TIME IN PAST");
All other >=
instances entailed:
31: require(sale.startTime >= block.timestamp, "START TIME IN PAST");
32: require(sale.startTime >= block.timestamp, "INVALID START TIME");
require(block.timestamp >= sale_.startTime, "TOO SOON");
61: require(block.timestamp >= temp.startTime, "TOO SOON"); 91: require(block.timestamp >= temp.endTime, "TOO SOON");
Similarly, consider replacing <=
with the strict counterpart <
in the following inequality instance, as an example:
- require(newId <= sale_.finalId, "TOO MANY"); + require(newId < sale_.finalId + 1, "TOO MANY");
All other <=
instances entailed:
[File: FixedPrice.sol]https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol)
65: for (uint48 x = sale_.currentId + 1; x <= newId; x++) {
66: for (uint24 x = temp.currentId + 1; x <= newId; x++) {
A storage pointer is cheaper since copying a state struct in memory would incur as many SLOADs and MSTOREs as there are slots. In another words, this causes all fields of the struct/array to be read from storage, incurring a Gcoldsload (2100 gas) for each field of the struct/array, and then further incurring an additional MLOAD rather than a cheap stack read. As such, declaring the variable with the storage keyword and caching any fields that need to be re-read in stack variables will be much cheaper, involving only Gcoldsload for all associated field reads. Read the whole struct/array into a memory variable only when it is being returned by the function, passed into a function that requires memory, or if the array/struct is being read from another memory array/struct.
Here are the instances entailed:
58: Sale memory sale_ = sale;
59: Sale memory temp = sale; 90: Sale memory temp = sale
60: Sale memory temp = sale; 100: Receipt memory r = receipts[msg.sender]; 118: Sale memory temp = sale;
+=
and -=
generally cost 22 more gas than writing out the assigned equation explicitly. The amount of gas wasted can be quite sizable when repeatedly operated in a loop.
Here are the +=
instances entailed that may be refactored as follows:
66: - amountSold += amount; + amountSold = amountSold + amount; ... 70: - receipts[msg.sender].amount += amount; + receipts[msg.sender].amount = receipts[msg.sender].amount + amount; 71: - receipts[msg.sender].balance += uint80(msg.value); + receipts[msg.sender].balance = receipts[msg.sender].balance + uint80(msg.value);
The order of function will also have an impact on gas consumption. Because in smart contracts, there is a difference in the order of the functions. Each position will have an extra 22 gas. The order is dependent on method ID. So, if you rename the frequently accessed function to more early method ID, you can save gas cost. Please visit the following site for further information:
Before deploying your contract, activate the optimizer when compiling using “solc --optimize --bin sourceFile.sol”. By default, the optimizer will optimize the contract assuming it is called 200 times across its lifetime. If you want the initial contract deployment to be cheaper and the later function executions to be more expensive, set it to “ --optimize-runs=1”. Conversely, if you expect many transactions and do not care for higher deployment cost and output size, set “--optimize-runs” to a high number.
module.exports = { solidity: { version: "0.8.17", settings: { optimizer: { enabled: true, runs: 1000, }, }, }, };
Please visit the following site for further information:
https://docs.soliditylang.org/en/v0.5.4/using-the-compiler.html#using-the-commandline-compiler
Here's one example of instance on opcode comparison that delineates the gas saving mechanism:
for !=0 before optimization PUSH1 0x00 DUP2 EQ ISZERO PUSH1 [cont offset] JUMPI after optimization DUP1 PUSH1 [revert offset] JUMPI
Disclaimer: There have been several bugs with security implications related to optimizations. For this reason, Solidity compiler optimizations are disabled by default, and it is unclear how many contracts in the wild actually use them. Therefore, it is unclear how well they are being tested and exercised. High-severity security issues due to optimization bugs have occurred in the past . A high-severity bug in the emscripten -generated solc-js compiler used by Truffle and Remix persisted until late 2018. The fix for this bug was not reported in the Solidity CHANGELOG. Another high-severity optimization bug resulting in incorrect bit shift results was patched in Solidity 0.5.6. Please measure the gas savings from optimizations, and carefully weigh them against the possibility of an optimization-related bug. Also, monitor the development and adoption of Solidity compiler optimizations to assess their maturity.
"Checked" math, which is default in ^0.8.0 is not free. The compiler will add some overflow checks, somehow similar to those implemented by SafeMath
. While it is reasonable to expect these checks to be less expensive than the current SafeMath
, one should keep in mind that these checks will increase the cost of "basic math operation" that were not previously covered. This particularly concerns variable increments in for loops.
When no arithmetic overflow/underflow is going to happen, unchecked { x++ ;}
in the code block to use the previous wrapping behavior further saves gas just as in the for loop below as an example:
for (uint24 x = temp.currentId + 1; x <= newId;) { nft.mint(msg.sender, x); unchecked { x++; } }
All other instances entailed:
73: for (uint256 x = temp.currentId + 1; x <= newId; x++) {
65: for (uint48 x = sale_.currentId + 1; x <= newId; x++) {
#0 - c4-judge
2023-01-04T11:03:41Z
berndartmueller marked the issue as grade-b