Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

# L-4: `getUserMemorabiliaDetailed` is unbounded in total collections × items

Root + Impact

getUserMemorabiliaDetailed performs a nested loop over every collection (1..nextCollectionId) and every item ever minted in each (1..currentItemId), calling balanceOf for each. The cost grows with all historical state, not with the user's holdings, so any on-chain consumer will eventually exceed the block gas limit and revert, even for a user who owns nothing. The loop also wastes 99 iterations on the reserved gap between pass IDs (1-3) and the first memorabilia collection ID (100).

Description

// src/FestivalPass.sol:268-276
uint256 count = 0;
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
@> for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) { count++; }
}
}

The same double loop is repeated a second time to populate the arrays (:284-294), doubling the cost. Collections start at nextCollectionId = 100 (:20), so cId = 1..99 are empty but still iterated.

Risk

Likelihood: Low. It does not affect the core protocol, only eth_call/on-chain integrators; an off-chain caller can raise the gas cap. It only becomes a hard failure on-chain after enough items are minted.

Impact: On-chain consumers (airdrops, marketplace listings, indexers that call on-chain) revert as the collection catalogue grows, degrading composability. No user funds are locked.

Proof of Concept

The test mints one item into each of N = 150 collections and measures the gas of getUserMemorabiliaDetailed for a wallet that owns nothing. The measured cost is ~938,645 gas at 150 items, rising roughly linearly; at ~5,000 minted items a single on-chain call exceeds a 30M block gas limit. This demonstrates the loop is bounded by total state rather than by the user's balance.

// test/Exploits.t.sol (add)
function test_GetUserMemorabiliaDetailedGasScalesWithState() public {
address nobody = makeAddr("nobody");
uint256 n = 150;
for (uint256 i = 0; i < n; i++) {
vm.prank(organizer);
uint256 col = fp.createMemorabiliaCollection("c", "ipfs://x", 1e18, 2, true);
address u = makeAddr(string(abi.encodePacked("m", i)));
vm.prank(address(fp));
beat.mint(u, 1e18);
vm.prank(u);
fp.redeemMemorabilia(col); // 1 item per collection
}
​
uint256 g0 = gasleft();
fp.getUserMemorabiliaDetailed(nobody);
uint256 used = g0 - gasleft();
​
// bug proof: gas for a zero-balance user grows with global state
assertGt(used, 200_000);
}

Recommended Mitigation

Track owned token IDs per user (e.g., an enumerable set or tokenId => owner index updated on mint/transfer) and return those directly, so cost scales with the user's holdings. At minimum, start the collection loop at the first real collection ID (100) to remove the wasted range.

- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
+ for (uint256 cId = 100; cId < nextCollectionId; cId++) {

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!