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:
function uri(uint256 tokenId) public view override returns (string memory) {
if (tokenId <= BACKSTAGE_PASS) {
return string(abi.encodePacked("ipfs://beatdrop/", Strings.toString(tokenId)));
}
(uint256 collectionId, uint256 itemId) = decodeTokenId(tokenId);
if (collections[collectionId].priceInBeat > 0) {
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:
function test_URIReturnsInvalidMetadataForNonExistentItems() public {
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Test Collection",
"ipfs://testbase",
50e18,
5,
true
);
vm.prank(address(festivalPass));
beatToken.mint(user1, 200e18);
vm.startPrank(user1);
festivalPass.redeemMemorabilia(collectionId);
festivalPass.redeemMemorabilia(collectionId);
vm.stopPrank();
uint256 existingItem1 = festivalPass.encodeTokenId(collectionId, 1);
uint256 existingItem2 = festivalPass.encodeTokenId(collectionId, 2);
uint256 nonExistentItem3 = festivalPass.encodeTokenId(collectionId, 3);
uint256 nonExistentItem6 = festivalPass.encodeTokenId(collectionId, 6);
assertEq(festivalPass.balanceOf(user1, existingItem1), 1);
assertEq(festivalPass.balanceOf(user1, existingItem2), 1);
assertEq(festivalPass.balanceOf(user1, nonExistentItem3), 0);
assertEq(festivalPass.balanceOf(user1, nonExistentItem6), 0);
string memory uri1 = festivalPass.uri(existingItem1);
string memory uri2 = festivalPass.uri(existingItem2);
string memory uri3 = festivalPass.uri(nonExistentItem3);
string memory uri6 = festivalPass.uri(nonExistentItem6);
assertEq(uri1, "ipfs://testbase/metadata/1");
assertEq(uri2, "ipfs://testbase/metadata/2");
assertEq(uri3, "ipfs://testbase/metadata/3");
assertEq(uri6, "ipfs://testbase/metadata/6");
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:
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.