Unbounded Iteration Over Global Token Supply in getUserMemorabiliaDetailed
Can Break Functionality for Active Users
Description
The getUserMemorabiliaDetailed
function is designed to return arrays of token IDs, collection IDs, and item IDs for all memorabilia owned by a specific user.
The function exhibits unbounded gas consumption that scales linearly with the number of memorabilia items owned by a user, potentially causing transaction failures or making the function unusable for users with large collections.
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++;
}
}
}
return (tokenIds, collectionIds, itemIds);
}
Risk
Likelihood:
Active users who redeem memorabilia regularly will accumulate large collections over time, making this issue inevitable for engaged users
The gas cost increases linearly with both the total number of tokens minted globally (nextTokenId) and the user's collection size, affecting all users as the platform grows
Impact:
Users with large memorabilia collections will be unable to call this function due to block gas limits, effectively breaking core functionality
Frontend applications relying on this function will fail to load user data, creating poor user experience and potential application crashes
As the platform scales and more tokens are minted globally, even users with small collections may experience high gas costs
Proof of Concept
function test_GetUserMemorabiliaDetailed_GasConsumption() public {
vm.startPrank(organizer);
uint256 col1 = festivalPass.createMemorabiliaCollection("Col1", "ipfs://1", 1e18, 100, true);
uint256 col2 = festivalPass.createMemorabiliaCollection("Col2", "ipfs://2", 1e18, 100, true);
vm.stopPrank();
vm.prank(address(festivalPass));
beatToken.mint(user1, 100000e18);
vm.startPrank(user1);
festivalPass.redeemMemorabilia(col1);
festivalPass.redeemMemorabilia(col1);
festivalPass.redeemMemorabilia(col2);
vm.stopPrank();
uint256 gasBefore = gasleft();
(uint256[] memory tokenIds1, uint256[] memory collectionIds1, uint256[] memory itemIds1)
= festivalPass.getUserMemorabiliaDetailed(user1);
uint256 gasAfter = gasleft();
uint256 gasUsedSmall = gasBefore - gasAfter;
console.log("=== SMALL SCALE TEST ===");
console.log("Gas used:", gasUsedSmall);
console.log("");
assertEq(tokenIds1.length, 3);
assertEq(collectionIds1[0], col1);
assertEq(itemIds1[0], 1);
assertEq(collectionIds1[1], col1);
assertEq(itemIds1[1], 2);
assertEq(collectionIds1[2], col2);
assertEq(itemIds1[2], 1);
vm.startPrank(organizer);
for(uint256 i = 3; i <= 10; i++) {
uint256 newCol = festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Col", vm.toString(i))),
string(abi.encodePacked("ipfs://", vm.toString(i))),
1e18,
100,
true
);
}
vm.stopPrank();
vm.startPrank(user1);
for (uint256 i = 100; i <= 109; i++) {
for (uint256 j = 0; j < 90; j++) {
festivalPass.redeemMemorabilia(i);
}
}
vm.stopPrank();
gasBefore = gasleft();
(uint256[] memory tokenIds3, uint256[] memory collectionIds3, uint256[] memory itemIds3)
= festivalPass.getUserMemorabiliaDetailed(user1);
gasAfter = gasleft();
uint256 gasUsedLarge = gasBefore - gasAfter;
console.log("=== LARGE SCALE TEST ===");
console.log("Gas used:", gasUsedLarge);
console.log("");
}
Recommended Mitigation
+ // Add mapping to track user tokens efficiently
+ mapping(address => uint256[]) private userTokens;
+ // Update userTokens mapping when minting
function _mint(address to, uint256 tokenId) internal override {
super._mint(to, tokenId);
+ userTokens[to].push(tokenId);
}
+ // Update userTokens mapping when transferring
function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override {
+ if (from != address(0)) {
+ // Remove from sender's array
+ uint256[] storage fromTokens = userTokens[from];
+ for (uint256 i = 0; i < fromTokens.length; i++) {
+ if (fromTokens[i] == tokenId) {
+ fromTokens[i] = fromTokens[fromTokens.length - 1];
+ fromTokens.pop();
+ break;
+ }
+ }
+ }
+ if (to != address(0)) {
+ // Add to receiver's array
+ userTokens[to].push(tokenId);
+ }
}
function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
- uint256 balance = balanceOf(user);
- tokenIds = new uint256[](balance);
- collectionIds = new uint256[](balance);
- itemIds = new uint256[](balance);
-
- uint256 index = 0;
- for (uint256 tokenId = 1; tokenId <= nextTokenId; tokenId++) {
- if (ownerOf(tokenId) == user) {
- tokenIds[index] = tokenId;
- collectionIds[index] = memorabiliaTokens[tokenId].collectionId;
- itemIds[index] = memorabiliaTokens[tokenId].itemId;
- index++;
- }
- }
+ uint256[] storage userTokenIds = userTokens[user];
+ uint256 length = userTokenIds.length;
+
+ tokenIds = new uint256[](length);
+ collectionIds = new uint256[](length);
+ itemIds = new uint256[](length);
+
+ for (uint256 i = 0; i < length; i++) {
+ uint256 tokenId = userTokenIds[i];
+ tokenIds[i] = tokenId;
+ collectionIds[i] = memorabiliaTokens[tokenId].collectionId;
+ itemIds[i] = memorabiliaTokens[tokenId].itemId;
+ }
}