Description
Users earn BEAT tokens by calling attendPerformance during a live performance window. A 1-hour cooldown (COOLDOWN = 1 hours) prevents users from attending
performances back-to-back, acting as an anti-farming measure.
The cooldown (lastCheckIn[msg.sender]) is a single global timestamp shared across all performances. When the organizer legitimately schedules two performances
that overlap in time, attending the first one immediately locks the user out of the second for a full hour — even though the second is an entirely different
event. Users permanently forfeit rewards from the second concurrent performance even while holding a valid pass.
// src/FestivalPass.sol
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");
// ^^ single global timestamp — one attendance blocks ALL other performances for 1 hour
hasAttended[performanceId][msg.sender] = true;
@> lastCheckIn[msg.sender] = block.timestamp; // resets cooldown across all performances
...
}
Risk
Likelihood:
The organizer can create performances without any restriction on start time overlap — two performances starting simultaneously is a valid and expected scenario
for a multi-stage festival.
Every user who attends the first of two overlapping performances automatically loses eligibility for the second, with no warning or indication at check-in
time.
Impact:
Users lose BEAT rewards they were entitled to for performances they had a valid pass to attend.
For a festival with multiple concurrent stages, pass holders can only benefit from one stage per hour regardless of how many passes or how much ETH they spent
on premium tiers.
Proof of Concept
function test_ConcurrentPerformanceLockout() public {
vm.prank(user1);
festivalPass.buyPass{value: 0.05 ether}(1);
uint256 startTime = block.timestamp + 1 hours;
// Organizer creates two overlapping performances (Stage A and Stage B)
vm.startPrank(organizer);
uint256 stageA = festivalPass.createPerformance(startTime, 4 hours, 50e18);
uint256 stageB = festivalPass.createPerformance(startTime, 4 hours, 50e18);
vm.stopPrank();
vm.warp(startTime + 30 minutes);
// User attends Stage A — earns 50e18 BEAT, global cooldown starts
vm.prank(user1);
festivalPass.attendPerformance(stageA);
assertEq(beatToken.balanceOf(user1), 50e18);
// User tries to attend Stage B immediately — reverts despite being a different event
vm.prank(user1);
vm.expectRevert("Cooldown period not met");
festivalPass.attendPerformance(stageB); // 50e18 BEAT permanently forfeited
// Stage B ends before cooldown expires
vm.warp(startTime + 4 hours + 1);
assertFalse(festivalPass.isPerformanceActive(stageB));
// Stage B reward is gone forever
}
Recommended Mitigation
// src/FestivalPass.sol
mapping(address => uint256) public lastCheckIn;
mapping(uint256 => mapping(address => uint256)) public lastCheckIn;
function attendPerformance(uint256 performanceId) external {
...
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
require(block.timestamp >= lastCheckIn[performanceId][msg.sender] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
lastCheckIn[msg.sender] = block.timestamp;
lastCheckIn[performanceId][msg.sender] = block.timestamp;
...
}
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.