Beatland Festival

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

Off-by-One Error Prevents Final Memorabilia NFT Redemption

Root + Impact

Description

  • The redeemMemorabilia() function in FestivalPass.sol allows users to burn BEAT tokens to mint unique memorabilia NFTs from collections created by the organizer. Collections initialize with currentItemId = 1 to provide human-readable edition numbers (1/100, 2/100, etc.), and under normal operation, all items from 1 to maxSupply should be redeemable.

  • The redemption function uses a strict less-than comparison (currentItemId < maxSupply) instead of less-than-or-equal-to, causing the final item to be unredeemable. When currentItemId reaches maxSupply (e.g., attempting to mint item #10 in a 10-item collection), the condition evaluates to false and reverts with "Collection sold out" even though one legitimate item remains.

function createMemorabiliaCollection(...) external onlyOrganizer returns (uint256) {
// ... validation ...
collections[collectionId] = MemorabiliaCollection({
// ... other fields ...
maxSupply: maxSupply,
@> currentItemId: 1, // First edition starts at 1, not 0
// ...
});
}
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"); // Bug: should use <=
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
@> uint256 itemId = collection.currentItemId++;
// ... minting logic ...
}

Risk

Likelihood:

  • The vulnerability triggers deterministically when any user attempts to redeem the final item in any collection, occurring once per collection as soon as currentItemId reaches maxSupply.

  • Every collection created in the protocol will experience this issue, making it a guaranteed occurrence across all memorabilia offerings.

Impact:

  • One NFT per collection remains forever locked, reducing realized collection value and frustrating users with "sold out" errors when supply actually remains.

  • Protocol credibility suffers when advertised collection sizes (e.g., "Limited to 100 NFTs") deliver only 99 redeemable items.

Proof of Concept

function testExploit_OffByOne() public {
// Create collection with maxSupply = 5
vm.startPrank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Test Collection",
"ipfs://test",
10e18, // 10 BEAT per item
5, // maxSupply = 5
true // active
);
vm.stopPrank();
// Give user 50 BEAT (enough for 5 items)
vm.startPrank(address(festivalPass));
beatToken.mint(user, 50e18);
vm.stopPrank();
// Redeem 4 items successfully
vm.startPrank(user);
festivalPass.redeemMemorabilia(collectionId); // Item 1
festivalPass.redeemMemorabilia(collectionId); // Item 2
festivalPass.redeemMemorabilia(collectionId); // Item 3
festivalPass.redeemMemorabilia(collectionId); // Item 4
// Try to redeem 5th item - FAILS!
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId); // Item 5 - Should work but doesn't
vm.stopPrank();
// Verify: Only 4 items minted, but maxSupply = 5
(, , , , uint256 currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 5); // currentItemId = 5 (would be for 6th item)
// But only 4 items were actually minted!
}

Recommended Mitigation

Change the comparison operator to include equality, allowing redemption when currentItemId equals maxSupply.

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

Lead Judging Commences

ai-first-flight-judge Lead Judge about 20 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!