FestivalPass::getUserMemorabiliaDetailed function has unbounded gas consumption that can cause DoS
Description
The FestivalPass::getUserMemorabiliaDetailed function uses nested loops to iterate through ALL collections and ALL items ever created, regardless of whether the user owns them. This creates O(collections × items) complexity that grows unbounded as the platform scales.
The function iterates twice through the entire collection/item space - once to count, once to populate arrays - making it even more expensive.
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++;
}
}
}
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
@> for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
}
}
}
Risk
Likelihood:
Impact:
-
Any smart contract calling this function will run out of gas and revert, breaking integrations.
-
RPC nodes have execution limits for eth_call - the function will timeout or fail for frontends.
-
Users cannot retrieve their memorabilia data reliably, degrading UX.
Proof of Concept
Organizer creates multiple collections with items minted
As collections/items grow, gas cost increases quadratically
Eventually the function exceeds gas limits and becomes unusable
function testGetUserMemorabiliaDetailedDoS() public {
for (uint256 i = 0; i < 50; i++) {
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection", i)),
"ipfs://test",
10e18,
100,
true
);
address minter = makeAddr(string(abi.encodePacked("minter", i)));
vm.prank(address(festivalPass));
beatToken.mint(minter, 1000e18);
vm.prank(minter);
beatToken.approve(address(festivalPass), type(uint256).max);
for (uint256 j = 0; j < 10; j++) {
vm.prank(minter);
festivalPass.redeemMemorabilia(collectionId);
}
}
address user = makeAddr("user");
uint256 gasBefore = gasleft();
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used:", gasUsed);
}
Recommended Mitigation
Replace the unbounded iteration with user-specific tracking. Maintain a mapping of user addresses to their owned token IDs:
Note: This mitigation requires additional logic to handle transfers (override _update to maintain the mapping when tokens change hands).
+ mapping(address => uint256[]) private userMemorabilia;
function redeemMemorabilia(uint256 collectionId) external {
// ... existing logic ...
// Mint the unique NFT
_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[] memory owned = userMemorabilia[user];
+ uint256 length = owned.length;
+
+ tokenIds = new uint256[](length);
+ collectionIds = new uint256[](length);
+ itemIds = new uint256[](length);
+
+ for (uint256 i = 0; i < length; i++) {
+ tokenIds[i] = owned[i];
+ (collectionIds[i], itemIds[i]) = decodeTokenId(owned[i]);
+ }
+
+ return (tokenIds, collectionIds, itemIds);
+ }