Description
The FestivalPass::configurePass function unconditionally resets the passSupply[passId] counter to 0, regardless of how many passes have already been minted and are currently in circulation. This creates a critical accounting mismatch between the actual number of NFTs in circulation and the contract's internal supply tracking.
function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer {
require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID");
require(price > 0, "Price must be greater than 0");
require(maxSupply > 0, "Max supply must be greater than 0");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
@> passSupply[passId] = 0;
}
The buyPass function relies on passSupply[collectionId] < passMaxSupply[collectionId] to enforce supply limits. When the supply counter is reset to 0, users can mint up to the full maxSupply amount again, completely bypassing the intended scarcity mechanism.
Impact
-
Supply Limit Bypass: Users can mint significantly more passes than the intended maximum supply, potentially doubling or tripling the total circulation
-
Economic Fraud: Holders who paid for "limited edition" passes are defrauded as their passes lose scarcity value
-
Revenue Loss: The protocol may lose revenue from artificially deflated pass prices due to oversupply
-
Trust Violation: Breaks fundamental promises about NFT scarcity and maximum supply limits
-
Market Manipulation: Malicious organizers can intentionally flood the market by repeatedly calling configurePass
Proof of Concept
function testMaxSupplyBypassThroughSupplyReset() public {
console.log("Total max supply of Backstage pass is ", festivalPass.passMaxSupply(3));
console.log("Total current supply of Backstage pass is ", festivalPass.passSupply(3));
vm.startPrank(user1);
for (uint256 i = 0; i < 50; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
}
vm.stopPrank();
assertEq(festivalPass.balanceOf(user1, 3), 50);
assertEq(festivalPass.passSupply(3), 50);
console.log("Total current supply after user purchased: ", festivalPass.passSupply(3));
vm.prank(organizer);
uint256 passId = 3;
uint256 newPrice = 0.5 ether;
uint256 newMaxSupply = 200;
festivalPass.configurePass(passId, newPrice, newMaxSupply);
console.log("New Total max supply of Backstage pass is ", festivalPass.passMaxSupply(3));
console.log("New Total current supply of Backstage pass is ", festivalPass.passSupply(3));
console.log("But User has 50 Backstage passes even though current circulation shows 0");
assertTrue(festivalPass.passSupply(3) == 0);
assertEq(festivalPass.balanceOf(user1, 3), 50);
}
Logs:
Total max supply of Backstage pass is 100
Total current supply of Backstage pass is 0
Total current supply after user purchased: 50
New Total max supply of Backstage pass is 200
New Total current supply of Backstage pass is 0
But User has 50 Backstage passes even though current circulation shows 0
Recommended Mitigation:
Remove the supply counter reset and add validation to prevent reducing max supply below current circulation:
function configurePass(uint256 passId, uint256 price, uint256 maxSupply) external onlyOrganizer {
require(passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS, "Invalid pass ID");
require(price > 0, "Price must be greater than 0");
require(maxSupply > 0, "Max supply must be greater than 0");
+ require(maxSupply >= passSupply[passId], "Cannot set max supply below current supply");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
- passSupply[passId] = 0; // Reset current supply
}
This ensures that:
The supply counter accurately reflects the actual number of passes in circulation
Maximum supply cannot be reduced below the current circulating supply
The scarcity mechanism works as intended
Existing pass holders' investments are protected