Root + Impact
Description
- 
Normal Behavior: Festival passes (NFTs) allow a user to attend a performance and earn BEAT tokens as a reward. Each wallet that attends a performance with a valid pass should be eligible for BEAT tokens once, reflecting fair and individual participation. 
 
- 
Issue: Users can transfer the same NFT pass across multiple wallets during the performance window. Each new wallet that receives the pass can independently call attendPerformance(), allowing the same NFT to be used multiple times. This enables attackers to farm BEAT tokens infinitely by looping transfers among controlled wallets.
 
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"
    );
    ...
}
Risk
Likelihood:
- NFT transfers are unrestricted during the performance time window. 
- Each new wallet receiving the NFT is treated as a unique eligible participant. 
- There is no mapping to track if a specific pass ID has already been used to attend the same performance. 
Impact:
- 
Attackers can farm unlimited BEAT tokens with a single VIP pass. 
- 
Token economy can be significantly diluted, undermining any token utility, reward system, or incentive structure. 
- 
Creates an unfair advantage for attackers while penalizing honest users. 
Proof of Concept
function test_Exploit_PassTransferFarming() public {
        address attacker = vm.addr(1);
        address[] memory alts = new address[](3);
        for (uint256 i = 0; i < 3; i++) {
            alts[i] = vm.addr(100 + i);
        }
        
        vm.deal(attacker, 1 ether);
        vm.prank(attacker);
        festivalPass.buyPass{value: VIP_PRICE}(2); 
        
        vm.startPrank(organizer);
        uint256 perfId = festivalPass.createPerformance(
            block.timestamp + 1 hours,
            2 hours,
            100e18
        );
        vm.stopPrank();
        
        vm.warp(block.timestamp + 90 minutes);
        
        for (uint256 i = 0; i < alts.length; i++) {
            address current = i == 0 ? attacker : alts[i - 1];
            address next = alts[i];
            
            vm.prank(current);
            festivalPass.safeTransferFrom(current, next, 2, 1, ""); 
            
            vm.prank(next);
            festivalPass.attendPerformance(perfId);
        }
        
        for (uint256 i = 0; i < alts.length; i++) {
            assertEq(beatToken.balanceOf(alts[i]), 200e18); 
        }
    }
Recommended Mitigation
+ mapping(uint256 => mapping(uint256 => bool)) public passUsedInPerformance; 
// passId => performanceId => used
function attendPerformance(uint256 performanceId, uint256 passId) external {
    require(isPerformanceActive(performanceId), "Performance is not active");
    require(
        passId == GENERAL_PASS || passId == VIP_PASS || passId == BACKSTAGE_PASS,
        "Invalid pass ID"
    );
    require(balanceOf(msg.sender, passId) > 0, "You do not own this pass");      // Must own this pass
    require(!hasAttended[performanceId][msg.sender], "Already attended");        // One attendance per address
     require(!passUsedInPerformance[passId][performanceId], "Pass already used"); // One use per pass
    require(
        block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN,
        "Cooldown period not met"
    );
    hasAttended[performanceId][msg.sender] = true;
    passUsedInPerformance[passId][performanceId] = true;
    lastCheckIn[msg.sender] = block.timestamp;
    uint256 multiplier = getMultiplier(msg.sender);
    BeatToken(beatToken).mint(
        msg.sender,
        performances[performanceId].baseReward * multiplier
    );
    emit Attended(
        msg.sender,
        performanceId,
        performances[performanceId].baseReward * multiplier
    );
}