Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

redeemMemorabilia() off-by-one makes the last edition of every collection unmintable, and maxSupply of 1 collections can never be redeemed at all

Root + Impact

createMemorabiliaCollection starts a collection's currentItemId at 1, but redeemMemorabilia requires currentItemId < maxSupply before minting. The last edition of every collection can therefore never be minted: a collection with maxSupply of 3 only ever issues items 1 and 2 before reporting "Collection sold out", and in the extreme case a collection created with maxSupply of 1 (valid per createMemorabiliaCollection, which only requires maxSupply > 0) can never be redeemed even once, permanently bricking it while users may already hold BEAT they earned or bought specifically for it.

Description

collections[collectionId] = MemorabiliaCollection({
...
// @> currentItemId: 1, // Start item IDs at 1
...
});
​
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++;
...
}

Because item IDs start at 1, the condition should allow minting while currentItemId <= maxSupply. As written, when currentItemId reaches maxSupply (i.e. exactly one edition remains) the check already reverts, so issued supply always stops at maxSupply - 1.

Risk

Likelihood: High - deterministic; it happens for every collection, on every deployment, with no special conditions.
Impact: Medium - every memorabilia collection permanently loses its final edition (the scarcest, often most valuable one), and single-edition collections are completely unusable despite being creatable through the normal organizer flow. Users who farmed or bought BEAT targeting those editions cannot redeem as promised.

Proof of Concept

Verified with Foundry (both tests pass): a collection with maxSupply 1 reverts "Collection sold out" on the very first redemption attempt by a user holding enough BEAT; a collection with maxSupply 3 accepts exactly 2 redemptions and reverts on the third.

function test_MaxSupplyOneCollectionCanNeverBeRedeemed() public {
uint256 colId = pass.createMemorabiliaCollection("Poster", "ipfs://poster", 1e18, 1, true);
// alice holds 5 BEAT (VIP pass welcome bonus)
vm.prank(alice);
vm.expectRevert("Collection sold out");
pass.redeemMemorabilia(colId); // nothing was ever minted, yet "sold out"
}
​
function test_LastEditionNeverMintable() public {
uint256 colId = pass.createMemorabiliaCollection("Vinyl", "ipfs://vinyl", 1e18, 3, true);
vm.startPrank(alice);
pass.redeemMemorabilia(colId); // item 1 OK
pass.redeemMemorabilia(colId); // item 2 OK
vm.expectRevert("Collection sold out");
pass.redeemMemorabilia(colId); // item 3, the last edition, can never be minted
vm.stopPrank();
}

Recommended Mitigation

Align the check with the 1-based item IDs:

- require(collection.currentItemId < collection.maxSupply, "Collection sold out");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Off-by-One in `redeemMemorabilia` Prevents Last NFT From Being Redeemed

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

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!