Description
The organizer creates memorabilia collections with a maxSupply parameter defining how many unique NFTs can be minted. currentItemId starts at 1 and increments
after each successful redemption, serving as both the item counter and the next token ID to mint.
The sold-out guard uses strict less-than (currentItemId < maxSupply). After maxSupply - 1 redemptions, currentItemId equals maxSupply, and the check maxSupply
< maxSupply is permanently false — blocking the final edition forever. For a collection with maxSupply = 1, the check 1 < 1 fails immediately, making the entire
collection unmintable.
// src/FestivalPass.sol
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");
// ^^ strict less-than on a counter starting at 1
// last reachable item is maxSupply-1, not maxSupply
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
@> uint256 itemId = collection.currentItemId++; // reaches maxSupply, then check blocks it
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
Risk
Likelihood:
This affects every memorabilia collection without exception — there are no code paths that bypass the check or correct for the off-by-one.
The organizer advertising "100 limited editions" unknowingly sells only 99, with the 100th slot provably and permanently unreachable on-chain.
Impact:
For a collection with maxSupply = 1 (a 1-of-1 piece), zero NFTs are ever mintable: the require fires on the very first call with 1 < 1 = false.
Collectors who secondary-market the second-to-last edition expecting demand for the final slot find no demand — that token can never exist, making every series
permanently incomplete.
Proof of Concept
function test_LastNFTNeverMintable() public {
// Create a 3-edition collection
vm.prank(organizer);
uint256 colId = festivalPass.createMemorabiliaCollection(
"Limited Series", "ipfs://Qm", 10e18, 3, true
);
vm.prank(address(festivalPass));
beatToken.mint(user1, 100e18);
vm.startPrank(user1);
festivalPass.redeemMemorabilia(colId); // item 1 — OK (currentItemId: 1 → 2)
festivalPass.redeemMemorabilia(colId); // item 2 — OK (currentItemId: 2 → 3)
vm.expectRevert("Collection sold out"); // 3 < 3 = false — REVERTS
festivalPass.redeemMemorabilia(colId); // item 3 — permanently locked
vm.stopPrank();
uint256 token1 = festivalPass.encodeTokenId(colId, 1);
uint256 token2 = festivalPass.encodeTokenId(colId, 2);
uint256 token3 = festivalPass.encodeTokenId(colId, 3);
assertEq(festivalPass.balanceOf(user1, token1), 1);
assertEq(festivalPass.balanceOf(user1, token2), 1);
assertEq(festivalPass.balanceOf(user1, token3), 0); // never minted
}
Recommended Mitigation
// src/FestivalPass.sol
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
# Off-by-One in `redeemMemorabilia` Prevents Last NFT From Being Redeemed ## Description * The `createMemorabiliaCollection` function allows an organizer to create an NFT collection that can be exchanged for the BEAT token via the `redeemMemorabilia` function by users. * The `redeemMemorabilia` function checks if `collection.currentItemId` is less than `collection.maxSupply`. However, the `currentItemId` starts with 1 in the `createMemorabiliaCollection` function. This prevents the final item (where `currentItemId` equals `maxSupply`) from being redeemed. ```Solidity function createMemorabiliaCollection( string memory name, string memory baseUri, uint256 priceInBeat, uint256 maxSupply, bool activateNow ) external onlyOrganizer returns (uint256) { require(priceInBeat > 0, "Price must be greater than 0"); require(maxSupply > 0, "Supply must be at least 1"); require(bytes(name).length > 0, "Name required"); require(bytes(baseUri).length > 0, "URI required"); uint256 collectionId = nextCollectionId++; collections[collectionId] = MemorabiliaCollection({ name: name, baseUri: baseUri, priceInBeat: priceInBeat, maxSupply: maxSupply, @> currentItemId: 1, // Start item IDs at 1 isActive: activateNow }); emit CollectionCreated(collectionId, name, maxSupply); return collectionId; } 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"); // 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); } ``` ## Risk **Likelihood**: * A legitimate user calls `redeemMemorabilia` attempting to redeem the last NFT in a collection. **Impact**: * The user fails to get the NFT, even though the redemption counter has not reached the maximum supply of the collection. ## Proof of Concept The following test shows a user trying to redeem the 10th NFT in one collection. Running `forge test --mt test_Audit_RedeemMaxSupply -vv` shows the output that the 10th redemption is reverted due to the sold out. ```Solidity function test_Audit_RedeemMaxSupply() public { vm.prank(organizer); uint256 maxSupply = 10; // Cap for memorabilia NFT collection uint256 collectionId = festivalPass.createMemorabiliaCollection( "Future Release", "ipfs://QmFuture", 10e18, maxSupply, true ); vm.startPrank(address(festivalPass)); beatToken.mint(user1, 10000e18); // Give enough BEAT for user vm.stopPrank(); vm.startPrank(user1); for (uint256 i = 0; i < maxSupply - 1; i++) { festivalPass.redeemMemorabilia(collectionId); console.log("Redeem sucess:", i + 1); // Redeem success from 1 to 9 } // 10th redeem call reverts with "Collection Sold out" vm.expectRevert("Collection sold out"); festivalPass.redeemMemorabilia(collectionId); console.log("Redeem reverted:", maxSupply); vm.stopPrank(); } ``` ## Recommended Mitigation Modify the supply check in `redeemMemorabilia` to use `<=` (less than or equal to) instead of `<`, ensuring that the final item can be redeemed. This approach is preferable to modifying the `createMemorabiliaCollection` function (which is clearly documented to start `currentItemId` at 1). ```diff // Redeem a memorabilia NFT from a collection 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"); // allow equals // 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); } ```
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.