Beatland Festival

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

Off-by-one in redeemMemorabilia: item IDs start at 1 but the guard is currentItemId < maxSupply, so every collection mints only maxSupply - 1 items and a 1-of-1 collection can never be redeemed

Root + Impact

Description

  • The organizer creates a memorabilia collection with a maxSupply. IFestivalPass defines it as the "Maximum number of items that can be minted in this collection" and "Maximum items that can exist in this collection", and says item IDs start at 1. So editions 1 through maxSupply should all be redeemable. createMemorabiliaCollection explicitly accepts maxSupply = 1 ("Supply must be at least 1").

  • createMemorabiliaCollection sets currentItemId: 1, and redeemMemorabilia requires currentItemId < maxSupply before it post-increments. The redeem that would mint edition maxSupply sees currentItemId == maxSupply and reverts with "Collection sold out". Every collection therefore delivers one item fewer than configured. A maxSupply = 1 collection reverts on its very first redeem (1 < 1). No function can change maxSupply or currentItemId afterwards.

// src/FestivalPass.sol L170, L176-L183 (createMemorabiliaCollection) and L190-L200 (redeemMemorabilia); @> on L170, L181, L194, L200
@> require(maxSupply > 0, "Supply must be at least 1");
// ...
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");
// Burn BEAT tokens
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// Generate unique token ID
@> uint256 itemId = collection.currentItemId++;

Risk

Likelihood:

  • Every collection that sells through reaches its final edition, and the redeem for that edition reverts deterministically during normal use.

  • Every collection created with maxSupply = 1, an input the contract explicitly accepts, fails on its first redeem by any user.

Impact:

  • Every collection permanently loses its final edition (a 10-item collection mints 9), and 1-of-1 collections can never mint at all. No setter exists to correct either collection.

  • On-chain data misstates the real supply. getMemorabiliaDetails().maxSupply and CollectionCreated.maxSupply advertise an edition that can never exist, and users who saved BEAT for it cannot redeem. No BEAT is lost, because the revert happens before burnFrom.

Proof of Concept

Users are funded the real way: each buys a BACKSTAGE pass and receives the 15 BEAT bonus.

  • test_Baseline_PassMaxSupplyMeansExactlyN shows that within the same contract a pass maxSupply of N sells exactly N.

  • test_CollectionUnderDeliversByOne lets nine fans redeem editions 1-9 of a maxSupply = 10 collection. The tenth fan, holding 15 BEAT against a 10 BEAT price, reverts with "Collection sold out". getMemorabiliaDetails still reports maxSupply = 10, and the reverting fan keeps all their BEAT.

  • test_OneOfOneCollectionNeverRedeemable shows two different fans failing on a maxSupply = 1 collection, so zero items are ever minted.

Save as test/PoC_BF-04.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-04.t.sol -vv

