pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract MemorabiliaOffByOneBugPoC is Test {
FestivalPass public festivalPass;
MockBeatToken public beatToken;
address public organizer = makeAddr("organizer");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
beatToken = new MockBeatToken();
festivalPass = new FestivalPass(address(beatToken));
vm.prank(organizer);
festivalPass.setOrganizer(organizer, true);
beatToken.mint(user1, 1000 ether);
beatToken.mint(user2, 1000 ether);
beatToken.mint(user3, 1000 ether);
vm.prank(user1);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user2);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user3);
beatToken.approve(address(festivalPass), type(uint256).max);
}
* TEST 1: Demonstrates the off-by-one bug with maxSupply = 3
* Expected: 3 items should be redeemable
* Actual: Only 2 items can be redeemed, 3rd fails
*/
function testOffByOneBug_MaxSupply3() public {
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Test Collection",
"https://example.com/",
100 ether,
3,
true
);
(, , , uint256 maxSupply, uint256 currentItemId, bool isActive) =
festivalPass.collections(collectionId);
assertEq(maxSupply, 3, "Max supply should be 3");
assertEq(currentItemId, 1, "Current item ID should start at 1");
assertTrue(isActive, "Collection should be active");
vm.prank(user1);
festivalPass.redeemMemorabilia(collectionId);
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 2, "Current item ID should be 2 after first redemption");
vm.prank(user2);
festivalPass.redeemMemorabilia(collectionId);
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 3, "Current item ID should be 3 after second redemption");
vm.prank(user3);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
(, , , , currentItemId, ) = festivalPass.collections(collectionId);
assertEq(currentItemId, 3, "Current item ID stuck at 3, should be 4 if third redemption succeeded");
}
* TEST 2: Critical edge case - maxSupply = 1
* This is catastrophic: NO items can be redeemed from 1-of-1 collections!
*/
function testOffByOneBug_MaxSupply1_Critical() public {
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Rare 1-of-1 NFT",
"https://example.com/rare/",
500 ether,
1,
true
);
vm.prank(user1);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
assertEq(festivalPass.balanceOf(user1, _encodeTokenId(collectionId, 1)), 0,
"No tokens should have been minted");
}
* TEST 3: Demonstrates the pattern across different supply sizes
*/
function testOffByOneBug_VariousSupplySizes() public {
uint256[] memory supplySizes = new uint256[](4);
supplySizes[0] = 1;
supplySizes[1] = 2;
supplySizes[2] = 10;
supplySizes[3] = 100;
for (uint i = 0; i < supplySizes.length; i++) {
uint256 maxSupply = supplySizes[i];
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection ", i)),
"https://example.com/",
100 ether,
maxSupply,
true
);
for (uint j = 0; j < maxSupply - 1; j++) {
address user = makeAddr(string(abi.encodePacked("user_", i, "_", j)));
beatToken.mint(user, 1000 ether);
vm.prank(user);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(user);
festivalPass.redeemMemorabilia(collectionId);
}
address finalUser = makeAddr(string(abi.encodePacked("final_user_", i)));
beatToken.mint(finalUser, 1000 ether);
vm.prank(finalUser);
beatToken.approve(address(festivalPass), type(uint256).max);
vm.prank(finalUser);
vm.expectRevert("Collection sold out");
festivalPass.redeemMemorabilia(collectionId);
}
}
* Utility function to encode token ID (mimics the contract's logic)
*/
function _encodeTokenId(uint256 collectionId, uint256 itemId) internal pure returns (uint256) {
return (collectionId << 128) + itemId;
}
}