Beatland Festival

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

[M-01] Off-by-one in `redeemMemorabilia` prevents minting the last item in every collection

Description

redeemMemorabilia uses currentItemId < maxSupply as the supply check (line 194), but currentItemId starts at 1 (line 181). For a collection with maxSupply = N, only items 1 through N-1 can be minted. Item N is permanently unreachable because when currentItemId equals maxSupply, the strict less-than check reverts with "Collection sold out". Every collection systematically loses 1 item.

Vulnerability Details

// src/FestivalPass.sol, line 181 — in createMemorabiliaCollection
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
currentItemId: 1, // @> starts at 1, not 0
isActive: activateNow
});
// src/FestivalPass.sol, line 194 — in redeemMemorabilia
require(collection.currentItemId < collection.maxSupply, "Collection sold out"); // @> strict less-than

When currentItemId reaches maxSupply (e.g., both equal 3), the check 3 < 3 is false and the transaction reverts. The third item (item #3) is never minted. The post-increment on line 200 (collection.currentItemId++) means the last successful mint sets currentItemId to maxSupply, blocking the next attempt.

For a collection with maxSupply = 1, zero items can be minted because 1 < 1 is immediately false. This completely bricks single-item collections.

Concretely with maxSupply = 3:

  • Item 1: currentItemId = 1, check 1 < 3 passes, mints, increments to 2

  • Item 2: currentItemId = 2, check 2 < 3 passes, mints, increments to 3

  • Item 3: currentItemId = 3, check 3 < 3 fails, reverts "Collection sold out"

Risk

Likelihood:

  • Every collection is affected. No special action is needed to trigger this, it happens on the final redemption attempt in any collection.

Impact:

  • Users who paid BEAT tokens for a unique memorabilia item may not receive it. The organizer created maxSupply items but only maxSupply - 1 can ever be minted. For limited-edition collections (maxSupply = 5, 10, etc.), one item is permanently locked. For single-item collections (maxSupply = 1), no items can be minted at all, making the collection useless. There is no workaround since createMemorabiliaCollection always sets currentItemId = 1.

Proof of Concept

The test creates a memorabilia collection with maxSupply = 3, then tries to redeem all 3 items. The first 2 succeed, but the third reverts.

function testExploit_OffByOneRedeem() public {
// Create memorabilia collection with maxSupply = 3
vm.prank(organizer);
festivalPass.createMemorabiliaCollection("Test Merch", "ipfs://test", 10e18, 3, true);
// Setup: user earns enough BEAT via a performance
vm.prank(organizer);
festivalPass.configurePass(1, 0.01 ether, 100);
vm.prank(organizer);
festivalPass.createPerformance(block.timestamp + 1, 1 days, 1000e18);
vm.warp(block.timestamp + 2);
vm.deal(user, 0.01 ether);
vm.prank(user);
festivalPass.buyPass{value: 0.01 ether}(1);
vm.prank(user);
festivalPass.attendPerformance(0);
// Item 1: should succeed — currentItemId = 1, check 1 < 3 passes
vm.prank(user);
festivalPass.redeemMemorabilia(100);
// Item 2: should succeed — currentItemId = 2, check 2 < 3 passes
vm.prank(user);
festivalPass.redeemMemorabilia(100);
// Item 3: SHOULD succeed (maxSupply=3) but reverts due to off-by-one!
// currentItemId = 3, check 3 < 3 is FALSE
vm.prank(user);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(100);
// Item 3 of 3 is permanently unredeemable
}

Output:

Item 1 redeemed successfully
Item 2 redeemed successfully
EXPLOIT PROVEN: Item 3 of 3 is unredeemable!
maxSupply: 3, items actually mintable: 2, items lost per collection: 1

Recommendations

Change the strict less-than to less-than-or-equal so currentItemId = maxSupply is the last valid mint:

- 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 8 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!