uri() constructs a metadata URL for any memorabilia token whose collection exists, without checking whether the specific item ID within that collection was ever minted. Items are assigned sequential IDs starting at 1; currentItemId tracks the next available slot.
A caller can request the URI for item ID 99 in a collection where only 2 items have been minted (currentItemId == 3). The function returns a well-formed URL, giving off-chain systems — marketplaces, indexers, wallets — the false impression that item 99 exists.
Likelihood:
Any external system that iterates token IDs or queries URIs speculatively will hit this. NFT marketplaces commonly do this to pre-populate listings.
Impact:
No funds are at risk. The inconsistency between balanceOf() (returns 0 for unminted items) and uri() (returns a URL) breaks the implicit contract that a valid URI implies a minted token. Marketplaces may display ghost listings; indexers may cache stale metadata.
A collection is created and 2 items are minted. uri() returns valid metadata strings for item IDs 3 and 6, which were never minted and have zero balance for all addresses.
The URI is returned despite the item having zero supply, confirming the missing bounds check.
Validate that the item ID falls within the minted range before returning a URI:
### Description The `uri` function returns metadata URLs for any token ID that belongs to an existing collection, even if the specific item within that collection was never minted. This creates confusion about which tokens actually exist and can cause integration issues with external systems that rely on URI responses to determine token validity. ### Root Cause The URI function only validates that the collection exists but doesn't verify that the specific item was actually minted: ```solidity function uri(uint256 tokenId) public view override returns (string memory) { // Handle regular passes (IDs 1-3) if (tokenId <= BACKSTAGE_PASS) { return string(abi.encodePacked("ipfs://beatdrop/", Strings.toString(tokenId))); } // Decode collection and item IDs (uint256 collectionId, uint256 itemId) = decodeTokenId(tokenId); // Check if it's a valid memorabilia token if (collections[collectionId].priceInBeat > 0) { // ❌ Returns URI even for non-existent items! return string(abi.encodePacked( collections[collectionId].baseUri, "/metadata/", Strings.toString(itemId) )); } return super.uri(tokenId); } ``` The function should also verify that `itemId` is within the range of actually minted items (`itemId > 0 && itemId < collections[collectionId].currentItemId`). ### Risk **Likelihood**: Medium - Any external system querying URIs for memorabilia tokens can encounter this issue when checking non-existent item IDs. **Impact**: Low - No funds are at risk, but metadata integrity is compromised and external integrations may be confused. ### Impact * External systems receive metadata URLs for tokens that were never minted * NFT marketplaces might display non-existent items as available * Inconsistent behavior between `balanceOf()` (returns 0 for non-existent tokens) and `uri()` (returns metadata) * Confusion about which items in a collection actually exist * Potential integration failures with systems expecting URI calls to fail for non-existent tokens ### Proof of Concept This test demonstrates how the URI function returns metadata for items that were never minted: ```solidity function test_URIReturnsInvalidMetadataForNonExistentItems() public { // Organizer creates a collection with maxSupply = 5 vm.prank(organizer); uint256 collectionId = festivalPass.createMemorabiliaCollection( "Test Collection", "ipfs://testbase", 50e18, 5, // maxSupply = 5 true ); // Give user BEAT tokens and let them redeem 2 items vm.prank(address(festivalPass)); beatToken.mint(user1, 200e18); // User redeems 2 items (itemIds 1 and 2) vm.startPrank(user1); festivalPass.redeemMemorabilia(collectionId); // Item 1 festivalPass.redeemMemorabilia(collectionId); // Item 2 vm.stopPrank(); // Collection now has currentItemId = 3 (next item to be minted) // Only items 1 and 2 actually exist // Encode token IDs for existing and non-existing items uint256 existingItem1 = festivalPass.encodeTokenId(collectionId, 1); uint256 existingItem2 = festivalPass.encodeTokenId(collectionId, 2); uint256 nonExistentItem3 = festivalPass.encodeTokenId(collectionId, 3); uint256 nonExistentItem6 = festivalPass.encodeTokenId(collectionId, 6); // Verify only items 1 and 2 actually exist (user owns them) assertEq(festivalPass.balanceOf(user1, existingItem1), 1); assertEq(festivalPass.balanceOf(user1, existingItem2), 1); assertEq(festivalPass.balanceOf(user1, nonExistentItem3), 0); assertEq(festivalPass.balanceOf(user1, nonExistentItem6), 0); // BUT uri() function returns metadata URLs for ALL items, even non-existent ones! string memory uri1 = festivalPass.uri(existingItem1); string memory uri2 = festivalPass.uri(existingItem2); string memory uri3 = festivalPass.uri(nonExistentItem3); // Should not exist! string memory uri6 = festivalPass.uri(nonExistentItem6); // Should not exist! // All URIs are returned even for non-existent items assertEq(uri1, "ipfs://testbase/metadata/1"); assertEq(uri2, "ipfs://testbase/metadata/2"); assertEq(uri3, "ipfs://testbase/metadata/3"); // ❌ This shouldn't exist assertEq(uri6, "ipfs://testbase/metadata/6"); // ❌ This shouldn't exist // This creates confusion - external systems get metadata URLs for tokens that were never minted console.log("URI for non-existent item 3:", uri3); console.log("URI for non-existent item 6:", uri6); } ``` ### Recommended Mitigation Add validation to ensure the requested item actually exists within the collection: ```diff function uri(uint256 tokenId) public view override returns (string memory) { // Handle regular passes (IDs 1-3) if (tokenId <= BACKSTAGE_PASS) { return string(abi.encodePacked("ipfs://beatdrop/", Strings.toString(tokenId))); } // Decode collection and item IDs (uint256 collectionId, uint256 itemId) = decodeTokenId(tokenId); // Check if it's a valid memorabilia token if (collections[collectionId].priceInBeat > 0) { + // Validate that the item actually exists + require(itemId > 0 && itemId < collections[collectionId].currentItemId, "Item does not exist"); return string(abi.encodePacked( collections[collectionId].baseUri, "/metadata/", Strings.toString(itemId) )); } return super.uri(tokenId); } ``` This ensures that URI calls will fail for non-existent items, providing consistent behavior with the rest of the contract and preventing confusion for external integrators.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.