Beatland Festival

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

Off-by-One Error in `maxSupply` Enforcement Leading to Permanent Loss of the Last NFT

Off-by-One Error in maxSupply Enforcement Leading to Permanent Loss of the Last NFT


Description

The memorabilia redemption logic contains an off-by-one error in the supply cap enforcement.

The contract tracks the next item to be minted using currentItemId, which starts at 1 and is incremented after each redemption. However, the redemption guard uses a strict inequality:

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

This condition prevents minting when currentItemId equals maxSupply, even though this state corresponds to minting the final valid NFT in the collection. As a result, only maxSupply - 1 items can ever be redeemed, leaving the last NFT permanently unmintable.


Risk

Likelihood: High

The issue is deterministic and will occur for every memorabilia collection regardless of external conditions or user behavior. Any collection created with maxSupply > 0 will consistently suffer from this off-by-one error, making the likelihood of occurrence high.

Impact: Low

The impact is limited but tangible: one NFT per collection becomes permanently inaccessible. This results in an incorrect supply cap, broken collection economics, and potential trust issues for organizers and users expecting the advertised maximum supply to be fully mintable.


Proof of Concept

The following test demonstrates that a collection with maxSupply = 3 allows only two successful redemptions, while the third redemption attempt always reverts.

function test_MaxSupply_OffByOne() public {
// Organizer creates a collection with a declared maxSupply of 3
vm.startPrank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Test Collection",
"ipfs://test",
1e18,
3, // maxSupply = 3
true
);
vm.stopPrank();
// Fund user with enough BEAT tokens to redeem all items
vm.prank(address(festivalPass));
beatToken.mint(user1, 10e18);
// First redemption: itemId = 1 (allowed)
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
// Second redemption: itemId = 2 (allowed)
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
// Third redemption should mint itemId = 3,
// but instead reverts due to the off-by-one supply check
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
}

The test can be executed using Foundry:

forge test --match-test test_MaxSupply_OffByOne

Output:

Ran 1 test for test/FestivalPass.t.sol:FestivalPassTest
[PASS] test_MaxSupply_OffByOne() (gas: 339325)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.18ms (1.37ms CPU time)
Ran 1 test suite in 82.06ms (9.18ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the final NFT in the collection can never be redeemed, effectively reducing the usable supply by one.


Recommended Mitigation

Align the supply check with the chosen indexing scheme.

The simplest and safest fix is to allow minting while the number of minted items is strictly less than maxSupply. Given that currentItemId starts at 1, the condition should be updated to:

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

This change preserves the existing item ID scheme while ensuring that exactly maxSupply NFTs can be minted, restoring correct supply enforcement without requiring structural changes to the contract.

Updates

Lead Judging Commences

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