Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Off-by-one in memorabilia redemption permanently blocks the last item in every collection

Off-by-one in memorabilia redemption permanently blocks the last item in every collection

Scope

  • FestivalPass.sol

  • createMemorabiliaCollection(...)

  • redeemMemorabilia(uint256 collectionId)

Root + Impact

Description

The normal behavior is that a memorabilia collection created with maxSupply = N should allow exactly N successful redemptions. In particular, a 1-of-1 collection should allow one successful redemption, and a collection with supply 10 should allow ten redemptions.

The issue is that each collection starts with currentItemId = 1, but the sold-out check in redeemMemorabilia() requires currentItemId < maxSupply instead of allowing the final item. This makes the last item in every collection unreachable.

As a result:

  • a 1-of-1 collection is immediately unreedeemable on the very first attempt;

  • a collection with maxSupply = N only ever allows N - 1 redemptions;

  • organizer-configured inventory is silently overstated;

  • honest users are blocked from redeeming valid advertised supply.

function createMemorabiliaCollection(
string memory name,
string memory baseUri,
uint256 priceInBeat,
uint256 maxSupply,
bool activateNow
) external onlyOrganizer returns (uint256) {
...
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
@> currentItemId: 1, // Start item IDs at 1
isActive: activateNow
});
...
}
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");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
@> uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
...
}

The root cause is the mismatch between:

  • currentItemId being 1-indexed;

  • and the sold-out condition using strict < maxSupply.

For example, when maxSupply = 1, the very first redeem attempt starts with currentItemId = 1, so the contract evaluates:

require(1 < 1, "Collection sold out");

which immediately reverts.

Risk

Likelihood:

  • The issue occurs deterministically for every collection because it is caused by fixed initialization and a fixed comparison.

  • The bug triggers on normal honest-user redemption flow and does not require any special attacker capability.

  • Every 1-of-1 collection is broken from the moment it is created.

  • Every larger collection loses its final redeemable item.

Impact:

  • Honest users cannot redeem the full advertised memorabilia supply.

  • 1-of-1 collections can never be redeemed at all.

  • The organizer can configure supply values that the contract can never actually deliver.

  • The protocol’s BEAT-to-memorabilia redemption flow is broken at the collection boundary.

Proof of Concept

The following Foundry test shows that a collection created with maxSupply = 1 is already considered sold out before the first redemption:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console2} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract FestivalPassAuditTest is Test {
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer = makeAddr("organizer");
address internal attacker1 = makeAddr("attacker1");
uint256 internal constant GENERAL_PRICE = 0.05 ether;
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 10);
vm.deal(attacker1, 1 ether);
}
function test_MaxSupplyOneMemorabiliaIsImmediatelySoldOut() public {
vm.prank(attacker1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(organizer);
uint256 performanceId = festivalPass.createPerformance(block.timestamp + 1 hours, 2 hours, 150e18);
vm.warp(block.timestamp + 90 minutes);
vm.prank(attacker1);
festivalPass.attendPerformance(performanceId);
vm.prank(organizer);
uint256 collectionId =
festivalPass.createMemorabiliaCollection("One of One Poster", "ipfs://poster", 100e18, 1, true);
(, , , uint256 maxSupply, uint256 currentItemId, ) = festivalPass.collections(collectionId);
console2.log("collection maxSupply", maxSupply);
console2.log("collection currentItemId before first redeem", currentItemId);
vm.prank(attacker1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
}
}

Observed result:

[PASS] test_MaxSupplyOneMemorabiliaIsImmediatelySoldOut()
Logs:
collection maxSupply 1
collection currentItemId before first redeem 1

This confirms that the very first redemption of a 1-of-1 collection reverts as sold out, so the collection can never produce its single intended item.

Recommended Mitigation

The sold-out check should be aligned with the 1-indexed currentItemId. With the current indexing model, redemption should stay open while currentItemId <= 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");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
...
}

