Platform: Code4rena
Start Date: 20/09/2022
Pot Size: $100,000 USDC
Total HM: 4
Participants: 109
Period: 7 days
Judge: GalloDaSballo
Id: 163
League: ETH
Rank: 9/109
Findings: 3
Award: $3,540.59
๐ Selected for report: 0
๐ Solo Findings: 0
๐ Selected for report: wagmi
Also found by: 0x52, CertoraInc, Lambda, RaymondFam, arcoun
https://github.com/code-423n4/2022-09-artgobblers/blob/d2087c5a8a6a4f1b9784520e7fe75afa3a9cbdbe/src/ArtGobblers.sol#L458 https://github.com/code-423n4/2022-09-artgobblers/blob/d2087c5a8a6a4f1b9784520e7fe75afa3a9cbdbe/src/utils/token/GobblersERC721.sol#L166
When minting a legendary token (in the ArtGobblers.mintLegendaryGobbler()
function), the value of getUserData[msg.sender].gobblersOwned
is updated to be getUserData[msg.sender].gobblersOwned - cost + 1
. This is done to reduce all the burnt token and add the new legendary gobbler. However, later in this function there's a call to the _mint
function, which also adds 1 to the getUserData[msg.sender].gobblersOwned
.
This causes the getUserData[msg.sender].gobblersOwned
value to decrease by 1 less than it should. This messes up the accounting, as the balance of the user who minted the legendary gobbler will be 1 more than the actual amount of gobblers he owns.
I wrote the PoC here as a foundry test, showing that the balance of the legendary gobbler minter decreases by cost - 2
instead of decreasing by cost - 1
.
function testWrongBalanceAccounting() public { uint256 startTime = block.timestamp + 30 days; vm.warp(startTime); // Mint full interval to kick off first auction. mintGobblerToAddress(users[0], gobblers.LEGENDARY_AUCTION_INTERVAL()); uint256 cost = gobblers.legendaryGobblerPrice(); // Prepare the ids array for (uint256 curId = 1; curId <= cost; curId++) { ids.push(curId); } // Record the balance before the mint uint balanceBefore = gobblers.balanceOf(users[0]); // Mint the legendary gobbler vm.prank(users[0]); uint256 mintedLegendaryId = gobblers.mintLegendaryGobbler(ids); // Legendary is owned by user. assertEq(gobblers.ownerOf(mintedLegendaryId), users[0]); // Record the balance after the mint uint balanceAfter = gobblers.balanceOf(users[0]); assertEq(balanceAfter, balanceBefore - cost + 2); console.log(balanceBefore); console.log(balanceAfter); }
The output:
Running 1 test for test/ArtGobblers.t.sol:ArtGobblersTest [PASS] testWrongBalanceAccounting() (gas: 39395768) Logs: 581 514 Test result: ok. 1 passed; 0 failed; finished in 28.98ms
Manual audit & foundry for the PoC
Remove one of the additions, the one in the mintLegendaryGobbler
will be easier to remove, however if you want to reduce the gas cost (save some SLOADs) a different solution can be applied.
#0 - Shungy
2022-09-28T14:37:16Z
#1 - GalloDaSballo
2022-09-29T20:35:51Z
๐ Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0x52, 0x5rings, 0xNazgul, 0xRobocop, 0xSmartContract, 0xdeadbeef, 0xsanson, 8olidity, Amithuddar, Aymen0909, B2, B353N, CertoraInc, Ch_301, Chom, CodingNameKiki, Deivitto, ElKu, Funen, JC, JohnnyTime, Kresh, Lambda, Noah3o6, RaymondFam, ReyAdmirado, RockingMiles, Rolezn, Sm4rty, SuldaanBeegsi, Tadashi, TomJ, Tomio, V_B, Waze, __141345__, a12jmx, ak1, arcoun, asutorufos, aviggiano, berndartmueller, bharg4v, bin2chen, brgltd, bulej93, c3phas, catchup, cccz, ch0bu, cryptonue, cryptphi, csanuragjain, delfin454000, devtooligan, djxploit, durianSausage, eighty, erictee, exd0tpy, fatherOfBlocks, giovannidisiena, hansfriese, ignacio, joestakey, ladboy233, lukris02, m9800, malinariy, martin, minhtrng, obront, oyc_109, pedr02b2, pedroais, pfapostol, philogy, prasantgupta52, rbserver, ronnyx2017, rotcivegaf, rvierdiiev, sach1r0, shung, simon135, throttle, tnevler, tonisives, wagmi, yixxas, zkhorse, zzykxx, zzzitron
696.8956 USDC - $696.90
ArtGobblers.claimGobbler()
function allows each user to claim at most 1 gobbler. It will be better to extend this functionality to allow users to claim an amount of gobblers that will be specified in the merkle tree.mintFromGoo
in both of the ArtGobblers
and the Pages
contracts allows the users to pay goo from his virtual balance or from his actual goo balance. This functionality can be improved by allowing a user to use both if this balances - this will save the users from first adding goo to their virtual balance and then using it, or withdrawing goo from their virtual balance and then using their non virtual balance. This will of course relates to the case where the user doesn't have enough balance to pay in both his virtual and non-virtual balances.
// From ArtGobblers useVirtualBalance ? updateUserGooBalance(msg.sender, currentPrice, GooBalanceUpdateType.DECREASE) : goo.burnForGobblers(msg.sender, currentPrice); // From Pages useVirtualBalance ? artGobblers.burnGooForPages(msg.sender, currentPrice) : goo.burnForPages(msg.sender, currentPrice);
legendaryGobblerPrice
will return a value and won't revert even after all the legendary gobblers will be minted. This can confuse innocent users into thinking another legendary gobbler will be minted/is open for auction. This will happen in 2 unwanted cases - when all the legendary gobblers will be sold, and the value of numMintedFromGoo
will be either 6391 or 6392. 6392 is the maximum number of gobblers that can be sold (i.e. MAX_MINTABLE
), and when the numMintedFromGoo
value will be one of the two I mentioned before, the legendaryGobblerPrice
function will return unwanted values.
// For numMintedFromGoo = 6391, the return value will be FixedPointMathLib.unsafeDivUp(startPrice * 6391, 6392) = 99.98% of the start price // For numMintedFromGoo = 6392, the return value will be FixedPointMathLib.unsafeDivUp(startPrice * 6392, 6392) = the start price
acceptRandomSeed
function to verify that the contract is actually waiting for a seed. If somehow the chainlink oracle will call the function multiple times, this can be harmful to the contract.gobble
function doesn't allow an operator to feed the gobbler with an NFT. The current implementation reverts if owner != msg.sender
, but it shouldn't revert if the msg.sender
is an allowed operator, i.e. isApprovedForAll[owner][msg.sender] == true
.updateUserGooBalance
is an internal function but it doesn't have an underscore in the beginning of its name.getCopiesOfArtGobbledByGobbler
mapping becomes irrelevant. Consider saving the data differently to allow moving it to the legendary gobbler when it is burned.numGobblersEach
argument to the ArtGobblers.mintReservedGobblers()
function. This can also happen by providing numPages = 0
to the Pages.mintCommunityPages()
functionArtGobblers
contract is not an ERC721Receiver. It won't support gobbling of NFTs that their transferFrom function does the onERC721Received
callback.#0 - GalloDaSballo
2022-10-06T18:18:44Z
R
R
NC
L
R
Valid R
##ย Users can spam the logs by providing 0 as the numGobblersEach argument to the ArtGobblers.mintReservedGobblers() function. This can also happen by providing numPages = 0 to the Pages.mintCommunityPages() function Because the event fires, NC
##ย Missing zero address checks - the constructors don't validate the input, i.e. don't check that the given addresses are not zero. L
Invalid
NC
Pretty good!
2L 4R 2NC
#1 - transmissions11
2022-10-12T05:46:34Z
The ArtGobblers contract is not an ERC721Receiver. It won't support gobbling of NFTs that their transferFrom function does the onERC721Received callback.
I think this is a false positive? ERC721 transferFrom
doesnt invoke the callback, only safetransfer, which we explicitly dont use
#2 - GalloDaSballo
2022-10-12T20:54:32Z
The ArtGobblers contract is not an ERC721Receiver. It won't support gobbling of NFTs that their transferFrom function does the onERC721Received callback.
I think this is a false positive? ERC721
transferFrom
doesnt invoke the callback, only safetransfer, which we explicitly dont use
Agree that this is a false positive and believe I've closed the ones suggesting to add it, will double check
๐ Selected for report: IllIllI
Also found by: 0x1f8b, 0xNazgul, 0xSmartContract, Atarpara, CertoraInc, Deathstore, Deivitto, ElKu, MiloTruck, ReyAdmirado, SnowMan, Tadashi, V_B, __141345__, aviggiano, catchup, djxploit, gogo, pfapostol, philogy, shung
68.6605 USDC - $68.66
UNREVEALED_URI
and BASE_URI
, which can't be changed, can be marked as immutable to save gas when accessing them.mapping(uint256 => mapping(address => mapping(uint256 => uint256))) public getCopiesOfArtGobbledByGobbler;
mapping can be changed to simplify it. We know the gobbler id will be maximum 10000, so it can fit in a uint96
. That means that instead of mapping a gobbler id to an address of an nft, we can group them together and create the new mapping(uint256 => mapping(uint256 => uint256))) public getCopiesOfArtGobbledByGobbler;
, where the first uint256 is the gobbler id and the nft contract's address grouped ((gobblerId << 160) | nft
).toWadUnsafe(MAX_MINTABLE)
in the ArtGobblers
contract pre deployment so it will save gas on deployment.legendaryGobblerAuctionData.numSold
, getGobblerData[id]
in the ArtGobblers.mintLegendaryGobbler
ArtGobblers.legendaryGobblerPrice
instead of accessing numMintedFromGoo
again (uint256 numMintedSinceStart = numMintedFromGoo - numMintedAtStart;
=> uint256 numMintedSinceStart = mintedFromGoo - numMintedAtStart;
)getGobblerData[swapId].idx
and getGobblerData[currentId].idx
in the ArtGobblers.revealGobblers
function. The optimized code:
uint idx = getGobblerData[swapId].idx; uint64 swapIndex = idx == 0 ? uint64(swapId) // Hasn't been shuffled before. : idx; // Shuffled before. // Get the owner of the current id. address currentIdOwner = getGobblerData[currentId].owner; // Get the index of the current id. idx = getGobblerData[currentId].idx; uint64 currentIndex = idx == 0 ? uint64(currentId) // Hasn't been shuffled before. : idx; // Shuffled before.
legendaryGobblerAuctionData.startPrice = uint120(cost <= LEGENDARY_GOBBLER_INITIAL_START_PRICE / 2 ? LEGENDARY_GOBBLER_INITIAL_START_PRICE : cost * 2);
in the ArtGobblers.mintLegendaryGobbler
function by:
cost <= LEGENDARY_GOBBLER_INITIAL_START_PRICE / 2
to 2 * cost <= LEGENDARY_GOBBLER_INITIAL_START_PRICE
2 * cost
to cost << 1
cost << 1
instead of calculating it twice
The final expression:uint doubleCost = cost << 1; legendaryGobblerAuctionData.startPrice = uint120(doubleCost <= LEGENDARY_GOBBLER_INITIAL_START_PRICE ? LEGENDARY_GOBBLER_INITIAL_START_PRICE : doubleCost);
ArtGobblers.mintLegendaryGobbler
function (getGobblerData[gobblerId].emissionMultiple = uint32(burnedMultipleTotal * 2)
=> getGobblerData[gobblerId].emissionMultiple = uint32(burnedMultipleTotal << 1)
)ArtGobblers.mintLegendaryGobbler
function - legendaryGobblerAuctionData.numSold += 1
=> ++legendaryGobblerAuctionData.numSold
getUserData[from].gobblersOwned -= 1;
=> --getUserData[from].gobblersOwned;
getUserData[to].gobblersOwned += 1;
=> ++getUserData[to].gobblersOwned;
FIRST_LEGENDARY_GOBBLER_ID - 1
in every iteration of the loop in ArtGobblers.revealGobblers
& (2 ** 64 - 1)
instead of % 2 ** 64
. (randomSeed := mod(keccak256(0, 32), shl(64, 1))
=> randomSeed := and(keccak256(0, 32), 0xffffffffffffffff)
)newNumMintedForReserves > (numMintedFromGoo + newNumMintedForReserves) / 5
5 * newNumMintedForReserves > numMintedFromGoo + newNumMintedForReserves
4 * newNumMintedForReserves > numMintedFromGoo
(newNumMintedForReserves << 2) > numMintedFromGoo
#0 - GalloDaSballo
2022-10-06T01:26:10Z
4.2k
This saves the keccak cost, at the cost of packing, so prob 20 gas
400 gas from SLAODs
We know shift wont' save gas (sponsor feedback) 3 gas from caching the other value
Prefix is 11 per instance = 33
Neat unique report, would recommend using bigger blocks for code, but really nice jbo!
4636
#1 - transmissions11
2022-10-12T05:41:29Z
Decided to selectively implement some of them. Lots of clever and good optimizations but not all are worth the readability costs.