Beatland Festival

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

Off-By-One in Supply Cap Check Prevents Collections From Minting Their Full `maxSupply`

Off-By-One in Supply Cap Check Prevents Collections From Minting Their Full maxSupply

Description

  • Normal behavior: When an organizer calls createMemorabiliaCollection, they set a maxSupply that represents the total number of items that collection should ever be able to mint. Users then call redeemMemorabilia to mint items from that collection until maxSupply is reached.

  • The issue: currentItemId is initialized to 1 (not 0), but the supply check in redeemMemorabilia compares it against maxSupply using a strict < operator as if currentItemId were a zero-indexed count. As a result, the collection stops accepting redemptions one item short of maxSupply, and a collection configured with maxSupply = 1 can never mint anything at all.

collections[collectionId] = MemorabiliaCollection({
...
currentItemId: 1, // @> 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"); // @> off-by-one: should be <=
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++; // @> last valid itemId minted is maxSupply - 1
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}

Risk

Likelihood:

  • Occurs on every single memorabilia collection, on every redemption sequence that runs to completion — there is no special input or attacker action needed to trigger it.

  • Occurs deterministically as soon as an organizer creates a collection and users redeem up to the (effective) limit, since currentItemId always starts at 1 regardless of configuration.

Impact:

  • Every collection permanently mints one fewer item than its configured maxSupply (e.g. maxSupply = 100 → only 99 mintable), breaking the supply guarantee organizers configure and may advertise to buyers.

  • A collection configured with maxSupply = 1 (e.g. a single "1 of 1" special/backstage item) can never be minted at all — redeemMemorabilia reverts with "Collection sold out" on the very first attempt, fully bricking that collection.

Proof of Concept

The following PoC demonstrates the off-by-one at both ends of its impact range. test_maxSupplyNeverFullyMinted sets up a 5-item collection, redeems until the contract itself reverts, and shows that only 4 items are ever mintable. test_maxSupplyOneIsFullyBricked isolates the worst case — a maxSupply = 1 collection — and shows the very first redemption reverts, so the collection can never be minted from at all.

// 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 FestivalPassTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public owner;
address public organizer;
address public user1;
address public user2;
// Pass configuration
uint256 constant GENERAL_PRICE = 0.05 ether;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant GENERAL_MAX_SUPPLY = 5000;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant BACKSTAGE_MAX_SUPPLY = 100;
// Events to test
event PassPurchased(address indexed buyer, uint256 indexed passId);
event PerformanceCreated(uint256 indexed performanceId, uint256 startTime, uint256 endTime);
event Attended(address indexed attendee, uint256 indexed performanceId, uint256 reward);
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
// Deploy contracts
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
// Set festival contract in BeatToken
beatToken.setFestivalContract(address(festivalPass));
// Configure passes as organizer
vm.startPrank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, GENERAL_MAX_SUPPLY);
festivalPass.configurePass(2, VIP_PRICE, VIP_MAX_SUPPLY);
festivalPass.configurePass(3, BACKSTAGE_PRICE, BACKSTAGE_MAX_SUPPLY);
vm.stopPrank();
// Fund test users
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
}
function test_maxSupplyNeverFullyMinted() public {
uint256 priceInBeat = 1e18;
uint256 maxSupply = 5;
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Tour Poster",
"ipfs://poster",
priceInBeat,
maxSupply,
true
);
// Fund user with enough BEAT to redeem up to maxSupply times
vm.prank(address(festivalPass));
beatToken.mint(user1, priceInBeat * maxSupply);
vm.startPrank(user1);
beatToken.approve(address(festivalPass), type(uint256).max);
uint256 minted;
for (uint256 i = 0; i < maxSupply; i++) {
try festivalPass.redeemMemorabilia(collectionId) {
minted++;
} catch Error(string memory reason) {
console.log("Reverted at attempt", i + 1, "with reason:", reason);
break;
}
}
vm.stopPrank();
console.log("configured maxSupply:", maxSupply);
console.log("actually mintable: ", minted);
// Proves only (maxSupply - 1) items were mintable, not maxSupply
assertEq(minted, maxSupply - 1, "expected exactly maxSupply - 1 items to be mintable");
}
function test_maxSupplyOneIsFullyBricked() public {
uint256 priceInBeat = 1e18;
uint256 maxSupply = 1; // "1 of 1" special item
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Backstage Exclusive 1-of-1",
"ipfs://exclusive",
priceInBeat,
maxSupply,
true
);
vm.prank(address(festivalPass));
beatToken.mint(user1, priceInBeat);
vm.startPrank(user1);
beatToken.approve(address(festivalPass), type(uint256).max);
// Should succeed since 1 item was configured, but reverts instead
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
vm.stopPrank();
}
}

Trace summary:

  • maxSupply = 5 → only 4 items mintable (minted == maxSupply - 1).

  • maxSupply = 1 → 0 items mintable, collection is permanently unmintable.

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");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);

This allows currentItemId to run from 1 through maxSupply inclusive, minting exactly maxSupply items as configured (including the maxSupply = 1 case, which becomes mintable once).

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!