Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Memorabilia Collections Cannot Mint Final Item

Root + Impact

Description

  • Memorabilia collections are created with a maxSupply that defines how many unique NFTs can be minted from that collection, with currentItemId tracking the next item
    to be minted.

  • An off-by-one error in the supply check prevents the last item in every collection from being minted, reducing the actual mintable supply by 1 for all collections.

function createMemorabiliaCollection(...) external onlyOrganizer returns (uint256) {
// ...
collections[collectionId] = MemorabiliaCollection({
// ...
maxSupply: maxSupply,
// @> currentItemId starts at 1, not 0
currentItemId: 1, // Start item IDs at 1
isActive: activateNow
});
}
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
// ...
// @> Check uses < instead of <=, making item at position maxSupply unreachable
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
// ...
uint256 itemId = collection.currentItemId++;
}

Risk

Likelihood:

  • Every memorabilia collection will have its last item permanently locked and unmintable

  • All collections with maxSupply of 1 will be completely unmintable (0 items can be minted)

Impact:

  • Revenue loss from unmintable NFTs that users would have purchased with BEAT tokens

  • User frustration and loss of trust when advertised collection sizes don't match reality

Proof of Concept

// 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 MemorabiliaOffByOneTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address alice = makeAddr("alice");
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), address(this));
beatToken.setFestivalContract(address(festivalPass));
// Give Alice some BEAT tokens
vm.prank(address(festivalPass));
beatToken.mint(alice, 100e18);
}
function test_CannotMintLastItemInCollection() public {
// Create a collection with maxSupply of 1
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Limited Edition",
"ipfs://limited/",
10e18,
1, // maxSupply = 1
true
);
vm.prank(alice);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
// The collection shows currentItemId = 1 but no items can be minted
(, , , , uint256 currentItemId,) = festivalPass.collections(collectionId);
assertEq(currentItemId, 1);
}
}

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");
// ... rest of function
}
```
Updates

Lead Judging Commences

inallhonesty Lead Judge 28 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Off by one error in redeemMemorabilia

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.