Beatland Festival

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

Off-by-One Error in `FestivalPass.redeemMemorabilia()` Prevents Last NFT from Being Minted

Off-by-One Error in FestivalPass.redeemMemorabilia() Prevents Last NFT from Being Minted

Description

The FestivalPass.redeemMemorabilia() function is used by the users to mint an nft inside a memorabilia collection.

However the FestivalPass.redeemMemorabilia() function contains an off-by-one error in its supply check. The function uses a strict less-than comparison (<) to check if the currentItemId is less than the maxSupply.

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

Because currentItemId represents the ID of the next token to be minted, its value reaches maxSupply when maxSupply - 1 tokens have already been minted. At this point, the check collection.currentItemId < collection.maxSupply (which evaluates to maxSupply < maxSupply) fails, making it impossible to mint the final memorabilia item.

Risk

Likelihood:

  • This issue occurs when the number of memorabilia redeemed has reached maxSupply - 1 of that collection.

Impact:

  • The issue prevents all memorabilia collections from ever being fully sold out, causing users to not be able to redeem when they should be able to.

Proof of Concept

Append the following poc to FestivalPass.t.sol and run it using forge test --mt test_RedeemMemorabilia_CantRedeemLastMemorabilia

function test_RedeemMemorabilia_CantRedeemLastMemorabilia() public {
// 1. Organizer creates a collection with a max supply of 2
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Official Poster", "ipfs://...",
1e18,
2,
true
);
// Give users BEAT tokens to pay for the memorabilia
vm.startPrank(address(festivalPass));
beatToken.mint(user1, 1e18);
beatToken.mint(user2, 1e18);
vm.stopPrank();
// 2. User1 mints the first item (ID 1). This succeeds.
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
// After the first mint, the next item ID is 2.
(, , , , uint256 currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 2);
// 3. User 2 attempts to mint the second and final item.
// The transaction reverts because of the faulty check (2 < 2 is false).
vm.prank(user2);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
// 4. Verify that the total number of minted items is 1, not the max supply of 2.
// The number of items minted is currentItemId - 1.
uint256 totalMinted = currentItemId - 1;
assertEq(totalMinted, 1);
}

Recommended Mitigation

The strict less-than check (<) should be changed to a less-than-or-equal-to check (<=) to allow the currentItemId to equal maxSupply during the final mint. This ensures all items up to and including the one with the ID maxSupply can be minted.

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

inallhonesty Lead Judge about 2 months 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.