Beatland Festival

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

redeemMemorabilia off-by-one: only N-1 items ever mintable; maxSupply=1 collections permanently bricked

Summary

FestivalPass.redeemMemorabilia() guards its sold-out check with require(collection.currentItemId < collection.maxSupply), but currentItemId starts at 1. A collection with maxSupply = N can therefore only ever mint N - 1 items, and a collection with maxSupply = 1 is permanently bricked from creation — with no admin function able to repair it.

Root Cause

Contract: src/FestivalPass.sol
Function: redeemMemorabilia(uint256 collectionId)

require(collection.currentItemId < collection.maxSupply, "Collection sold out"); // @> off-by-one: currentItemId is 1-based

createMemorabiliaCollection initializes currentItemId to 1 and each redemption mints the item for the current id, then increments it. The strict < comparison against the 1-based counter means the Nth redemption (requiring N < N) can never succeed.

Impact

  • Lost editions: every memorabilia collection permanently loses its last edition. With maxSupply = 3, only items #1 and #2 are ever mintable; item #3 can never exist.

  • Fully bricked collections: a maxSupply = 1 ("one-of-one") collection can never be redeemed at all — 1 < 1 is false from the start — and FestivalPass exposes no organizer/admin function to adjust maxSupply, currentItemId, or isActive after creation, so the collection can never be repaired or wound down. Setup effort and user expectations are stranded.

  • Likelihood is Medium: no attacker is needed; any organizer creating a collection with these parameters hits the bug through normal use.

Description

The intended behavior is that a memorabilia collection with maxSupply = N yields exactly N redeemable items, numbered 1..N. The implementation stores currentItemId starting at 1 and mints encodeTokenId(collectionId, currentItemId) per redemption. However, the sold-out guard uses a strict less-than: require(collection.currentItemId < collection.maxSupply, "Collection sold out").

For maxSupply = 3: redemptions for items #1 (1 < 3) and #2 (2 < 3) succeed, but item #3 requires 3 < 3, which reverts forever. For maxSupply = 1: the very first redemption requires 1 < 1 and reverts with "Collection sold out" immediately — the collection is dead on arrival. Because all collection parameters are immutable after creation (no setter exists for maxSupply, currentItemId, or isActive), there is no recovery path.

Proof of Concept

From repo/test/PoC.t.sol (BeatlandPoC; all 5 tests pass with forge test):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
​
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
​
contract BeatlandPoC is Test {
FestivalPass public fp;
BeatToken public beat;
address public owner;
address public organizer;
address public user;
​
uint256 constant VIP = 2;
uint256 constant VIP_PRICE = 0.1 ether;
​
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user = makeAddr("user");
vm.deal(user, 100 ether);
​
beat = new BeatToken();
fp = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(fp));
​
vm.startPrank(organizer);
fp.configurePass(1, 0.05 ether, 5000);
fp.configurePass(VIP, VIP_PRICE, 1000);
fp.configurePass(3, 0.25 ether, 100);
vm.stopPrank();
}
​
// ============ M-2: redeemMemorabilia off-by-one ============
function test_PoC_RedeemMemorabilia_OffByOne() public {
vm.prank(organizer);
uint256 cid = fp.createMemorabiliaCollection("Genesis", "ipfs://qmx", 1e18, 3, true);
assertEq(cid, 100);
​
// Fund user with BEAT via a VIP pass purchase (5e18 bonus).
vm.prank(user);
fp.buyPass{value: VIP_PRICE}(VIP);
​
vm.startPrank(user);
fp.redeemMemorabilia(cid); // item #1
fp.redeemMemorabilia(cid); // item #2
// Item #3 (== maxSupply) can never be minted: 3 < 3 is false.
vm.expectRevert("Collection sold out");
fp.redeemMemorabilia(cid);
vm.stopPrank();
​
assertEq(fp.balanceOf(user, fp.encodeTokenId(cid, 1)), 1);
assertEq(fp.balanceOf(user, fp.encodeTokenId(cid, 2)), 1);
assertEq(fp.balanceOf(user, fp.encodeTokenId(cid, 3)), 0, "last edition unmintable");
}
​
function test_PoC_RedeemMemorabilia_MaxSupplyOneFullyBricked() public {
vm.prank(organizer);
uint256 cid = fp.createMemorabiliaCollection("OneOfOne", "ipfs://qmy", 1e18, 1, true);
​
vm.prank(user);
fp.buyPass{value: VIP_PRICE}(VIP); // 5e18 BEAT, plenty to pay 1e18
​
// currentItemId (1) < maxSupply (1) is false from the start: nothing redeemable, ever.
vm.prank(user);
vm.expectRevert("Collection sold out");
fp.redeemMemorabilia(cid);
}
}

Recommended Mitigation

Change the sold-out check to use <= against the 1-based counter:

- require(collection.currentItemId < collection.maxSupply, "Collection sold out");
+ require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!