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: 43/119
Findings: 3
Award: $66.79
🌟 Selected for report: 0
🚀 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.6136 USDC - $0.61
The use of transfer() will revert the transaction when the receiver is a contract and when one of the following conditions is met:
Step 1. Create a contract without a payable callback
contract ReceiverContract { }
Step 2. Create a LPDA.Sale with the saleReceiver parameter setted up to the example contract ReceiverContract
contract LPDABase is EscherTest { LPDAFactory public lpdaSales; LPDA.Sale public lpdaSale; ReceiverContract public receiverTest; function setUp() public virtual override { super.setUp(); lpdaSales = new LPDAFactory(); receiverTest = new ReceiverContract(); // set up a LPDA Sale lpdaSale = LPDA.Sale({ currentId: uint48(0), finalId: uint48(1), 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(receiverTest)), endTime: uint96(block.timestamp + 1 days) }); } }
Step 3. Create a situation where the function buy execute the sentence temp.saleReceiver.transfer(totalSale - fee); in line 86 function buy
contract LDPAPayableTest is LPDABase { LPDA public sale; function test_pay_LPDA() public { // make the lpda sales contract sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); //lets buy an NFT sale.buy{value: 1 ether}(1); } }
Step 4. The transaction will revert. [FAIL. Reason: EvmError: Revert] test_pay_LPDA() (gas: 400569)
forge test -vvvvv
Use call() instead of transfer(). Add reentrancy guard to avoid reentrancy attack.
#0 - c4-judge
2022-12-10T12:07:49Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:49:37Z
berndartmueller marked the issue as satisfactory
🌟 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
The use of payable.transfer() is heavily frowned upon because it can lead to the locking of funds. The transfer() call requires that the recipient has a payable callback, only provides 2300 gas for its operation. This means the following cases can cause the transfer to fail:
If a user falls into one of the above categories, they'll be unable to receive funds from the vault in a refund. Inaccessible funds means loss of funds, which is Medium severity.
refund() use payable.transfer(). While use msg.sender, the funds are tied to the address that deposited them (line 100), and there is no mechanism to change the owner of the funds to an alternate address.
function refund() public { Receipt memory r = receipts[msg.sender]; uint80 price = uint80(getPrice()) * r.amount; uint80 owed = r.balance - price; require(owed > 0, "NOTHING TO REFUND"); receipts[msg.sender].balance = price; payable(msg.sender).transfer(owed); }
Manual inspection
Use address.call{value:x}() instead
#0 - c4-judge
2022-12-10T00:29:15Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:44:39Z
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
#Index
initialize() function can be called anybody when the contract is not initialized.
More importantly, if someone else runs this function, they will have full authority because of the __AccessControl_init() function. Also, there is no 0 address check in the address arguments of the initialize() function, which must be defined.
Contracts have the pragma solidity directive ^0.8.17. It is recommended to specify a fixed compiler version to ensure that the bytecode produced does not vary between builds. This is especially important if you rely on bytecode-level verification of the code.
Lock the pragma.
Unique.sol#L2 Base.sol#L2 Generative.sol#L2 Escher721Factory.sol#L2 FixedPriceFactory.sol#L2 OpenEditionFactory.sol#L2 LPDAFactory.sol#L2 Escher.sol#L2 Escher721.sol#L2 FixedPrice.sol#L2 OpenEdition.sol#L2 LPDA.sol#L2
Move structs before the state variables
LPDA.sol#L13 LPDA.sol#L29 FixedPrice.sol#L14 OpenEdition.sol#L13
According with solidity documentation link If return parameters are declared must be added to NatSpec. Some code analysis use NatSpec for analysis the code.
Add /// @return to function with return parameter
Base.sol#L14 Generative.sol#L27 Unique.sol#L10 OpenEdition.sol#L97 OpenEdition.sol#L102 OpenEdition.sol#L107 OpenEdition.sol#L112 OpenEdition.sol#L116 OpenEditionFactory.sol#L29 LPDA.sol#L117 LPDA.sol#L128 LPDA.sol#L133 LPDA.sol#L138 LPDA.sol#L142 LPDAFactory.sol#L29 FixedPriceFactory.sol#L29 Escher721Factory.sol#L27 Escher721.sol#L78 Escher721.sol#L84 Escher.sol#L31
Unique.sol Base.sol Generative.sol Escher721Factory.sol FixedPriceFactory.sol OpenEditionFactory.sol LPDAFactory.sol Escher.sol Escher721.sol FixedPrice.sol OpenEdition.sol LPDA.sol
The code should be refactored such that they no longer exist, or the block should do something useful, such as emitting an event or reverting.
Solidity versions higher 0.8 don't prevent integet overflow for casting operations just in mathematical operations.
Use a safeCast from OpenZeppelin
LPDA.sol#L59 LPDA.sol#L82 LPDA.sol#L101 OpenEdition.sol#L58 FixedPrice.sol#L62 Escher.sol#L61 Escher.sol#L63 Escher.sol#L69
#0 - c4-judge
2023-01-04T10:37:44Z
berndartmueller marked the issue as grade-b
🌟 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
It's possible to save gas if move the second if to the beginning. There are situations where that if is true and it's not necessary to assig value to start & end local variables or evaluate the first if (block.timestamp < start).
function getPrice() public view returns (uint256) { Sale memory temp = sale; + if (temp.currentId == temp.finalId) return temp.finalPrice; (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); }
When we are working with state variables is cheaper to use I = I + X than I += X.
Original Code Running 14 tests for test/LPDA.t.sol:LPDATest [PASS] test_Buy() (gas: 381183)
Code after apply changes Running 14 tests for test/LPDA.t.sol:LPDATest [PASS] test_Buy() (gas: 381141)
There is 42 gas saved with this change for each instance. Due to there are 3 instances the gas saved is 126
LPDA.sol#L66 LPDA.sol#L70 LPDA.sol#L71
The declaration of the variable is made at the beginning of the function while its use is made later on. Between the declaration of the variable and its use, there are several requirements that may result in the variable never being used. By moving the declaration after the requires and before it is used, gas can be saved in some circunstances.
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); + IEscher721 nft = IEscher721(temp.edition); for (uint256 x = temp.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); }
LPDA.sol#L58-L75 FixedPrice.sol#L59-L67
There are code where it's not possible to overflow/undeflow due to a IF or Require statement. In that case, it's possible to save gas adding the unckecked block.
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; + uint256 timeElapsed; + unchecked{ timeElapsed = end > block.timestamp ? block.timestamp - start : end - start;} return temp.startPrice - (temp.dropPerSecond * timeElapsed); }
Original Code
src/minters/LPDA.sol:LPDA contract | |||||
---|---|---|---|---|---|
Deployment Cost | Deployment Size | ||||
1252237 | 6103 | ||||
Function Name | min | avg | median | max | # calls |
buy | 1149 | 125350 | 101393 | 295596 | 20 |
cancel | 475 | 1912 | 627 | 4635 | 3 |
getPrice | 1501 | 1501 | 1501 | 1501 | 1 |
initialize | 143516 | 143516 | 143516 | 143516 | 15 |
refund | 2148 | 7221 | 9174 | 9342 | 6 |
Code after apply changes
src/minters/LPDA.sol:LPDA contract | |||||
---|---|---|---|---|---|
Deployment Cost | Deployment Size | ||||
1249637 | 6090 | ||||
Function Name | min | avg | median | max | # calls |
buy | 1149 | 125291 | 101328 | 295530 | 20 |
cancel | 475 | 1912 | 627 | 4635 | 3 |
getPrice | 1435 | 1435 | 1435 | 1435 | 1 |
initialize | 143516 | 143516 | 143516 | 143516 | 15 |
refund | 2082 | 7166 | 9142 | 9276 | 6 |
There is 66 of gas saved directly in getPrice due to the change. Due to getPrice is a use in other functions (buy and refund) there is a major gas saved because of each time this functions use getPrice 66 of gas is saved.
Use unchecked { i++; } or unchecked{ ++i; } when it's not possible to overflow to save gas.
- for (uint256 x = temp.currentId + 1; x <= newId; x++) { + for (uint256 x = temp.currentId + 1; x <= newId; ) { nft.mint(msg.sender, x); + unchecked{x++;} }
Original code Running 14 tests for test/LPDA.t.sol:LPDATest [PASS] test_Buy() (gas: 381183)
Code after apply changes Running 14 tests for test/LPDA.t.sol:LPDATest [PASS] test_Buy() (gas: 381124)
In the example of execute the test_BUY without and with the change proposed it's possible to have 59 gas saved for instance.
#0 - c4-sponsor
2022-12-22T22:50:57Z
mehtaculous marked the issue as sponsor disputed
#1 - c4-judge
2023-01-04T10:58:58Z
berndartmueller marked the issue as grade-b