Beatland Festival

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

[H-03] Off-by-One Memorabilia Supply In `FestivalPass::redeemMemorabilia` leads to — Fewer Items Than Promised

Off-by-One Memorabilia Supply — Fewer Items Than Promised

Description

  • Normal behaviour: redeemMemorabilia() should allow exactly maxSupply unique NFTs to be minted from each collection.

  • Issue: The guard require(collection.currentItemId < collection.maxSupply) prevents minting when currentItemId equals maxSupply. Because currentItemId starts at 1, only maxSupply – 1 items can ever be redeemed; the last slot is unreachable.

// FestivalPass.sol – redeemMemorabilia()
@> require(collection.currentItemId < collection.maxSupply, "Collection sold out");
uint256 itemId = collection.currentItemId++; // post-increment, starts at 1

Risk

Likelihood:

  • Any collection with maxSupply ≥ 2 will eventually attempt to mint the final item.

  • The final buyer will encounter a revert once the penultimate token has been redeemed.

Impact:

  • One promised NFT in every collection can never be created, frustrating collectors and misreporting sold-out status.

  • If BEAT tokens were pre-burned for the failed redemption (future feature), users would lose funds.

Proof of Concept

Steps executed in the Forge test test_PoC_OffByOne_Memorabilia():

  1. Alice acquires a BACKSTAGE pass to have enough BEAT balance.

  2. The organiser creates a new collection with maxSupply = 2.

  3. Alice redeems the first item – success.

  4. Alice immediately attempts to redeem what should be the second and final item. The call reverts with “Collection sold out” although only one NFT exists.

/* ---------------------------------------------------------------------- */
/* PoC-3: Off-by-One in memorabilia maxSupply check */
/* ---------------------------------------------------------------------- */
/**
* @dev Demonstrates that only (maxSupply-1) items can be redeemed from a
* memorabilia collection due to an off-by-one logic error.
*/
function test_PoC_OffByOne_Memorabilia() public {
// Alice buys a BACKSTAGE pass to acquire enough BEAT tokens (15e18 bonus)
vm.prank(alice);
festivalPass.buyPass{value: 0.25 ether}(BACKSTAGE_PASS);
// Organizer creates a small collection with maxSupply = 2, price = 5 BEAT
vm.startPrank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Limited",
"ipfs://limited",
5e18,
2,
true
);
vm.stopPrank();
// Alice redeems the first item successfully
vm.prank(alice);
festivalPass.redeemMemorabilia(collectionId);
// Attempting to redeem the second (and supposedly final) item reverts
// even though maxSupply is 2 – highlighting the off-by-one bug.
vm.prank(alice);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
}

Recommended Mitigation

Two straightforward fixes:

- require(collection.currentItemId < collection.maxSupply, "Collection sold out");
+ // Allow equality so the final item can be minted
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");

or, during collection creation, start the counter at zero and switch to pre-increment so it naturally ranges 1..maxSupply:

- currentItemId: 1,
+ currentItemId: 0,
...
- uint256 itemId = collection.currentItemId++; // post-increment
+ uint256 itemId = ++collection.currentItemId; // pre-increment

Both approaches guarantee that exactly maxSupply NFTs are redeemable.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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.