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: 16/119
Findings: 6
Award: $380.74
🌟 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
LPDA auctions are run using the getPrice()
function, which calculates the current price based on the formula:
price = startPrice - (timeElapsed * dropPerSecond);
However, there is no edge case handling for the situation where startPrice > (timeElapsed * dropPerSecond)
. As a result, if the dropPerSecond
variable is set too large, the auction will eventually start to revert on getPrice()
.
If this happens before a mint sells out, it will cause buy()
to revert. This means that the contract will never finish selling out. Since there is no way to remove funds from a non-completed auction, all funds will be permanently locked in the contract.
It will additionally cause refund()
to revert, so that users who bought so far will not be able to redeem their overpayment as intended.
100 - (10 * (endTime - startTime))
getPrice()
will begin to revertgetPrice()
reverting, buy()
cannot be calledgetPrice()
will also cause refund()
to revert, causing similar problemsManual Review
Two possible safety precautions:
Option 1: Add a check in LPDAFactory.sol#createLPDASale() that ensures that the dropPerSecond
isn't too great and won't cause a price underflow:
require(sale.startPrice - (sale.dropPerSecond * (sale.endTime - sale.StartTime)) > 0);
Option 2: Manually check for this case in the getPrice()
function so that it returns a 0 in the case that the drops per second bring the price down lower than zero:
uint priceDiscount = temp.dropPerSecond * timeElapsed; if (temp.startPrice > priceDiscount) { return temp.startPrice - priceDiscount; } else { return 0 }
#0 - c4-judge
2022-12-13T17:04:03Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:57:24Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xNazgul, cccz, hansfriese, obront
289.163 USDC - $289.16
The documentation describes the ability to set the royalties on a token-by-token basis using the setTokenRoyalty
function. However, this function doesn't exist in the contract.
In the docs, it explains that the flow for setting up a sale includes:
If the artist would like sales and royalties to go somewhere other than the default royalty receiver, they must call setTokenRoyalty with the following variables:
- id: the token ID of the sale
- receiver: the address to receive paymnts
- feeNumerator: the desired royalty %
However, this function does not exist. While the inherited ERC2981.sol file does have a _setTokenRoyalty()
internal function, Escher has not implemented any external function to access it.
Instead, it implements a setDefaultRoyalty()
function, which doesn't have the same granularity laid out in the docs.
Manual Review
Implement a setTokenRoyalty()
function in Escher721.sol that calls the _setTokenRoyalty()
internal function from ERC2981.sol.
#0 - berndartmueller
2022-12-11T19:21:29Z
Downgrading to QA (Low) given the C4 Judging Criteria (https://docs.code4rena.com/awarding/judging-criteria):
QA (Quality Assurance) Includes both Non-critical (code style, clarity, syntax, versioning, off-chain monitoring (events, etc) and Low risk (e.g. assets are not at risk: state handling, function incorrect as to spec, issues with comments)
#1 - c4-judge
2022-12-11T19:21:50Z
#2 - Simon-Busch
2022-12-14T11:32:00Z
Revert back to Medium as requested by @berndartmueller
#3 - c4-judge
2022-12-14T11:34:36Z
berndartmueller marked the issue as not a duplicate
#4 - c4-judge
2022-12-14T11:34:56Z
berndartmueller marked the issue as duplicate of #181
#5 - c4-judge
2023-01-03T15:50:58Z
berndartmueller marked the issue as satisfactory
#6 - liveactionllama
2023-01-11T20:13:20Z
Duplicate of #68
🌟 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.6136 USDC - $0.61
In LPDA.sol, the refund()
function calculates the difference between what a user spent in the auction and their actual cost (calculated as final price * amount). It then refunds the difference to the user.
This refund is performed with a .transfer
call that doesn't allow the user to specify the address. In the event that the original transactions came from a smart contract that uses more than 2300 gas in its receive()
or fallback()
function, the refund will revert and the user will not be able to access their fees.
buy()
with 50 ETH for 5 NFTs, expecting to get a refundreceipts[msg.sender]
is set with their quantity and balance.transfer
call will failManual Review
Two options:
to
input to the refund()
function, which would allow users to send their refund to another address if they aren't able to accept it.#0 - c4-judge
2022-12-12T10:06:03Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:50:56Z
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
In an LPDA auction, the price falls from startPrice
down to startPrice - (dropPerSecond * (endTime - startTime)
. At this point, the auction continues, but the price no longer falls.
If the auction stops at a price that doesn't clear the market, it'll remain open indefinitely.
But, as long as the auction remains open, the owners are not able to withdraw their funds and the feeReceiver doesn't receive their fees.
The result is that an NFT collection can be mostly minted and active, but the owners are not able to collect their earnings to fund the project.
The only place in the LPDA.sol contract where funds are transferred to the feeReceiver
and saleReceiver
are in the final block of the buy()
function:
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(); }
For this block to execute, the NFTs need to be minted up to the point where newId == sale.finalId
. If this doesn't happen, this block can never execute.
There are only two ways for this to happen:
cancel()
is called, which can only happen before a sale starts.If the latter doesn't happen, the funds will be stuck.
[Note that this doesn't impact refunds, as users will be able to call refund()
at any time to get their refund up to the current price.]
Manual Review
Add an end()
function that can be called after the endTime
to finalize the LPDA and process the payouts.
#0 - c4-judge
2022-12-12T09:04:35Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:21:08Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-02T20:22:56Z
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
After an LPDA auction is ended, the final price is locked in as follows:
sale.finalPrice = uint80(price);
This unsafe downcasting casts price into an 80 bit integer. This may be sufficient for most situations, but only represents 1.208MM ETH, which is not out of the question for how much an NFT might one day sell for.
If an NFT were to sell for 1.209MM ETH:
price = 1209 * 10 ** 18;
sale.finalPrice = uint80(price);
Manual Review
Use safe downcasting by checking that the value of price is less than the max uint80 value before downcasting:
require(price <= type(uint80).max, "CANT DOWNCAST");
This option would stop any NFTs from being purchased above this price. A simplier solution might be to rearrange the storage struct so that finalPrice
is sized at uin96, which would fit within the existing slot and not cost any extra gas.
#0 - c4-judge
2022-12-10T17:12:55Z
berndartmueller marked the issue as duplicate of #369
#1 - c4-judge
2023-01-03T13:56:43Z
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
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEdition.sol#L122 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L110
The selfdestruct
opcode may be phased out. This possibility is discussed in EIP4760 (written by Vitalik and Dankrad).
This opcode is used to send Eth to the saleReceiver
, which risks changes to the network invalidating the immutable implementation contracts deployed by Escher.
The _end()
function in both OpenEdition.sol and FixedPrice.sol both use selfdestruct()
to send their remaining funds to the saleReceiver
.
Manual Review
Use a low level call to transfer funds to the saleReceiver
instead of self destructing.
#0 - berndartmueller
2022-12-11T18:14:11Z
Downgrading to QA (Low). See https://github.com/code-423n4/2022-12-escher-findings/issues/506#issuecomment-1345621194
#1 - c4-judge
2022-12-11T18:14:20Z
berndartmueller changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-01-04T10:46:53Z
berndartmueller marked the issue as grade-b