Beatland Festival

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

[M-1] configurePass() Unconditionally Resets Supply Counter Allowing Overselling

Root + Impact

The configurePass() function unconditionally resets passSupply[passId] to zero every time it's called, even when passes have already been sold. This allows the organizer to unintentionally (or maliciously) sell passes beyond the maxSupply limit by reconfiguring the pass after initial sales, breaking the protocol's scarcity guarantee.

Description

  • When an organizer reconfigures a pass (e.g., to change price or increase maxSupply), the existing supply count should be preserved. The passSupply variable should only be reset when explicitly intended, not as a side effect of configuration changes.

  • Any call to configurePass() resets passSupply[passId] to zero on line 59, regardless of current state. This means:

    • Changing the price resets the counter

    • Increasing the maxSupply resets the counter

    • Even reconfiguring with identical values resets the counter

// src/FestivalPass.sol - Lines 52-60
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;
// @> VULNERABILITY: Unconditionally resets supply to 0
passSupply[passId] = 0;
}

Risk

Likelihood:

  • Legitimate use case: Organizer changes pass price after initial sales

  • Legitimate use case: Organizer increases maxSupply to accommodate demand

  • No warning or confirmation that supply will be reset

  • Organizer may not realize this is happening

  • Common scenario in NFT/pass sales (dynamic pricing)

Impact:

  • Accurate tracking of sold passes (first N buyers forgotten)

  • Ability to enforce maxSupply limit (can sell 2x, 3x max)

  • Credibility and trust (scarcity promise broken)

  • Control over total supply (no upper bound)

Proof of Concept

The following test demonstrates selling 150 passes when maxSupply is 100:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract SupplyResetTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public organizer;
uint256 constant GENERAL_PASS = 1;
function setUp() public {
organizer = makeAddr("organizer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
}
function test_SupplyReset_AllowsOverselling() public {
console.log("\n=== SUPPLY RESET VULNERABILITY ===\n");
// Step 1: Configure GENERAL pass
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.05 ether, 100);
console.log("Step 1: Initial Configuration");
console.log(" Max Supply:", festivalPass.passMaxSupply(GENERAL_PASS));
console.log(" Current Supply:", festivalPass.passSupply(GENERAL_PASS));
// Step 2: Sell 50 passes to users
console.log("\nStep 2: Selling 50 passes...");
for (uint256 i = 0; i < 50; i++) {
address buyer = makeAddr(string(abi.encodePacked("buyer", i)));
vm.deal(buyer, 0.05 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.05 ether}(GENERAL_PASS);
}
console.log(" Passes sold:", festivalPass.passSupply(GENERAL_PASS));
assertEq(festivalPass.passSupply(GENERAL_PASS), 50, "Should have 50 passes sold");
// Step 3: Organizer changes price (legitimate operation!)
console.log("\nStep 3: Organizer changes price from 0.05 ETH to 0.08 ETH");
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.08 ether, 100);
// Step 4: BUG - Supply was reset!
console.log("\n[!] BUG TRIGGERED:");
console.log(" Supply BEFORE reconfigure: 50");
console.log(" Supply AFTER reconfigure:", festivalPass.passSupply(GENERAL_PASS));
assertEq(festivalPass.passSupply(GENERAL_PASS), 0, "Supply was reset to 0!");
// Step 5: Sell 100 MORE passes at new price
console.log("\nStep 5: Selling 100 MORE passes at new price...");
for (uint256 i = 0; i < 100; i++) {
address buyer = makeAddr(string(abi.encodePacked("newbuyer", i)));
vm.deal(buyer, 0.08 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.08 ether}(GENERAL_PASS);
}
console.log(" Additional passes sold:", festivalPass.passSupply(GENERAL_PASS));
// Step 6: Calculate total
uint256 totalInCirculation = 50 + 100;
console.log("\n=== FINAL STATE ===");
console.log(" First batch (old price): 50");
console.log(" Second batch (new price): 100");
console.log(" Total in circulation:", totalInCirculation);
console.log(" Max supply:", festivalPass.passMaxSupply(GENERAL_PASS));
console.log(" Oversold by:", totalInCirculation - festivalPass.passMaxSupply(GENERAL_PASS));
console.log("\n[!] VULNERABILITY CONFIRMED:");
console.log(" 150 passes exist when max should be 100");
console.log(" Protocol oversold by 50 passes (50%% over limit!)\n");
assertGt(totalInCirculation, festivalPass.passMaxSupply(GENERAL_PASS), "Oversold!");
}
function test_SupplyReset_MultipleReconfigurations() public {
console.log("\n=== MULTIPLE RECONFIGURATIONS ===\n");
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.05 ether, 100);
// Round 1: Sell 30
for (uint256 i = 0; i < 30; i++) {
address buyer = makeAddr(string(abi.encodePacked("r1", i)));
vm.deal(buyer, 0.05 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.05 ether}(GENERAL_PASS);
}
console.log("Round 1: Sold 30, supply =", festivalPass.passSupply(GENERAL_PASS));
// Reconfigure (reset!)
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.06 ether, 100);
console.log("After reconfigure #1, supply =", festivalPass.passSupply(GENERAL_PASS));
// Round 2: Sell 40
for (uint256 i = 0; i < 40; i++) {
address buyer = makeAddr(string(abi.encodePacked("r2", i)));
vm.deal(buyer, 0.06 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.06 ether}(GENERAL_PASS);
}
console.log("Round 2: Sold 40, supply =", festivalPass.passSupply(GENERAL_PASS));
// Reconfigure again (reset again!)
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.07 ether, 100);
console.log("After reconfigure #2, supply =", festivalPass.passSupply(GENERAL_PASS));
// Round 3: Sell 50
for (uint256 i = 0; i < 50; i++) {
address buyer = makeAddr(string(abi.encodePacked("r3", i)));
vm.deal(buyer, 0.07 ether);
vm.prank(buyer);
festivalPass.buyPass{value: 0.07 ether}(GENERAL_PASS);
}
uint256 total = 30 + 40 + 50;
console.log("\n=== RESULTS ===");
console.log("Round 1: 30 passes");
console.log("Round 2: 40 passes");
console.log("Round 3: 50 passes");
console.log("Total:", total);
console.log("Max:", 100);
console.log("Oversold by:", total - 100);
assertGt(total, 100, "Multiple reconfigurations compound overselling");
}
}

