Beatland Festival

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

[M-2] Off-by-one logic error in `FestivalPass::redeemMemorabilia` prevents minting of the final NFT in a collection

[M-2] Off-by-one logic error in `FestivalPass::redeemMemorabilia` prevents minting of the final NFT in a collection

Description

  • The `FestivalPass::redeemMemorabilia` function is used to buy a souvenir NFT. It burns BEAT tokens from the user and mints them a unique NFT from a collection of their choosing.

  • However, the function requirement checking whether the collection is sold out or not makes it so this function can only mint one NFT less than the collection's max supply, due to the `currentItemId` field starting from 1.

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:

  • When a user wants to buy the final NFT in a collection

Impact:

  • User can't buy the last NFT of a collection, denying them of a souvenir

Proof of Concept

1. Organizer creates a performance

2. Organizer creates a collection

3. User buys a pass

4. It's show time! User attends performance

5. User wants to buy a souvenir but is denied. The function reverts.

function testCantRedeemNFT() public {
// Organizer creates a performance
vm.prank(organizer);
uint256 startTime = block.timestamp + 1 hours;
uint256 perfId = festivalPass.createPerformance(startTime, 1 hours, 1000e18);
​
// Organizer creates a collection
vm.prank(organizer);
uint256 collectionMaxSupply = 1;
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Golden Hats", "ipfs://QmGoldenHats", 500e18, collectionMaxSupply, true
);
​
// User buys a pass
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
​
// It's show time!
vm.warp(startTime + 30 minutes);
​
// User attends performance
vm.prank(user1);
festivalPass.attendPerformance(perfId);
​
// User wants to buy a souvenir
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
}

Recommended Mitigation

Change the function requirement from `collection.currentItemId < collection.maxSupply` to `collection.currentItemId <= collection.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");
​
// 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);
}
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!