Beatland Festival

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

Memorabilia redemption Off-By-One Bug

Off-By-One Bug + Results in last Memorabilia to be always unsold

Description

  • In the contract FestivalPass.sol line 194 has a vital bug

  • In the function createMemorabiliaCollection, currentItemId starts with '1' , and maxSupply > 0 (atleast 1).

  • The require statement in the function redeemMemorabilia checks "collection.currentItemId < collection.maxSupply"

  • Leads to last Memorabilia being always unsold

// @redeemMemorabilia(uint256 collectionId) external
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
// But actually the last item(Memorabilia) is always unsold.
//Eg - currentItemId=1 , maxSupply =2
// 1st redemption passed as 1<2 , currentItemId++;
// 2nd redemption failed as 2<2 (false), but Memorabilia is still left

Risk

Likelihood: HIGH

  • This condition is present in every redemption call.

  • It's not edge-case-specific - it affects all collections with small maxSupply

  • Likely to occur in production unless fixed

Impact: HIGH

  • High - failure of redemption of Memorabilia with maxSupply=1 always.

  • Could result in:

    • Leads to unintentional frontend problem (Memorabilia left but cannot be redeemed)

    • User frustration and possible complaints or refunds.

    • Loss of trust, especially in exclusive or premium drops.

    • Business-side impact on marketing/promotional claims.

Proof of Concept

VULNERABILITY SUMMARY

=====================

  • Function: redeemMemorabilia(uint256 collectionId) external

  • Contract: FestivalPass

  • Type: Logic flaw – Off-by-one boundary error

  • Severity: HIGH

  • Impact: Prevents redemption of final item in collections, permanently locking collectibles


  • ROOT CAUSE:


    * currentItemId starts at 1
    * Boundary check uses: currentItemId < maxSupply
    * Should use: currentItemId <= maxSupply
  • RESULT: For maxSupply=N, only N-1 items can be redeemed


IMPACT SUMMARY:

===============

  • maxSupply = 1: 0% redeemable (CRITICAL - 1-of-1s are permanently locked)

  • maxSupply = 2: 50% redeemable (HIGH impact)

  • maxSupply = 10: 90% redeemable (MEDIUM impact)

  • maxSupply = 100: 99% redeemable (LOW % but high $ value impact)


  • BUSINESS IMPACT:


    * Revenue loss: 1 item per collection permanently unsold
    * User frustration: Cannot obtain advertised quantity
    * Reputation damage: Especially for 1-of-1 "exclusive" collectibles
    * Gas waste: Users waste gas attempting final redemption
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract MemorabiliaOffByOneBugPoC is Test {
FestivalPass public festivalPass;
MockBeatToken public beatToken;
address public organizer = makeAddr("organizer");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
// Deploy contracts
beatToken = new MockBeatToken();
festivalPass = new FestivalPass(address(beatToken));
// Setup organizer role
vm.prank(organizer);
festivalPass.setOrganizer(organizer, true);
// Provide BEAT tokens to users
beatToken.mint(user1, 1000 ether);
beatToken.mint(user2, 1000 ether);
beatToken.mint(user3, 1000 ether);
// Approve spending
vm.prank(user1);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user2);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user3);
beatToken.approve(address(festivalPass), type(uint256).max);
}
/**
* TEST 1: Demonstrates the off-by-one bug with maxSupply = 3
* Expected: 3 items should be redeemable
* Actual: Only 2 items can be redeemed, 3rd fails
*/
function testOffByOneBug_MaxSupply3() public {
// Create collection with maxSupply = 3
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Test Collection",
"https://example.com/",
100 ether, // price in BEAT
3, // maxSupply - user expects 3 items
true // activate now
);
// Verify initial state
(, , , uint256 maxSupply, uint256 currentItemId, bool isActive) =
festivalPass.collections(collectionId);
assertEq(maxSupply, 3, "Max supply should be 3");
assertEq(currentItemId, 1, "Current item ID should start at 1");
assertTrue(isActive, "Collection should be active");
// Redemption 1: Should succeed (itemId = 1)
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
// Verify state after first redemption
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 2, "Current item ID should be 2 after first redemption");
// Redemption 2: Should succeed (itemId = 2)
vm.prank(user2);
festivalPass.redeemMemorabilia(collectionId);
// Verify state after second redemption
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 3, "Current item ID should be 3 after second redemption");
// Redemption 3: Should succeed but FAILS due to bug
// At this point: currentItemId = 3, maxSupply = 3
// Check: 3 < 3 = false, so transaction reverts
vm.prank(user3);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId); // ❌ BUG: This should succeed!
// Verify final state - collection appears "sold out" but only 2/3 items were minted
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 3, "Current item ID stuck at 3, should be 4 if third redemption succeeded");
}
/**
* TEST 2: Critical edge case - maxSupply = 1
* This is catastrophic: NO items can be redeemed from 1-of-1 collections!
*/
function testOffByOneBug_MaxSupply1_Critical() public {
// Create 1-of-1 collectible
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Rare 1-of-1 NFT",
"https://example.com/rare/",
500 ether, // high price for rare item
1, // maxSupply = 1 (should be redeemable)
true
);
// Attempt to redeem the ONLY item - this should work but FAILS
// currentItemId = 1, maxSupply = 1
// Check: 1 < 1 = false, immediate revert
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId); // ❌ CRITICAL: 1-of-1 is unredeemable!
// Verify no tokens were minted
assertEq(festivalPass.balanceOf(user1, _encodeTokenId(collectionId, 1)), 0,
"No tokens should have been minted");
}
/**
* TEST 3: Demonstrates the pattern across different supply sizes
*/
function testOffByOneBug_VariousSupplySizes() public {
uint256[] memory supplySizes = new uint256[](4);
supplySizes[0] = 1; // 1-of-1: 0 redeemable (0%) - CRITICAL
supplySizes[1] = 2; // 2 supply: 1 redeemable (50%) - HIGH
supplySizes[2] = 10; // 10 supply: 9 redeemable (90%) - MEDIUM
supplySizes[3] = 100; // 100 supply: 99 redeemable (99%) - LOW but $ impact
for (uint i = 0; i < supplySizes.length; i++) {
uint256 maxSupply = supplySizes[i];
// Create collection
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection ", i)),
"https://example.com/",
100 ether,
maxSupply,
true
);
// Redeem maxSupply - 1 items (these should all succeed)
for (uint j = 0; j < maxSupply - 1; j++) {
address user = makeAddr(string(abi.encodePacked("user_", i, "_", j)));
beatToken.mint(user, 1000 ether);
vm.prank(user);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user);
festivalPass.redeemMemorabilia(collectionId);
}
// The final redemption should succeed but FAILS
address finalUser = makeAddr(string(abi.encodePacked("final_user_", i)));
beatToken.mint(finalUser, 1000 ether);
vm.prank(finalUser);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(finalUser);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId); // ❌ Final item always fails
}
}
/**
* Utility function to encode token ID (mimics the contract's logic)
*/
function _encodeTokenId(uint256 collectionId, uint256 itemId) internal pure returns (uint256) {
return (collectionId << 128) + itemId;
}
}

Recommended Mitigation

Below shown is the changes need to be done

  • This change leads to redemption of 'N' Memorabilia(max) for a maxSupply = N, instead of N-1 from previous code

  • Redemption call for 1-of-1 Memorabilia passes, HIGH severity impact prevented.

- require(collection.currentItemId < collection.maxSupply, "Collection sold out");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
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.