Beatland Festival

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

Memorabilia Collections Mint One Fewer Item Than Their Configured Max Supply

Description

The organizer normally creates memorabilia collections with a maxSupply that represents the maximum number of unique items that can be minted in the collection. Attendees spend BEAT to redeem these memorabilia NFTs, and a collection configured with maxSupply = N should allow exactly N successful redemptions before rejecting the next one.

The issue is an off-by-one error in the redemption boundary. createMemorabiliaCollection() initializes currentItemId to 1, but redeemMemorabilia() only allows redemption while currentItemId < maxSupply. This means a collection with maxSupply = 1 is sold out before the first item can be redeemed, and every collection can only mint maxSupply - 1 items.

The interface describes maxSupply as the maximum number of items that can be minted or exist in the collection.

IFestivalPass.sol#L29-L44

/**
* @notice Represents a collection of memorabilia NFTs
* @dev Each collection can have multiple unique items with sequential IDs
* @param priceInBeat Cost in BEAT tokens to redeem one item from this collection
@> * @param maxSupply Maximum number of items that can be minted in this collection
@> * @param currentItemId Next item ID to be minted (starts at 1)
*/
struct MemorabiliaCollection {
string name;
string baseUri;
uint256 priceInBeat;
uint256 maxSupply;
uint256 currentItemId;
bool isActive;
}

IFestivalPass.sol#L136-L152

/**
* @notice Create a new memorabilia collection
* @dev Only callable by organizer. Each item in collection has unique token ID.
@> * @param maxSupply Maximum items that can exist in this collection
*/
function createMemorabiliaCollection(
string memory name,
string memory baseUri,
uint256 priceInBeat,
uint256 maxSupply,
bool activateNow
) external returns (uint256);

The implementation starts item IDs at 1.

FestivalPass.sol#L161-L186

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");
uint256 collectionId = nextCollectionId++;
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
@> currentItemId: 1, // Start item IDs at 1
isActive: activateNow
});
}

However, redemption uses a strict < maxSupply check before minting the current item ID.

FestivalPass.sol#L190-L207

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

Risk

Likelihood: High

  • This occurs deterministically for every memorabilia collection because currentItemId starts at 1 and redemption requires currentItemId < maxSupply.

  • Unique or one-of-one collections configured with maxSupply = 1 are immediately unusable because the first redemption is rejected as sold out.

Impact: Medium

  • Every memorabilia collection can mint one fewer NFT than the organizer configured and the interface describes.

  • For maxSupply = 1, the entire collection is permanently unredeemable, breaking a core advertised feature of the festival memorabilia system.

Proof of Concept

Add the following test file:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract FestivalPassI03MemorabiliaMaxSupplyTest is Test {
uint256 internal constant PRICE_IN_BEAT = 100e18;
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer;
address internal collector;
function setUp() public {
organizer = makeAddr("organizer");
collector = makeAddr("collector");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
}
function test_I03_01_collectionWithMaxSupplyOneRejectsFirstRedemption() public {
uint256 collectionId = _createCollection(1);
_mintBeat(collector, PRICE_IN_BEAT);
(,,, uint256 maxSupply, uint256 currentItemId, bool isActive) = festivalPass.collections(collectionId);
assertEq(maxSupply, 1, "setup max supply");
assertEq(currentItemId, 1, "setup first item id");
assertTrue(isActive, "setup collection active");
assertEq(beatToken.balanceOf(collector), PRICE_IN_BEAT, "setup collector beat");
uint256 expectedTokenId = festivalPass.encodeTokenId(collectionId, 1);
vm.prank(collector);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
assertEq(beatToken.balanceOf(collector), PRICE_IN_BEAT, "failed redemption did not burn beat");
assertEq(festivalPass.balanceOf(collector, expectedTokenId), 0, "failed redemption did not mint item");
assertEq(festivalPass.tokenIdToEdition(expectedTokenId), 0, "failed redemption did not set edition");
(,,, maxSupply, currentItemId, isActive) = festivalPass.collections(collectionId);
assertEq(maxSupply, 1, "max supply unchanged");
assertEq(currentItemId, 1, "current item id unchanged");
assertTrue(isActive, "collection remains active");
}
function test_I03_02_collectionWithMaxSupplyTwoOnlyAllowsOneRedemption() public {
uint256 collectionId = _createCollection(2);
_mintBeat(collector, 2 * PRICE_IN_BEAT);
uint256 tokenIdOne = festivalPass.encodeTokenId(collectionId, 1);
uint256 tokenIdTwo = festivalPass.encodeTokenId(collectionId, 2);
vm.prank(collector);
festivalPass.redeemMemorabilia(collectionId);
assertEq(festivalPass.balanceOf(collector, tokenIdOne), 1, "first item minted");
assertEq(beatToken.balanceOf(collector), PRICE_IN_BEAT, "first redemption burned price");
(,,, uint256 maxSupply, uint256 currentItemId, bool isActive) = festivalPass.collections(collectionId);
assertEq(maxSupply, 2, "max supply unchanged after first redemption");
assertEq(currentItemId, 2, "second item should be next");
assertTrue(isActive, "collection active after first redemption");
vm.prank(collector);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
assertEq(festivalPass.balanceOf(collector, tokenIdTwo), 0, "second advertised item not minted");
assertEq(beatToken.balanceOf(collector), PRICE_IN_BEAT, "failed second redemption did not burn beat");
assertEq(festivalPass.tokenIdToEdition(tokenIdTwo), 0, "failed second redemption did not set edition");
(,,, maxSupply, currentItemId, isActive) = festivalPass.collections(collectionId);
assertEq(maxSupply, 2, "max supply unchanged after failed second redemption");
assertEq(currentItemId, 2, "current item id unchanged after failed second redemption");
assertTrue(isActive, "collection remains active");
}
function _createCollection(uint256 maxSupply) internal returns (uint256 collectionId) {
vm.prank(organizer);
collectionId = festivalPass.createMemorabiliaCollection(
"Limited Poster", "ipfs://limited-poster", PRICE_IN_BEAT, maxSupply, true
);
}
function _mintBeat(address to, uint256 amount) internal {
vm.prank(address(festivalPass));
beatToken.mint(to, amount);
}
}

Run:

forge test --match-contract FestivalPassI03MemorabiliaMaxSupplyTest -vvv

The tests pass and confirm:

maxSupply = 1
-> first redemption reverts "Collection sold out"
-> item #1 is not minted
-> no BEAT is burned
maxSupply = 2
-> first redemption mints item #1
-> second redemption reverts "Collection sold out"
-> item #2 is not minted
-> no BEAT is burned on the failed second redemption

Recommended Mitigation

Allow redemption while currentItemId <= maxSupply, since item IDs start at 1 and maxSupply is documented as the total number of items.

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!