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: 35/109
Findings: 2
Award: $525.56
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: zzykxx
Also found by: 8olidity, IllIllI, Lambda, berndartmueller, bytehat, devtooligan, hansfriese, imare, obront, philogy, shung, tonisives, zdhu, zkhorse
The owner
of the ArtGobblers
contract has the authority to upgrade the randomness provider, and a comment notes that this functionality may be "useful if current VRF is sunset". However, if the owner attempts to update the randomness provider while there is a pending request for randomness tracked in gobblerRevealsData.waitingForSeed
, the call will revert with a SeedPending
error:
ArtGobblers#upgradeRandProvider
/// @notice Upgrade the rand provider contract. Useful if current VRF is sunset. /// @param newRandProvider The new randomness provider contract address. function upgradeRandProvider(RandProvider newRandProvider) external onlyOwner { // Revert if waiting for seed, so we don't interrupt requests in flight. if (gobblerRevealsData.waitingForSeed) revert SeedPending(); randProvider = newRandProvider; // Update the randomness provider. emit RandProviderUpgraded(msg.sender, newRandProvider); }
Gobbler reveals are locked while the seed is pending. Only a callback from the current randomness provider to acceptRandomSeed
can clear waitingForSeed
and unlock reveals:
/// @notice Callback from rand provider. Sets randomSeed. Can only be called by the rand provider. /// @param randomness The 256 bits of verifiable randomness provided by the rand provider. function acceptRandomSeed(bytes32, uint256 randomness) external { // The caller must be the randomness provider, revert in the case it's not. if (msg.sender != address(randProvider)) revert NotRandProvider(); // The unchecked cast to uint64 is equivalent to moduloing the randomness by 2**64. gobblerRevealsData.randomSeed = uint64(randomness); // 64 bits of randomness is plenty. gobblerRevealsData.waitingForSeed = false; // We have the seed now, open up reveals. emit RandomnessFulfilled(randomness); }
Although admittedly very unlikely, it's possible that reveals may be permanently blocked if the acceptRandomSeed
callback never arrives from the randomness provider:
ArtGobblers#requestRandomSeed
, which sets waitingForSeed
to true
.acceptRandomSeed
is never called.upgradeRandProvider
and replace it.Gobbler reveals will be permanently bricked. We consider this extremely low likelihood, but very high impact, so we are reporting it as a Medium.
Consider tracking the last revealed timestamp and adding a timeout to the upgrade logic. (Or potentially, using the already stored nextRevealTimestamp
to calculate a timeout). If this timeout expires and the randomness provider callback has not been received, allow the contract owner to change the provider.
#0 - Shungy
2022-09-28T15:06:00Z
#1 - GalloDaSballo
2022-09-29T21:29:49Z
🌟 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
55.1985 USDC - $55.20
ArtGobblers
and Pages
: Token mints do not use safe transfersFunctions that mint tokens in ArtGobblers
and Pages
(ArtGobblers#claimGobbler
, ArtGobblers#mintFromGoo
, ArtGobblers#mintLegendaryGobbler
, Pages#mintFromGoo
, Pages#mintCommunityPages
) do not check that the recipient address supports ERC721 token transfers. If this is a concern, consider using _safeMint
to mint tokens.
ArtGobblers
: Zero goo amounts may be added/removed from balancesIt's possible to call addGoo
and removeGoo
with a zero gooAmount
:
/// @notice Add goo to your emission balance, /// burning the corresponding ERC20 balance. /// @param gooAmount The amount of goo to add. function addGoo(uint256 gooAmount) external { // Burn goo being added to gobbler. goo.burnForGobblers(msg.sender, gooAmount); // Increase msg.sender's virtual goo balance. updateUserGooBalance(msg.sender, gooAmount, GooBalanceUpdateType.INCREASE); } /// @notice Remove goo from your emission balance, and /// add the corresponding amount to your ERC20 balance. /// @param gooAmount The amount of goo to remove. function removeGoo(uint256 gooAmount) external { // Decrease msg.sender's virtual goo balance. updateUserGooBalance(msg.sender, gooAmount, GooBalanceUpdateType.DECREASE); // Mint the corresponding amount of ERC20 goo. goo.mintForGobblers(msg.sender, gooAmount); }
This will make an internal call to updateUsergooBalance
and emit a GooBalanceUpdated
event.
/// @notice Update a user's virtual goo balance. /// @param user The user whose virtual goo balance we should update. /// @param gooAmount The amount of goo to update the user's virtual balance by. /// @param updateType Whether to increase or decrease the user's balance by gooAmount. function updateUserGooBalance( address user, uint256 gooAmount, GooBalanceUpdateType updateType ) internal { // Will revert due to underflow if we're decreasing by more than the user's current balance. // Don't need to do checked addition in the increase case, but we do it anyway for convenience. uint256 updatedBalance = updateType == GooBalanceUpdateType.INCREASE ? gooBalance(user) + gooAmount : gooBalance(user) - gooAmount; // Snapshot the user's new goo balance with the current timestamp. getUserData[user].lastBalance = uint128(updatedBalance); getUserData[user].lastTimestamp = uint64(block.timestamp); emit GooBalanceUpdated(user, updatedBalance); }
Since this wastes gas for the caller and emits a confusing event, consider checking for zero amounts in addGoo
and removeGoo
.
Additionally, we found that calling addGoo(0)
repeatedly and frequently at intervals of less than 1 day can cause a user's goo balance to be lower than expected. We didn't see a clear path to an exploit here, since the error penalizes rather than rewards the caller, but it is unusual behavior that may deserve a closer look. See the test below, which passes with TIMESTEP_OK
and fails with TIMESTEP_FAIL
.
/// @notice make sure that actions that trigger balance snapshotting do not affect total balance. function testSnapshotDoesNotAffectBalanceFuzzed() public { //mint one gobbler for each user mintGobblerToAddress(users[0], 1); mintGobblerToAddress(users[1], 1); vm.warp(block.timestamp + 1 days); //give user initial goo balance vm.prank(address(gobblers)); goo.mintForGobblers(users[0], 100); //reveal gobblers bytes32 requestId = gobblers.requestRandomSeed(); uint256 randomness = 1022; // magic seed to ensure both gobblers have same multiplier vrfCoordinator.callBackWithRandomness(requestId, randomness, address(randProvider)); gobblers.revealGobblers(2); //make sure both gobblers have same multiple, and same starting balance assertGt(gobblers.getUserEmissionMultiple(users[0]), 0); assertEq(gobblers.getUserEmissionMultiple(users[0]), gobblers.getUserEmissionMultiple(users[1])); assertEq(gobblers.gooBalance(users[0]), gobblers.gooBalance(users[1])); uint256 TIMESTEP_OK = 1 days; uint256 TIMESTEP_FAIL = 1 days - 1; unchecked { for (uint256 n = 0; n < 1000; n += 12) { vm.warp(block.timestamp + TIMESTEP_OK); vm.prank(users[0]); gobblers.addGoo(0); console.log("balance1 - balance0 =", gobblers.gooBalance(users[1]) - gobblers.gooBalance(users[0])); } } // make sure users have equal balance unchecked { assertEq(0, gobblers.gooBalance(users[1]) - gobblers.gooBalance(users[0])); } }
ArtGobblers
: Gobbling ignores token approvalsThe caller of ArtGobblers#gobble
must be the owner of the Gobbler being fed:
// The caller must own the gobbler they're feeding. if (owner != msg.sender) revert OwnerMismatch(owner);
However, this limits composability. Consider adopting semantics similar to isApprovedForAll
to allow third parties to feed a Gobbler with owner approval.
DeployBase
: Double check wallet addressAccording to the Art Gobblers whitepaper,
No Pages accrue directly to the team, but one in ten newly created pages do go to a vault to be distributed to artists in the community.
However, in DeployBase.s.sol
, the Pages
contract is deployed with teamColdWallet
as the community
argument:
// Deploy pages contract. pages = new Pages(mintStart, goo, teamColdWallet, artGobblers, pagesBaseUri);
Double check whether you're passing the correct parameter here.
Pages
and GobblerReserve
: Prefer preincrementWe have not submitted a separate gas report, but we consider it a sacred duty to point out the following two usages of i++
that could instead use ++i
:
// Mint the pages to the community reserve while updating lastMintedPageId. for (uint256 i = 0; i < numPages; i++) { _mint(community, ++lastMintedPageId); }
/// @notice Withdraw gobblers from the reserve. /// @param to The address to transfer the gobblers to. /// @param ids The ids of the gobblers to transfer. function withdraw(address to, uint256[] calldata ids) external onlyOwner { // This is quite inefficient, but it's okay because this is not a hot path. unchecked { for (uint256 i = 0; i < ids.length; i++) { artGobblers.transferFrom(address(this), to, ids[i]); } } }
#0 - GalloDaSballo
2022-10-06T19:25:18Z
L
Refactoring because well thought out but I think it's Non-Critical / Informational R
R
R
Well done champion of the pre-increment
1L 3R