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: 10/119
Findings: 5
Award: $656.69
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0x446576, 0xA5DF, 0xDave, 0xDecorativePineapple, 0xRobocop, 0xbepresent, 8olidity, Aymen0909, Ch_301, Chom, Franfran, HollaDieWaldfee, Madalad, Parth, Ruhum, Tricko, bin2chen, carrotsmuggler, chaduke, danyams, evan, gz627, hansfriese, hihen, imare, immeas, jadezti, jayphbee, jonatascm, kaliberpoziomka8552, kiki_dev, kree-dotcom, ladboy233, lukris02, lumoswiz, mahdikarimi, minhquanym, minhtrng, nameruse, neumo, obront, pauliax, poirots, reassor, rvierdiiev, slvDev, sorrynotsorry, yixxas, zapaz
0.8413 USDC - $0.84
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L110-L114
LPDA price decreases over time till endTime
timestamp.
If the LPDA isn't configured properly, the price might underflow when reaching the endTime
timestamp (to be specific, it'll underflow if (endTime-startTime)*dropPerSecond > startPrice
).
This will disable all functionality of the LPDA instance and freeze all funds accumulated from all purchases done till the point in time the price underflowed.
The following test demonstrates such a case of poor configuration, the test will revert with an underflow error.
File: test/LPDA.poc.t.sol
pragma solidity ^0.8.17; import "forge-std/Test.sol"; import {EscherTest} from "./utils/EscherTest.sol"; import {LPDAFactory, LPDA} from "src/minters/LPDAFactory.sol"; contract LPDABase is EscherTest { LPDAFactory public lpdaSales; LPDA.Sale public lpdaSale; LPDA public sale; event End(LPDA.Sale _saleInfo); function setUp() public virtual override { super.setUp(); lpdaSales = new LPDAFactory(); // set up a LPDA Sale lpdaSale = LPDA.Sale({ currentId: uint48(0), finalId: uint48(10), edition: address(edition), startPrice: uint80(uint256(1 ether)), finalPrice: uint80(uint256(0.1 ether)), dropPerSecond: uint80(uint256(1 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 2 days) }); } function test_Buy_bug() public { sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); //lets buy an NFT vm.warp(block.timestamp + 3 days); sale.buy{value: 1 ether}(1); assertEq(address(sale).balance, 1 ether); } }
Check that the price isn't going to overflow during the creation of the LPDA instance.
#0 - c4-judge
2022-12-12T10:00:18Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:56:50Z
berndartmueller changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-02T19:56:53Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0x52, 0xA5DF, 0xdeadbeef0x, KingNFT, Madalad, Parth, Soosh, _Adam, adriro, csanuragjain, danyams, eyexploit, gasperpre, gz627, gzeon, hansfriese, hihen, immeas, jadezti, jonatascm, kiki_dev, kree-dotcom, ladboy233, lukris02, lumoswiz, mahdikarimi, minhtrng, nalus, nameruse, obront, reassor, rvierdiiev, seyni, tnevler, wait, yixxas
1.3417 USDC - $1.34
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol#L107-L111 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L146-L148
This one affects LPDA and Fixed Price contracts, since those end only when the creator sells all of the tokens that he put up for sale. In case that the creator didn't manage to sell all the tokens, they won't able to withdraw the profit for the tokens they've sold.
The lack of ability to withdraw the funds before the contest ends is pretty clear from the code (and the lack of a feature can't be demonstrated via a coded PoC).
Consider the following scenario:
Allow creator to withdraw the sale revenue even before the sale ends.
In the case of LPDA - allow only to withdraw lowestPrice * amount
, since some funds should be reserved for refunds when/if the price goes down further.
#0 - c4-judge
2022-12-13T11:50:37Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:21:20Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-02T20:23:08Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: 0xA5DF, 0xRobocop, AkshaySrivastav, Ch_301, HollaDieWaldfee, KingNFT, bin2chen, carrotsmuggler, cccz, gasperpre, hansfriese, hihen, jonatascm, lumoswiz, neumo
28.357 USDC - $28.36
The sales contracts are supposed to mint a specific range of token IDs, however there's no guarantee that those tokens weren't minted previously or wouldn't be minted by another contract/EOA during the sale.
In such case the buy()
function will revert when it reaches that already-minted token ID.
In the case of Open Edition it'll only disable further buying, but in the case of LPDA and Fixed Price it'll also lock all the funds from previous purchases, since the funds can't be withdrawn till all token IDs that are up for sale are purchased.
The following test demonstrates a case where 2 sales are created, one has done already one sale
File: test/LPAD.poc.t.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "forge-std/Test.sol"; import {EscherTest} from "./utils/EscherTest.sol"; import {LPDAFactory, LPDA} from "src/minters/LPDAFactory.sol"; contract LPDABase is EscherTest { LPDAFactory public lpdaSales; LPDA.Sale public lpdaSale; LPDA public sale; LPDA public sale2; event End(LPDA.Sale _saleInfo); function setUp() public virtual override { super.setUp(); lpdaSales = new LPDAFactory(); // set up a LPDA Sale lpdaSale = LPDA.Sale({ currentId: uint48(0), finalId: uint48(10), edition: address(edition), startPrice: uint80(uint256(1 ether)), finalPrice: uint80(uint256(0.1 ether)), dropPerSecond: uint80(uint256(0.1 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 1 days) }); } function test_Buy_bug() public { sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); lpdaSale.currentId += 1; sale2 = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); edition.grantRole(edition.MINTER_ROLE(), address(sale2)); //lets buy an NFT sale.buy{value: 1 ether}(1); sale2.buy{value: 1 ether}(1); sale.buy{value: 1 ether}(1); } }
currentId
parameter is equal to last token minted#0 - c4-judge
2022-12-13T11:52:19Z
berndartmueller marked the issue as duplicate of #390
#1 - c4-judge
2023-01-02T20:04:55Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-02T20:05:36Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: 0xA5DF, HollaDieWaldfee
594.9857 USDC - $594.99
https://github.com/code-423n4/2022-12-escher/blob/11ca988e39effe6d316e996c27a876c92e82b4da/src/Escher721.sol#L13 https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/65420cb9c943c32eb7e8c9da60183a413d90067a/contracts/access/AccessControlUpgradeable.sol#L165-L167
Reading the cancel()
function it seems that the protocol intends to allow canceling the sale only before the sale starts.
However, a creator can effectively cancel a sale after it started by revoking the minting role from the sale contract.
This is mostly relevant for the Open Edition sale - since it ends at a certain point in time regardless of the amount sold (meaning, a creator can stop the sale by revoking the minting role and will still be able to collect the funds from purchases done before revoking).
The following test demonstrates such a scenario, where the creator revokes the role after the sale has started. The creator is still able to withdraw the profit from the first purchase.
File: test/Revoke.poc.t.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import {EscherTest} from "./utils/EscherTest.sol"; import {OpenEditionFactory, OpenEdition} from "src/minters/OpenEditionFactory.sol"; contract OpenEditionBase is EscherTest { OpenEditionFactory public openSales; OpenEdition.Sale public openSale; function setUp() public virtual override { super.setUp(); openSales = new OpenEditionFactory(); openSale = OpenEdition.Sale({ price: uint72(uint256(1 ether)), currentId: uint24(1), edition: address(edition), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 1 days) }); } } contract OpenEditionTest is OpenEditionBase { OpenEdition public sale; event End(OpenEdition.Sale _saleInfo); function test_Revoke_bug() public { sale = OpenEdition(openSales.createOpenEdition(openSale)); // authorize the fixed price sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); // lets buy some NFTs sale.buy{value: 1 ether}(1); // creator regrets and revokes the minter role edition.revokeRole(edition.MINTER_ROLE(), address(sale)); // Attempting to buy any additional tokens will revert vm.expectRevert(); sale.buy{value: 1 ether}(1); // creator is still able to finalize the sale and get the payment from 1st purchase vm.warp(openSale.endTime + 1 hours); sale.finalize(); } }
revokeRole()
and adding a check before revoking#0 - c4-judge
2022-12-13T13:58:32Z
berndartmueller marked the issue as duplicate of #399
#1 - c4-judge
2023-01-03T10:31:46Z
berndartmueller marked the issue as satisfactory
🌟 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
31.1555 USDC - $31.16
They buy()
function doesn't require the amount to purchase to be greater than zero, meaning a user can run the function with zero amount, which would spam the contract with Buy
events without actually buying anything.
It's not possible to mint the #0 token, since the sales contract will start minting the temp.currentId + 1
token, and since currentId
is an unsigned int it can't be lower then 0. Meaning the minimum token ID that can be minted is 1.
Switch the currentID to be the next token ID to be minted, rather than the last token minted, and update the code accordingly.
msg.value
can cause loss of ETH in a rare caseCode: src/minters/LPDA.sol#L71
The LPDA.buy()
function unsafely casts the msg.value
to uint80
, in case that msg.value
is greater than 2^80
it'll reduce the amount to msg.value % 2^80
, and the difference will be locked in the contract.
This is not very practical since it requires to send ~1.2M ETH, but it's still better to do a safe cast (i.e. check that msg.value <= type(uint80).max
) just in case.
_amount
at buy()
The buy()
function gets a uint256
parameter named _amount
and unsafely casts it to uint24/uint48.
As for the OpenEdition and LPDA contracts, this would have only a 'cosmetic' effect (user who'd look at the tx would see that the _amount
parameter sent is much greater than the actual amount purchased).
As for the Fixed Price contract, this would also cause a loss of value, since the _amount
is casted only for calculating the newID
but not for the msg.value
check.
This can lead to a rare case where a user sets _amount
to x + 2^48
and send ETH accordingly, but get only x tokens in return.
The Sale.price
variable of the OpenEdition
contract is a uint72
, meaning it's max value is ~4.7K ETH.
For most sales that would be enough, but in the history of NFT there have been recorded higher prices.
The size of the variable can be increased without taking up any additional storage slots, by moving the price
variable to the 3rd slot of the Sale
struct:
struct Sale { // slot 1 uint48 currentId; address edition; // slot 2 uint96 startTime; address payable saleReceiver; // slot 3 uint96 endTime; uint96 price; }
Also the LDPA price variable can be increased to uint84
instead of uint80
without taking up any additional slots.
Generative.tokenToSeed()
is internal instead of publicGenerative.tokenToSeed()
function is defined as internal, but has no internal usage.
I understood from the devs that it's supposed to serve external contract in generating metadata from a seed. Therefore it needs to be changed to public/external in order to be accessible.
Escher721
contractThe sale contract factories only check that the creator has the admin role over the _sale.edition
contract.
But any user can simply clone the code of Escher721
, deploy his own version without having the Creator role and then start a sale with it.
This might confuse users to thing that the created sale contract is from a legitimate seller approved by the protocol.
The cheapest/easiest solution would be to also check that msg.sender
has the Creator role.
A more thorough solution would be to handle a registry of deployed contract at Escher721Factory
, and at the sale factories check that the edition
contract is registered there.
#0 - c4-judge
2023-01-04T10:43:15Z
berndartmueller marked the issue as grade-b