Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

[H-1] Off-by-one error in `redeemMemorabilia` prevents last item redemption

Root + Impact

Description

  • The `redeemMemorabilia` function uses strict less-than comparison (`<`) against `maxSupply`, but `currentItemId` is initialized to 1 in `createMemorabiliaCollection`. This causes the last item to be unredeemable.

  • For a collection with `maxSupply = 10`, only 9 items can ever be minted because when `currentItemId` reaches 10, the check `10 < 10` fails.

// 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"); @> should be <=
// 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:

  • Every memorabilia collection created will have this issue

  • Users attempting to redeem the final advertised item will have transactions revert

Impact:

  • Users cannot redeem the last item of any collection despite having BEAT tokens

  • Protocol breaks its advertised supply guarantees (potential fraud)

  • User BEAT tokens wasted on failed transactions

Proof of Concept

Below PoC demonstrate that the last item cannot be redeemed.

function test_OffByOne_LastItemUnredeemable() public {
uint256 maxSupply = 10;
uint256 priceInBeat = 100e18;
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Limited Poster", "ipfs://poster", priceInBeat, maxSupply, true
);
// Give user enough BEAT for all 10 items
vm.prank(address(festivalPass));
beatToken.mint(user, priceInBeat * maxSupply);
vm.startPrank(user);
// Mint items 1-9 successfully
for (uint256 i = 0; i < 9; i++) {
festivalPass.redeemMemorabilia(collectionId);
}
// Item 10 fails!
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
vm.stopPrank();
// User still has BEAT for 1 item but cannot redeem
assertEq(beatToken.balanceOf(user), priceInBeat);
}

Recommended Mitigation

Change currentItemId < maxSupply to currentItemId <= maxSupply, so that the last item can be redeemed.

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");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 11 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!