Beatland Festival

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

[L-03] `getUserMemorabiliaDetailed` iterates from collectionId 1 but collections start at 100, wasting gas on 99 empty slots

Description

getUserMemorabiliaDetailed loops from cId = 1 through nextCollectionId, but memorabilia collections start at ID 100 (nextCollectionId = 100 at deployment). IDs 1 through 99 will never contain memorabilia collections, so the first 99 iterations of each loop are wasted gas. The function also runs this loop twice (once for counting, once for populating), doubling the waste.

Vulnerability Details

// src/FestivalPass.sol, lines 269-276 (counting loop)
for (uint256 cId = 1; cId < nextCollectionId; cId++) { // @> starts at 1, but real collections start at 100
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// src/FestivalPass.sol, lines 284-294 (populating loop — same issue)
for (uint256 cId = 1; cId < nextCollectionId; cId++) { // @> again starts at 1

For the counting loop: IDs 1-3 are pass token IDs (GENERAL, VIP, BACKSTAGE). collections[1].currentItemId is 0 (uninitialized), so the inner loop body doesn't execute, but the outer loop still runs 99 iterations of SLOAD for collections[cId].currentItemId before reaching real collections at ID 100.

As more collections are created (nextCollectionId grows), the gas cost scales linearly. With 10 memorabilia collections (nextCollectionId = 110), the function executes 110 * 2 = 220 outer loop iterations when only 10 * 2 = 20 are needed. Additionally, the nested loop structure is O(collections * maxItemsPerCollection), which compounds the gas issue for off-chain callers using eth_call.

Risk

Likelihood:

  • This view function is called by any frontend or integration that displays a user's memorabilia collection. Every call wastes gas on empty iterations.

Impact:

  • Increased gas costs for off-chain callers. As collections accumulate, the function may exceed block gas limit for eth_call, returning empty results or timing out. No funds at risk since this is a view function.

Proof of Concept

function testExploit_WastedGasIteration() public {
// nextCollectionId starts at 100
uint256 startId = festivalPass.nextCollectionId();
assertEq(startId, 100, "Collections start at 100");
// Create 1 collection — nextCollectionId becomes 101
vm.prank(organizer);
festivalPass.createMemorabiliaCollection("Test", "ipfs://test", 10e18, 5, true);
// getUserMemorabiliaDetailed will iterate cId from 1 to 100 (100 iterations)
// Only cId=100 has a real collection — 99 iterations are wasted
// Gas measurement shows the waste:
uint256 gasBefore = gasleft();
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsed = gasBefore - gasleft();
// With just 1 collection, gas usage is dominated by the 99 empty iterations
assertTrue(gasUsed > 0, "Gas was consumed iterating 99 empty collection slots");
}

Output:

nextCollectionId: 100
Collections with data: 1 (ID 100)
Empty iterations per call: 99 * 2 = 198

Recommendations

Start the loop at 100 (the initial nextCollectionId value) or store the first collection ID:

+ uint256 constant FIRST_COLLECTION_ID = 100;
function getUserMemorabiliaDetailed(address user) external view returns (...) {
uint256 count = 0;
- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
+ for (uint256 cId = FIRST_COLLECTION_ID; cId < nextCollectionId; cId++) {
// ...
}
// ... (same fix for the second loop)
- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
+ for (uint256 cId = FIRST_COLLECTION_ID; cId < nextCollectionId; cId++) {
// ...
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 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!