Off-by-One error in redeemMemorabilia function prevents full collection redemption and unique item (maxSupply == 1) redemption
Description
-
The FestivalPass contract allows users to redeem memorabilia NFTs from a collection by burning BEAT tokens, with each collection having a defined maxSupply to limit the number of items that can be minted.
-
Due to an off-by-one error in the redeemMemorabilia function, users can only redeem maxSupply - 1 items in a collection, preventing the redemption of the final item, which is critical for collections where maxSupply is 1 (i.e., a unique item).
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, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
Risk
Likelihood:
-
Occurs whenever a user attempts to redeem an item from a collection where currentItemId equals maxSupply - 1, as the require check will pass, but the next redemption will fail due to currentItemId reaching maxSupply.
-
Always occurs for collections with maxSupply set to 1, as the first redemption attempt will fail since currentItemId (starting at 1) is not less than maxSupply.
Impact:
-
Users cannot redeem the final item in a collection, reducing the total number of redeemable items to maxSupply - 1, which diminishes the collection’s intended value and utility.
-
For unique item collections (maxSupply == 1), no items can be redeemed, rendering the collection unusable.
Proof of Concept
Add the following test to test/FestivalPass.t.sol and run forge test --mt test_RedeemMemorabilia_CanNotRedeemUniqueItem -vvv.
function test_RedeemMemorabilia_CanNotRedeemUniqueItem() public {
vm.prank(organizer);
uint256 collectionId =
festivalPass.createMemorabiliaCollection("Unique Shirt", "ipfs://QmUniqueShirt", 1000e18, 1, true);
vm.startPrank(address(festivalPass));
beatToken.mint(user1, 1000e18);
vm.stopPrank();
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
console.log("===== User is unable to redeem the unique item =====");
}
Expected output:
Ran 1 test for test/FestivalPass.t.sol:FestivalPassTest
[PASS] test_RedeemMemorabilia_CanNotRedeemUniqueItem() (gas: 223115)
Logs:
===== User is unable to redeem the unique item =====
Recommended Mitigation
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");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
// Burn BEAT tokens
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// Generate unique token ID
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
// Store edition number
tokenIdToEdition[tokenId] = itemId;
// Mint the unique NFT
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}