Beatland Festival

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

[M-2] Unbounded nested loop in getUserMemorabiliaDetailed causes out-of-gas reverts as the protocol scales

Root + Impact

Description

  • getUserMemorabiliaDetailed iterates over every collection ever created (outer loop) and every item within each collection (inner loop) to find what a given user owns. Both loop bounds grow indefinitely as more collections are added and more items are redeemed.

function getUserMemorabiliaDetailed(address user) external view returns (...) {
uint256 count = 0;
// @> outer loop grows with every new collection — unbounded
for (uint256 cId = 1; cId < nextCollectionId; cId++) {
// @> inner loop grows with every item redeemed in each collection — unbounded
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
...
// entire loop is run a second time to populate the arrays
}

Risk

Likelihood:

  • The function becomes uncallable once the cumulative number of collection-item combinations exceeds the block gas limit.

  • Growth is continuous; every new collection and every redemption permanently increases the cost.

Impact:

  • Front-ends and integrating contracts that rely on this function will break as the protocol grows.

  • Users lose the ability to enumerate their memorabilia on-chain; the data becomes inaccessible without off-chain indexing.

Proof of Concept

The following illustrates that at the festival scale (500 collections × 200 items each), the function's cumulative balanceOf reads exceed the block gas limit, causing it to revert permanently for every caller.

// Organizer creates 500 collections each with 200 items sold
// getUserMemorabiliaDetailed now iterates 500 * 200 = 100,000 iterations
// each iteration calls balanceOf (an ERC1155 storage read)
// total gas far exceeds the 30M block gas limit
// function permanently reverts for all callers
vm.expectRevert(); // out of gas
festivalPass.getUserMemorabiliaDetailed(user);

Recommended Mitigation

Replace the unbounded double loop with a paginated version that accepts offset and limit parameters so callers retrieve results in bounded batches. For production scale, off-chain indexing via TransferSingle/TransferBatch events is the preferred long-term approach, and on-chain enumeration should be eliminated entirely.

- function getUserMemorabiliaDetailed(address user) external view returns (
+ function getUserMemorabiliaDetailed(address user, uint256 offset, uint256 limit) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
+ uint256 end = offset + limit < nextCollectionId ? offset + limit : nextCollectionId;
uint256 count = 0;
- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
+ for (uint256 cId = offset + 1; cId < end; cId++) {
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) count++;
}
}
...
}
Updates

Lead Judging Commences

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