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: 24/119
Findings: 5
Award: $148.60
🌟 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/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDAFactory.sol#L29-L42 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L117-L125 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L58-L89
The way getPrice()
calculation works in LPDA is with return temp.startPrice - (temp.dropPerSecond * timeElapsed)
. This is sound as price is decreasing at a constant rate based on temp.dropPerSecond
. However, the lack of input sanitation is crucially dangerous here. If at some point in time after sale has started, temp.dropPerSecond * timeElapsed > temp.startPrice
, getPrice()
will always revert due to the underflow error here.
The sale will never end because no one is able to call buy()
-> saleReceiver
and feeReceiver()
will never be able to claim their proceeds.
refund()
can never be called by users. Whatever funds that are transferred to the contract will be stuck forever.
We will also not be able to cancel()
as sale has started.
We first see in createLPDASale()
that there is no restrictions placed on dropPerSecond
relative to startPrice
, startTime
and endTime
.
function createLPDASale(LPDA.Sale calldata sale) external returns (address clone) { require(IEscher721(sale.edition).hasRole(bytes32(0x00), msg.sender), "NOT AUTHORIZED"); require(sale.saleReceiver != address(0), "INVALID SALE RECEIVER"); require(sale.startTime >= block.timestamp, "INVALID START TIME"); require(sale.endTime > sale.startTime, "INVALID END TIME"); require(sale.finalId > sale.currentId, "INVALID FINAL ID"); require(sale.startPrice > 0, "INVALID START PRICE"); require(sale.dropPerSecond > 0, "INVALID DROP PER SECOND"); clone = implementation.clone(); LPDA(clone).initialize(sale); emit NewLPDAContract(msg.sender, sale.edition, clone, sale); }
getPrice()
will always reach the reverting line uint256 timeElapsed = end > block.timestamp ? block.timestamp - start : end - start
if sale has started and sale has not completely been sold out.
function getPrice() public view returns (uint256) { Sale memory temp = sale; (uint256 start, uint256 end) = (temp.startTime, temp.endTime); if (block.timestamp < start) return type(uint256).max; if (temp.currentId == temp.finalId) return temp.finalPrice; uint256 timeElapsed = end > block.timestamp ? block.timestamp - start : end - start; return temp.startPrice - (temp.dropPerSecond * timeElapsed); }
Say we our sale is set to last 24 hours ( 86400 seconds ).
We set startPrice = 1 ether
, dropPerSecond = 1e14
.
After 10000 seconds, we have dropPerSecond * timeElapsed = 1e14 * 10000 = 1e18
.
Any call to getPrice()
after the 10000th seconds will revert. Assuming that there are still NFTs remaining to be sold, anyone that bought anything in the first 10000th seconds will not be able to claim back their rightful funds as they should in a LPDA auction as they should, as refund()
relies on getPrice()
which always reverts.
function buy(uint256 _amount) external payable { uint48 amount = uint48(_amount); Sale memory temp = sale; IEscher721 nft = IEscher721(temp.edition); require(block.timestamp >= temp.startTime, "TOO SOON"); uint256 price = getPrice(); require(msg.value >= amount * price, "WRONG PRICE"); amountSold += amount; uint48 newId = amount + temp.currentId; require(newId <= temp.finalId, "TOO MANY"); receipts[msg.sender].amount += amount; receipts[msg.sender].balance += uint80(msg.value); for (uint256 x = temp.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, amount, msg.value, temp); if (newId == temp.finalId) { sale.finalPrice = uint80(price); uint256 totalSale = price * amountSold; uint256 fee = totalSale / 20; ISaleFactory(factory).feeReceiver().transfer(fee); temp.saleReceiver.transfer(totalSale - fee); _end(); } }
No one is also able to buy anymore NFTs after this period because buy()
checks the price with getPrice()
.
Sellers of the NFTs will not be able to retrieve their earnings from sales because the sale never ends.
Likewise, feeReceiver
will not be able to collect any fees from the sales that are done.
cancel()
does not save us as it cannot be called after sale has started.
Manual review
Do not allow a sale to be created where (endTime - startTime) * dropPerSecond > startPrice
.
Add the require check require( (endTime-startTime) * dropPerSecond <= startPrice )
in createLPDASale
.
#0 - c4-judge
2022-12-11T11:38:04Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:54:43Z
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/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L107-L111 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L57-L74 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L50-L53
If a FixedPrice sale is not popular, and its NFTs are not completely sold out, there is no way for seller or fee receiver to withdraw their funds. One can argue that the saleReceiver()
can purposefully buy out all the NFTs to trigger _end()
, but this requires paying a 5% fee on the remaining NFTs to the feeReceiver()
( assuming they are different addresses ), which is a sizable amount.
The only way for feeReceiver()
or saleReceiver()
to receive funds is in the _end()
function, which is triggered in buy()
after all NFTs are sold out. cancel()
will not work once a sale has started. We note that this is a very likely scenario to happen since NFTs are not going to be sold out all the time in a fixed price sale.
Manual Review
I recommend that we add a _sale.endTime
, to add a limit in which the fixed price sale should end. Once block.timestamp
passes this limit, we should be able to trigger _end()
for the seller or fee receiver to get their funds.
#0 - c4-judge
2022-12-13T11:59:31Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:23:10Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: 0x1f8b, Matin, UniversalCrypto, gzeon, karanctf, minhquanym, obront, rvierdiiev, seyni, slvDev, yixxas
57.6274 USDC - $57.63
When a user buy()
editions, they can choose the number of editions they want to purchase. However, due to an explicit downcast on a uint256
value, they can make a payment for a large number of editions, but receive a much lesser amount of editions. feeReceiver()
and saleReceiver()
will receive a disproportionately large amount of funds. Huge loss of asset for user.
we see in the code snippet below, buy()
takes in uin256 _amount
. This value is multiplied with sale_.price
to verify that it is equal to the amount of ether user has sent in. However, in the next line we have, uint48 newId = uint48(_amount) + sale_.currentId
. _amount
is downcasted to uint48
.
function buy(uint256 _amount) external payable { Sale memory sale_ = sale; IEscher721 nft = IEscher721(sale_.edition); require(block.timestamp >= sale_.startTime, "TOO SOON"); require(_amount * sale_.price == msg.value, "WRONG PRICE"); uint48 newId = uint48(_amount) + sale_.currentId; require(newId <= sale_.finalId, "TOO MANY"); for (uint48 x = sale_.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, _amount, msg.value, sale); if (newId == sale_.finalId) _end(sale); }
Let me illustrate with a scenario that causes massive loss to user.
A fixed sale is started with x = 100000000
number of edition ids. ( Exact amount of supply does not matter )
sale_.price
is set at 1000 wei
.
sale_.currentId = 0
.
Buyer is not aware of this, decides to buy type(uint48).max + 1 = 281,474,976,710,656
with the correct amount of msg.value = 281,474,976,710,656 * 1000 = 281,474,976,710,656,000
and this is about 0.28 ether
, a realistic amount.
Now, because _amount
is downcasted, we have uint48 newId = uint48(_amount) + sale_.currentId = 1 + 0 = 1
.
End result is that only one NFT would be minted to buyer despite paying for type(uint48).max + 1
of them. Buyer loses his 0.28 ether
.
Manual review.
We should downcast the _amount
at the start of the function and use this same value throughout just like how it is done in the other sale methods.
function buy(uint256 _amount) external payable { + _amount = uint48(_amount); Sale memory sale_ = sale; IEscher721 nft = IEscher721(sale_.edition); require(block.timestamp >= sale_.startTime, "TOO SOON"); require(_amount * sale_.price == msg.value, "WRONG PRICE"); - uint48 newId = uint48(_amount) + sale_.currentId; + uint48 newId = _amount + sale_.currentId; require(newId <= sale_.finalId, "TOO MANY"); for (uint48 x = sale_.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, _amount, msg.value, sale); if (newId == sale_.finalId) _end(sale); }
#0 - c4-judge
2022-12-13T17:02:12Z
berndartmueller marked the issue as duplicate of #369
#1 - c4-judge
2023-01-03T13:57:14Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-03T13:57:21Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: tnevler
Also found by: 0xDecorativePineapple, 0xRobocop, 0xbepresent, Chom, Ruhum, Soosh, imare, lukris02, pashov, yellowBirdy, yixxas
57.6274 USDC - $57.63
Judge has assessed an item in Issue #173 as M risk. The relevant finding follows:
L-03 - Depreciating-soon selfdestruct is used to transfer funds to seller after sale ends.
#0 - c4-judge
2023-01-07T13:33:48Z
berndartmueller marked the issue as duplicate of #377
#1 - c4-judge
2023-01-10T20:01:26Z
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
As explained in one of my reported issue, input of uint256 _amount
being explicitly downcasted to a uint48
here will cause issues. One of the side effects is that event emitted will be a uint256
value, but the amount that is minted will be much smaller than that. This can create confusion for indexers. Fixing that issue would fix this too by setting _amount = uint48(_amount)
right at the start.
function buy(uint256 _amount) external payable { Sale memory sale_ = sale; IEscher721 nft = IEscher721(sale_.edition); require(block.timestamp >= sale_.startTime, "TOO SOON"); require(_amount * sale_.price == msg.value, "WRONG PRICE"); uint48 newId = uint48(_amount) + sale_.currentId; require(newId <= sale_.finalId, "TOO MANY"); for (uint48 x = sale_.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, _amount, msg.value, sale); if (newId == sale_.finalId) _end(sale); }
This makes it difficult for royalties receivers to trust the artist, since royalties amount can be change anytime to 0. Artist can get all the profits to himself. I recommend to allow royalties to be set only once.
function setDefaultRoyalty( address receiver, uint96 feeNumerator ) public onlyRole(DEFAULT_ADMIN_ROLE) { _setDefaultRoyalty(receiver, feeNumerator); }
The selfdestruct function is being used in OpenEdition.sol
and FixedPrice.sol
to transfer funds to users. This is not recommended as future forks of Ethereum is likely going to change that. See EIP-4758. Once removed, it will break the usage of it.
I recommend using a normal transfer instead of selfdestruct
to transfer funds to users.
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEditionFactory.sol#L29 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPriceFactory.sol#L29
It is lacking 2 important 0 checks in both of them. saleReceiver()
address can be set to 0. This will cause the proceeds of sales to go to the 0 address, which are essentially lost. The variable startPrice
can also be set to 0. Since default value in solidity is 0, sale creators may accidentally create a 0 priced sale, which is unlikely to be something that is wanted, and have all their NFTs be sold for free.
I recommend adding this 2 input checks.
#0 - c4-judge
2023-01-04T10:45:15Z
berndartmueller marked the issue as grade-b
#1 - yixxas
2023-01-05T18:23:40Z
L-02 is a dupe of #377. Please do consider upgrading.
@berndartmueller
#2 - berndartmueller
2023-01-07T13:34:25Z
L-02 is a dupe of #377. Please do consider upgrading.
@berndartmueller
Done
@yixxas