Beatland Festival

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

Unbounded Nested Iteration Causes Denial of Service in `getUserMemorabiliaDetailed`

Unbounded Nested Iteration Causes Denial of Service in getUserMemorabiliaDetailed


Description

The getUserMemorabiliaDetailed function iterates over all existing memorabilia collections and, for each collection, over all minted items, in order to determine which NFTs are owned by a given user. This is done using two nested for loops, and the same iteration is performed twice: first to count the items and then to populate the return arrays.

function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
// First, count how many memorabilia they own
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++;
}
}
}
// Then populate arrays
tokenIds = new uint256[](count);
collectionIds = new uint256[](count);
itemIds = new uint256[](count);
uint256 index = 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) {
tokenIds[index] = tokenId;
collectionIds[index] = cId;
itemIds[index] = iId;
index++;
}
}
}
return (tokenIds, collectionIds, itemIds);
}

Both loop boundaries (nextCollectionId and collections[cId].currentItemId) are unbounded and fully dependent on protocol state, which can grow over time as new collections are created and items are minted. As a result, the total number of iterations grows multiplicatively with protocol usage.

Under realistic conditions — such as a moderate number of collections with multiple items each — the function can easily consume more gas than the block gas limit, making it impossible to execute successfully. Although the function is marked as view, it is still subject to gas limits when called on-chain (e.g., by other contracts) and can also fail in off-chain contexts that enforce gas caps.

This effectively renders the function unusable and creates a denial-of-service condition for consumers relying on it.


Risk

Likelihood: High

The protocol explicitly supports creating multiple collections and minting multiple unique items per collection, and there are no constraints or upper bounds limiting this growth. As the system is used as intended, the number of iterations in this function will inevitably increase, eventually reaching a point where the function exceeds practical gas limits.

Impact: Medium

While no funds are directly at risk, the function becomes non-functional and cannot be reliably called once the protocol reaches a certain scale. This breaks an important piece of user-facing functionality and prevents on-chain integrations or contracts from querying users’ memorabilia holdings, effectively causing a denial of service for this feature.


Proof of Concept

The following test demonstrates that getUserMemorabiliaDetailed becomes prohibitively expensive as the number of collections and minted items grows.

The test sets up multiple collections, mints nearly the maximum number of items in each collection to a single user, and then calls the affected function.

function test_DoS_getUserMemorabiliaDetailed() public {
uint256 collectionsCount = 25;
uint256 maxSupply = 20;
uint256[] memory collectionIds = new uint256[](collectionsCount);
vm.startPrank(organizer);
// Create multiple memorabilia collections
for (uint256 i = 0; i < collectionsCount; i++) {
uint256 collectionId = festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection-", vm.toString(i))),
"ipfs://baseURI",
1e18,
maxSupply,
true
);
collectionIds[i] = collectionId;
}
vm.stopPrank();
// Fund the user with BEAT tokens to allow redemptions
vm.prank(address(festivalPass));
beatToken.mint(user1, 1_000_000e18);
vm.startPrank(user1);
uint256 mintablePerCollection = maxSupply - 1;
// Redeem nearly all items from each collection
for (uint256 c = 0; c < collectionIds.length; c++) {
for (uint256 i = 0; i < mintablePerCollection; i++) {
festivalPass.redeemMemorabilia(collectionIds[c]);
}
}
vm.stopPrank();
// Call the function that iterates over all collections and items
festivalPass.getUserMemorabiliaDetailed(user1);
}

The test is executed using Foundry:

forge test --match-test test_DoS_getUserMemorabiliaDetailed

Output:

Ran 1 test for test/FestivalPass.t.sol:FestivalPassTest
[PASS] test_DoS_getUserMemorabiliaDetailed() (gas: 33378092)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 25.42ms (23.04ms CPU time)
Ran 1 test suite in 26.59ms (25.42ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

The reported gas usage exceeds 30 million gas, which is above typical block gas limits. This confirms that the function can become uncallable and demonstrates a denial-of-service condition.


Recommended Mitigation

The function should be redesigned to avoid iterating over all collections and items globally. The most effective mitigation is to maintain user-specific state that tracks which memorabilia tokens a user owns at mint time.

For example, the protocol can store an array or mapping of token IDs per user and update it during redeemMemorabilia. This would allow getUserMemorabiliaDetailed to iterate only over the user’s actual holdings, reducing complexity from unbounded global iteration to linear complexity relative to the user’s balance.

+ mapping(address => uint256[]) private userMemorabilia;
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
+ userMemorabilia[msg.sender].push(tokenId);
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
- 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++;
- }
- }
- }
-
- tokenIds = new uint256[](count);
- collectionIds = new uint256[](count);
- itemIds = new uint256[](count);
-
- uint256 index = 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) {
- tokenIds[index] = tokenId;
- collectionIds[index] = cId;
- itemIds[index] = iId;
- index++;
- }
- }
- }
+ uint256 length = userMemorabilia[user].length;
+
+ tokenIds = new uint256[](length);
+ collectionIds = new uint256[](length);
+ itemIds = new uint256[](length);
+
+ for (uint256 i = 0; i < length; i++) {
+ uint256 tokenId = userMemorabilia[user][i];
+ (uint256 collectionId, uint256 itemId) = decodeTokenId(tokenId);
+
+ tokenIds[i] = tokenId;
+ collectionIds[i] = collectionId;
+ itemIds[i] = itemId;
+ }
return (tokenIds, collectionIds, itemIds);
}
Updates

Lead Judging Commences

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