Beatland Festival

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

Performance Cooldown Bypass Through Multiple Pass Ownership

Root + Impact

The attendance cooldown mechanism is enforced per address rather than per pass, allowing users with multiple passes to bypass intended rate limiting and farm excessive rewards.

Description

  • The normal behavior should prevent rapid consecutive performance check-ins to maintain fair reward distribution

  • The current implementation only tracks cooldown per address globally, but users can own multiple passes of different types and still be subject to the same single cooldown timer

mapping(address => uint256) public lastCheckIn;
uint256 constant COOLDOWN = 1 hours;
function attendPerformance(uint256 performanceId) external {
require(isPerformanceActive(performanceId), "Performance is not active");
require(hasPass(msg.sender), "Must own a pass");
require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
@> require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met"); // Global cooldown
hasAttended[performanceId][msg.sender] = true;
@> lastCheckIn[msg.sender] = block.timestamp; // Single timestamp for all passes
@> uint256 multiplier = getMultiplier(msg.sender); // Uses highest pass tier
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
}

Risk

Likelihood:

  • Users who can afford multiple pass types will naturally own them for maximum benefits

  • The getMultiplier function already encourages owning higher-tier passes

Impact:

  • Unfair advantage for users with multiple financial resources to buy multiple passes

  • Reward system imbalance favoring wealthy participants

  • Undermining of the intended cooldown mechanism's rate limiting purpose

  • Potential token inflation from excessive reward farming

Proof of Concept

function testCooldownBypass() public {
// User owns multiple pass types
address user = makeAddr("user");
// User can attend performance and get highest multiplier
vm.startPrank(user);
festival.buyPass{value: generalPrice}(GENERAL_PASS);
festival.buyPass{value: vipPrice}(VIP_PASS);
festival.buyPass{value: backstagePrice}(BACKSTAGE_PASS);
// Attend first performance - gets 3x multiplier (BACKSTAGE)
festival.attendPerformance(0);
uint256 firstBalance = beatToken.balanceOf(user);
// Wait for next performance (but within cooldown period)
vm.warp(block.timestamp + 30 minutes); // Less than 1 hour cooldown
// This should fail due to cooldown, and it does
vm.expectRevert("Cooldown period not met");
festival.attendPerformance(1);
// But the issue is user got maximum multiplier despite having multiple passes
// The cooldown applies globally but rewards are maximized
assert(firstBalance == baseReward * 3); // Got maximum multiplier
}

Recommended Mitigation

- mapping(address => uint256) public lastCheckIn;
+ mapping(address => mapping(uint256 => uint256)) public lastCheckInByPass; // per pass type
+
+ function attendPerformance(uint256 performanceId) external {
+ require(isPerformanceActive(performanceId), "Performance is not active");
+
+ // Determine which pass type to use for this attendance
+ uint256 passTypeUsed;
+ if (balanceOf(msg.sender, BACKSTAGE_PASS) > 0) {
+ passTypeUsed = BACKSTAGE_PASS;
+ } else if (balanceOf(msg.sender, VIP_PASS) > 0) {
+ passTypeUsed = VIP_PASS;
+ } else if (balanceOf(msg.sender, GENERAL_PASS) > 0) {
+ passTypeUsed = GENERAL_PASS;
+ } else {
+ revert("Must own a pass");
+ }
+
+ require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
- require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
+ require(block.timestamp >= lastCheckInByPass[msg.sender][passTypeUsed] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
- lastCheckIn[msg.sender] = block.timestamp;
+ lastCheckInByPass[msg.sender][passTypeUsed] = block.timestamp;
- uint256 multiplier = getMultiplier(msg.sender);
+ uint256 multiplier = getPassMultiplier(passTypeUsed);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
}
+ function getPassMultiplier(uint256 passType) public pure returns (uint256) {
+ if (passType == BACKSTAGE_PASS) return 3;
+ if (passType == VIP_PASS) return 2;
+ if (passType == GENERAL_PASS) return 1;
+ return 0;
+ }
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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