Beatland Festival

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

Off-by-one in `redeemMemorabilia()` prevents last memorabilia item from ever being minted

Description

Root + Impact

  • The redeemMemorabilia() function is intended to allow users to mint up to maxSupply items from a memorabilia collection. The currentItemId starts at 1 and increments with each redemption.

  • The boundary check uses strict less-than (<) instead of less-than-or-equal (<=), which means only maxSupply - 1 items can ever be minted. The last item is permanently unredeemable, and users who burned BEAT tokens to earn enough for that last slot are denied their purchase.

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"); // @> Off-by-one: currentItemId starts at 1, so when currentItemId == maxSupply the last item is blocked
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
// ...
}

Compare with createMemorabiliaCollection() which sets currentItemId: 1 (1-indexed):

collections[collectionId] = MemorabiliaCollection({
// ...
currentItemId: 1, // @> Start item IDs at 1 (1-indexed)
// ...
});

With maxSupply = 10 and currentItemId starting at 1:

  • Items 1 through 9 can be minted (currentItemId goes 1,2,...,9 — all < 10)

  • Item 10 is blocked because currentItemId == 10 is NOT < 10

  • Only 9 out of 10 advertised items are actually mintable


Risk

Likelihood:

  • Every memorabilia collection created in the protocol is affected — this is not a conditional or edge-case scenario

  • The last slot reverts deterministically for any collection with any maxSupply value

Impact:

  • The protocol advertises maxSupply items but can only deliver maxSupply - 1, breaking the promised scarcity model

  • For small limited-edition collections (e.g. maxSupply = 3), losing 1 item is a 33% reduction in actual supply

  • Users who earned enough BEAT tokens specifically to redeem from a "not yet sold out" collection are permanently denied


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract RedeemOffByOneTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public organizer = makeAddr("organizer");
address public user = makeAddr("user");
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
}
function test_LastItemUnredeemable() public {
// Organizer creates collection with maxSupply = 3
vm.prank(organizer);
uint256 colId = festivalPass.createMemorabiliaCollection(
"Limited Hats", "ipfs://hats", 10e18, 3, true
);
// Give user enough BEAT for 3 redemptions
vm.prank(address(festivalPass));
beatToken.mint(user, 100e18);
vm.startPrank(user);
// Redeem item #1 — succeeds
festivalPass.redeemMemorabilia(colId);
console.log("Item 1 redeemed successfully");
// Redeem item #2 — succeeds
festivalPass.redeemMemorabilia(colId);
console.log("Item 2 redeemed successfully");
// Redeem item #3 — REVERTS (should succeed for maxSupply=3)
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(colId);
console.log("Item 3 BLOCKED — only 2 of 3 advertised items mintable");
vm.stopPrank();
}
}

Expected output:

[PASS] test_LastItemUnredeemable()
Logs:
Item 1 redeemed successfully
Item 2 redeemed successfully
Item 3 BLOCKED — only 2 of 3 advertised items mintable

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days 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!