// 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";
​
/// BF-04: redeemMemorabilia guard `currentItemId < maxSupply` with 1-based item IDs
/// lets only maxSupply - 1 items be minted; a 1-of-1 collection can never be redeemed.
contract PoC_BF04 is Test {
FestivalPass festival;
BeatToken beat;
​
address owner = makeAddr("owner");
address organizer = makeAddr("organizer");
​
uint256 constant BACKSTAGE_PASS = 3;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant ITEM_PRICE = 10e18; // 10 BEAT, each backstage pass grants 15 BEAT
​
function setUp() public {
vm.startPrank(owner);
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
vm.stopPrank();
​
vm.prank(organizer);
festival.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, 1000);
}
​
/// Fund a fresh user with 15 BEAT through the real flow (buying a backstage pass).
function _fundedUser(uint256 i) internal returns (address user) {
user = makeAddr(string(abi.encodePacked("fan", vm.toString(i))));
vm.deal(user, BACKSTAGE_PRICE);
vm.prank(user);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
assertEq(beat.balanceOf(user), 15e18, "user funded with 15 BEAT");
}
​
/// Baseline: the contract's own pass supply cap lets exactly maxSupply passes be sold.
function test_Baseline_PassMaxSupplyMeansExactlyN() public {
vm.prank(organizer);
festival.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, 3);
for (uint256 i = 0; i < 3; i++) {
_fundedUser(100 + i);
}
assertEq(festival.passSupply(BACKSTAGE_PASS), 3, "pass maxSupply=3 -> 3 passes sold");
​
address extra = makeAddr("extra");
vm.deal(extra, BACKSTAGE_PRICE);
vm.prank(extra);
vm.expectRevert("Max supply reached");
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
​
/// Limb 1 + 3: maxSupply=10 collection mints only 9; the 10th redeem reverts "Collection sold out"
/// while getMemorabiliaDetails still reports maxSupply = 10. No BEAT is burned on the failed call.
function test_CollectionUnderDeliversByOne() public {
vm.prank(organizer);
uint256 cId = festival.createMemorabiliaCollection("Tour Poster", "ipfs://poster", ITEM_PRICE, 10, true);
​
// Items 1..9 redeem fine.
for (uint256 i = 1; i <= 9; i++) {
address fan = _fundedUser(i);
vm.prank(fan);
festival.redeemMemorabilia(cId);
assertEq(festival.balanceOf(fan, festival.encodeTokenId(cId, i)), 1, "edition minted");
assertEq(beat.balanceOf(fan), 15e18 - ITEM_PRICE, "price burned");
}
​
(,,, uint256 maxSupply, uint256 currentItemId,) = festival.collections(cId);
console.log("maxSupply :", maxSupply);
console.log("currentItemId :", currentItemId);
console.log("items minted :", currentItemId - 1);
assertEq(maxSupply, 10);
assertEq(currentItemId, 10);
​
// The 10th fan, holding enough BEAT, cannot redeem edition #10.
address tenth = _fundedUser(10);
vm.prank(tenth);
vm.expectRevert("Collection sold out");
festival.redeemMemorabilia(cId);
​
// No BEAT lost on the failed call; edition #10 never exists.
assertEq(beat.balanceOf(tenth), 15e18, "no BEAT burned on revert");
assertEq(festival.balanceOf(tenth, festival.encodeTokenId(cId, 10)), 0, "edition 10 never minted");
​
// Views keep advertising 10 as the collection's total.
(,,,, uint256 reportedMax,) = festival.getMemorabiliaDetails(festival.encodeTokenId(cId, 1));
assertEq(reportedMax, 10, "getMemorabiliaDetails reports maxSupply 10");
(,,,, uint256 curAfter,) = festival.collections(cId);
assertEq(curAfter - 1, 9, "only 9 items exist for a maxSupply-10 collection");
}
​
/// Limb 2: a 1-of-1 collection (maxSupply=1, explicitly allowed by "Supply must be at least 1")
/// reverts on the very first redeem, so zero items can ever exist.
function test_OneOfOneCollectionNeverRedeemable() public {
vm.prank(organizer);
uint256 cId = festival.createMemorabiliaCollection("Solo", "ipfs://solo", ITEM_PRICE, 1, true);
​
address fan = _fundedUser(1);
vm.prank(fan);
vm.expectRevert("Collection sold out");
festival.redeemMemorabilia(cId);
​
// A second, different fan also fails: the collection is dead from creation.
address fan2 = _fundedUser(2);
vm.prank(fan2);
vm.expectRevert("Collection sold out");
festival.redeemMemorabilia(cId);
​
(,,, uint256 maxSupply, uint256 currentItemId,) = festival.collections(cId);
console.log("1-of-1 maxSupply:", maxSupply);
console.log("1-of-1 minted :", currentItemId - 1);
assertEq(maxSupply, 1);
assertEq(currentItemId, 1, "nothing ever minted");
assertEq(festival.balanceOf(fan, festival.encodeTokenId(cId, 1)), 0);
assertEq(beat.balanceOf(fan), 15e18, "no BEAT burned");
assertEq(beat.balanceOf(fan2), 15e18, "no BEAT burned");
}
}

Observed output (excerpt):

[PASS] test_Baseline_PassMaxSupplyMeansExactlyN() (gas: 300449)
[PASS] test_CollectionUnderDeliversByOne() (gas: 1546372)
maxSupply : 10
currentItemId : 10
items minted : 9
[PASS] test_OneOfOneCollectionNeverRedeemable() (gas: 383124)
1-of-1 maxSupply: 1
1-of-1 minted : 0
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Item IDs run from 1 to maxSupply inclusive, so the guard must allow currentItemId == maxSupply.

require(collection.isActive, "Collection not active");
- 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!