Recommended Mitigation

Remove the unconditional supply reset. Only reset supply when explicitly intended (e.g., new pass creation):

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;
+ // Only reset supply if this is initial configuration
+ // or add separate function for pass initialization
}
**Alternative: Add supply preservation check:**
```diff
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 reduce max below current supply");
passPrice[passId] = price;
passMaxSupply[passId] = maxSupply;
- passSupply[passId] = 0;
+ // Preserve existing supply count
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] [H-1] Reseting the current pass supply to 0 in the FestivalPass::configurePass function allows users to bypass the max supply cap of a pass

# \[H-1] Reseting the current pass supply to `0` in the `FestivalPass::configurePass` function allows users to bypass the max supply cap of a pass ## Description: ```solidity 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; // Reset current supply } ``` If you reset `passSupply[passId]` to `0` in the `FestivalPass::configurePass` function after passes have been sold, the next buyer will be able to mint as if no passes have been sold. This allows the total minted passes to exceed `passMaxSupply`, which is a serious vulnerability (a supply cap bypass) ## Impact: * Supply caps become meaningless: The users can mint unlimited passes beyond the intended maximum supply * Pass scarcity and value are destroyed, affecting the economic model ## Proof of Concept: ```solidity function test_SupplyCapBypassVulnerability() public { // Step 1: Configure a pass with max supply of 2 vm.prank(organizer); festivalPass.configurePass(1, GENERAL_PRICE, 2); // Step 2: Buy 2 passes (reaching max supply) vm.prank(user1); festivalPass.buyPass{value: GENERAL_PRICE}(1); vm.prank(user2); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Verify max supply reached assertEq(festivalPass.passSupply(1), 2); assertEq(festivalPass.passMaxSupply(1), 2); // Step 3: Try to buy another pass - should fail address user3 = makeAddr("user3"); vm.deal(user3, 10 ether); vm.prank(user3); vm.expectRevert("Max supply reached"); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 4: VULNERABILITY - Organizer reconfigures the pass // This resets passSupply[1] to 0, bypassing the supply cap! vm.prank(organizer); festivalPass.configurePass(1, GENERAL_PRICE, 2); // Step 5: Now we can buy more passes even though max supply was already reached vm.prank(user3); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 6: We can even buy more passes beyond the original max supply vm.deal(user4, 10 ether); vm.prank(user4); festivalPass.buyPass{value: GENERAL_PRICE}(1); // Step 7: Verify the vulnerability - total supply exceeds max supply assertEq(festivalPass.passSupply(1), 2); // Current supply counter assertEq(festivalPass.passMaxSupply(1), 2); // Max supply limit // But we actually have 4 passes minted in total! assertEq(festivalPass.balanceOf(user1, 1), 1); assertEq(festivalPass.balanceOf(user2, 1), 1); assertEq(festivalPass.balanceOf(user3, 1), 1); assertEq(festivalPass.balanceOf(user4, 1), 1); // Total minted: 4 passes, but max supply is only 2! uint256 totalMinted = festivalPass.balanceOf(user1, 1) + festivalPass.balanceOf(user2, 1) + festivalPass.balanceOf(user3, 1) + festivalPass.balanceOf(user4, 1); assertGt(totalMinted, festivalPass.passMaxSupply(1), "VULNERABILITY: Total minted exceeds max supply!"); } ``` ## Recommended Mitigation: The `passSupply` reset should be removed ```diff 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; } ```

Support

FAQs

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

Give us feedback!