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.
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.
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
Observed output (excerpt):
Item IDs run from 1 to maxSupply inclusive, so the guard must allow currentItemId == maxSupply.
# 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); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.