Beatland Festival

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

Off-by-One Error in FestivalPass::redeemMemorabilia Prevents Redemption of the Last Item in a Collection

Off-by-One Error in FestivalPass::redeemMemorabilia Prevents Redemption of the Last Item in a Collection

Description

  • In createMemorabiliaCollection, currentItemId is initialized to 1 for every new collection. The redemption logic checks if collection.currentItemId < collection.maxSupply holds.

  • This creates an off-by-one error: when currentItemId == maxSupply, the last item cannot be redeemed because the condition currentItemId < maxSupply fails. This effectively causes a denial-of-service (DoS) for the final collectible.

collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
@> currentItemId: 1, // Start item IDs at 1
isActive: activateNow,
});
//redemption condition
@> require(collection.currentItemId < collection.maxSupply, "Collection sold out");

Risk

Likelihood:

  • High – Occurs deterministically for every collection created under the current initialization and redemption logic.

Impact:

  • Users attempting to redeem the last item will always face a Collection sold out revert.

  • Causes a denial-of-service (DoS) for the final collectible

Proof of Concept

  1. Organizer creates a collection with maxSupply = 5.

  2. Redemptions work for 4items (IDs 1 to 4).

  3. Attempting to redeem the 5th item fails due to currentItemId < maxSupply being false (5 < 5 is false).

function test_Off_By_One_Error_In_Memorabilia_Redemption() external {
vm.prank(user1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3); // Gets 15e18 BEAT bonus
// Create performance and earn more BEAT
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(block.timestamp + 1 hours, 2 hours, 250e18);
vm.warp(block.timestamp + 90 minutes);
vm.prank(user1);
festivalPass.attendPerformance(perfId); // Earns 750e18 + 15e18 (250e18 * 3x BACKSTAGE multiplier)
// Create memorabilia collection
vm.prank(organizer);
uint256 collectionId =
festivalPass.createMemorabiliaCollection("Festival Poster", "ipfs://QmPosters", 50, 5, true);
for(uint i = 0 ; i < 5; i++){
vm.prank(user1);
if(i == 4) {
vm.expectRevert();
}
festivalPass.redeemMemorabilia(collectionId);
}
// Check collection state updated
(,,,, uint256 currentItemId,) = festivalPass.collections(collectionId);
assertEq(currentItemId, 5); // Next item will be #5
}

Recommended Mitigation

  1. Start currentItemId at 0 and and increment it on redemption.

  2. Adjust the require condition.

+ //1. Start `currentItemId` at 0 and and increment it on redemption.
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
- currentItemId: 1, // Start item IDs at 1
+ currentItemId: 0, // Start item IDs at 0
isActive: activateNow
});
+ //Adjust the require condition
- require(collection.currentItemId < collection.maxSupply, "Collection sold out");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
Updates

Lead Judging Commences

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