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);
}