Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

No Per-Wallet Limit Enables Hoarding DoS

Hoarder Can Deny Other Users from Buying Passes in FestivalPass.sol::buyPass

Description

  • The buyPass function lets users purchase festival passes by sending the exact ETH price for the selected tier.

  • However, there is no limit on the number of passes one wallet can buy. As a result, one user or bot can buy the entire available supply for a pass tier, preventing other participants from purchasing passes fairly. This creates an economic Denial-of-Service (DoS) condition where fair distribution is impossible.

function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// @> No Checks for limit is used
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// @> No limit per wallet enforced here
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • Any user with enough ETH can mint repeatedly because there is no on-chain limit

  • Front-running bots can automate multiple purchases per block to buy out the supply quickly

Impact:

  • Legitimate users are denied access once supply is exhausted by the hoarder

  • The hoarder can resell passes at a markup, harming the fairness and reputation of the festival ecosystem

Proof of Concept

Add this code to your FestivalPassTest.sol file to confirm the proof

...
// Add the variable
address public attacker = makeAddr("attacker");
...
function setUp() public {
...
// Update your `setUp` function by adding this
vm.deal(attacker, 1000 ether);
}
function test_Economic_DoS() public {
// An Attacker buys out entire BACKSTAGE pass supply in a loop
vm.startPrank(attacker);
for (uint256 i = 0; i < BACKSTAGE_MAX_SUPPLY; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
}
vm.stopPrank();
// User1 attempts to buy one more BACKSTAGE pass
vm.startPrank(user1);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
vm.stopPrank();
// Result: No other user can buy BACKSTAGE passes anymore
}

Recommended Mitigation

This change uses a single hasPurchasedTier flag to ensure each address can only purchase one pass in total, regardless of the tier. Once a buyer purchases any pass, further purchases are blocked, enforcing a strict one-tier-per-user limit.

+ mapping(address => bool) public hasPurchasedTier;
function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
+ require(!hasPurchasedTier[msg.sender], "Address can only purchase one tier");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ hasPurchasedTier[msg.sender] = true;
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 25 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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