Beatland Festival

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

Off by one in `redeemMemorabilia` prevents minting full `maxSupply`

Root + Impact


Description

createMemorabiliaCollection initializes currentItemId to 1, so redemption is intendedto mint editions starting from ite 1 upp to maxSupply.

redeemMemorabilia uses require(collection.currentItemId < collection.maxSupply, "Collection sold out");, which blocks redemption once currentItemId reaches maxSupply. This prevents the final edition from ever being minted, causing every collection to mint one less item than configured.

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, // @> IDs start at 1
isActive: activateNow
});
// ...
}
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
// ...
require(collection.currentItemId < collection.maxSupply, "Collection sold out"); // @> Off-by-one check
// ...
uint256 itemId = collection.currentItemId++;
// ...
}

Risk

Likelihood:

  • Every collection created through createMemorabiliaCollection starts with currentItemId = 1, so the boundary condition is always reached during normal redemptions.

  • Any collection with maxSupply >= 1 experiences early sell-out behavior at the final mint slot.

Impact:

  • The last intended edition (itemId == maxSupply) is permanently unmintable.

  • Collection supply/economics are broken because the actual max redeemable supply becomes maxSupply - 1.

Proof of Concept

function test_RedeemMemorabilia_OffByOneError() public {
// Create a memorabilia collection with maxSupply = 3
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Limited Edition",
"ipfs://QmLimited",
50e18,
3, // maxSupply = 3, so editions 1, 2, 3 should be mintable
true
);
// Give user enough BEAT to redeem all 3 items
vm.startPrank(address(festivalPass));
beatToken.mint(user1, 300e18);
vm.stopPrank();
// Successfully redeem edition #1 (currentItemId = 1, check: 1 < 3 ✓)
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
uint256 token1 = festivalPass.encodeTokenId(collectionId, 1);
assertEq(festivalPass.balanceOf(user1, token1), 1, "Edition 1 should be minted");
// Successfully redeem edition #2 (currentItemId = 2, check: 2 < 3 ✓)
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
uint256 token2 = festivalPass.encodeTokenId(collectionId, 2);
assertEq(festivalPass.balanceOf(user1, token2), 1, "Edition 2 should be minted");
// Attempt to redeem edition #3 (currentItemId = 3, check: 3 < 3 ✗)
// This SHOULD succeed but FAILS due to off-by-one error
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
// Proof: only 2 editions out of 3 were minted
uint256 token3 = festivalPass.encodeTokenId(collectionId, 3);
assertEq(festivalPass.balanceOf(user1, token3), 0, "Edition 3 should NOT exist (bug present)");
}

This test proves a supply-boundary bug in redeemMemorabilia:
Collection is created with maxsupply = 3

Contract initializes currentItemId = 1.

Redemption check is currentItemId 1 < 3 true -> mint item 1.
currentItemId = 2 -> 2 < 3 true -> mint item 2.
currentItemId = 3 -> 3 < 3 false -> revert "Collection sold out".

Result:

Item #3 (the last valid edition) is never mintable.
Effective max redeemable supply is maxSupply - 1.

Recommended Mitigation

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);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}

Changing it from < to <= is the simplest way to fix the issue.

Updates

Lead Judging Commences

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