An equivalent alternative is to switch currentItemId to zero-based indexing and keep the comparison consistent. The important requirement is that a collection with maxSupply = N must permit exactly N successful redemptions.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Off-by-One in `redeemMemorabilia` Prevents Last NFT From Being Redeemed

# Off-by-One in `redeemMemorabilia` Prevents Last NFT From Being Redeemed ## Description * The `createMemorabiliaCollection` function allows an organizer to create an NFT collection that can be exchanged for the BEAT token via the `redeemMemorabilia` function by users. * The `redeemMemorabilia` function checks if `collection.currentItemId` is less than `collection.maxSupply`. However, the `currentItemId` starts with 1 in the `createMemorabiliaCollection` function. This prevents the final item (where `currentItemId` equals `maxSupply`) from being redeemed. ```Solidity function createMemorabiliaCollection( string memory name, string memory baseUri, uint256 priceInBeat, uint256 maxSupply, bool activateNow ) external onlyOrganizer returns (uint256) { require(priceInBeat > 0, "Price must be greater than 0"); require(maxSupply > 0, "Supply must be at least 1"); require(bytes(name).length > 0, "Name required"); require(bytes(baseUri).length > 0, "URI required"); uint256 collectionId = nextCollectionId++; collections[collectionId] = MemorabiliaCollection({ name: name, baseUri: baseUri, priceInBeat: priceInBeat, maxSupply: maxSupply, @> currentItemId: 1, // Start item IDs at 1 isActive: activateNow }); emit CollectionCreated(collectionId, name, maxSupply); return collectionId; } 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"); // Burn BEAT tokens BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat); // Generate unique token ID uint256 itemId = collection.currentItemId++; uint256 tokenId = encodeTokenId(collectionId, itemId); // Store edition number tokenIdToEdition[tokenId] = itemId; // Mint the unique NFT _mint(msg.sender, tokenId, 1, ""); emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId); } ``` ## Risk **Likelihood**: * A legitimate user calls `redeemMemorabilia` attempting to redeem the last NFT in a collection. **Impact**: * The user fails to get the NFT, even though the redemption counter has not reached the maximum supply of the collection. ## Proof of Concept The following test shows a user trying to redeem the 10th NFT in one collection. Running `forge test --mt test_Audit_RedeemMaxSupply -vv` shows the output that the 10th redemption is reverted due to the sold out. ```Solidity function test_Audit_RedeemMaxSupply() public { vm.prank(organizer); uint256 maxSupply = 10; // Cap for memorabilia NFT collection uint256 collectionId = festivalPass.createMemorabiliaCollection( "Future Release", "ipfs://QmFuture", 10e18, maxSupply, true ); vm.startPrank(address(festivalPass)); beatToken.mint(user1, 10000e18); // Give enough BEAT for user vm.stopPrank(); vm.startPrank(user1); for (uint256 i = 0; i < maxSupply - 1; i++) { festivalPass.redeemMemorabilia(collectionId); console.log("Redeem sucess:", i + 1); // Redeem success from 1 to 9 } // 10th redeem call reverts with "Collection Sold out" vm.expectRevert("Collection sold out"); festivalPass.redeemMemorabilia(collectionId); console.log("Redeem reverted:", maxSupply); vm.stopPrank(); } ``` ## Recommended Mitigation Modify the supply check in `redeemMemorabilia` to use `<=` (less than or equal to) instead of `<`, ensuring that the final item can be redeemed. This approach is preferable to modifying the `createMemorabiliaCollection` function (which is clearly documented to start `currentItemId` at 1). ```diff // Redeem a memorabilia NFT from a collection 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"); // allow equals // Burn BEAT tokens BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat); // Generate unique token ID uint256 itemId = collection.currentItemId++; uint256 tokenId = encodeTokenId(collectionId, itemId); // Store edition number tokenIdToEdition[tokenId] = itemId; // Mint the unique NFT _mint(msg.sender, tokenId, 1, ""); emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId); } ```

Support

FAQs

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

Give us feedback!