Root + Impact
Description
-
The `attendPerformance` function tracks attendance per address via `hasAttended[performanceId][msg.sender]`, and tracks cooldown per address via `lastCheckIn[msg.sender]`. However, ERC1155 passes are transferable. A user can attend a performance, transfer their pass to another wallet they control, and attend the same performance again from that wallet.
-
This allows farming unlimited BEAT tokens from a single performance.
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");
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
lastCheckIn[msg.sender] = block.timestamp;
uint256 multiplier = getMultiplier(msg.sender);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
}
Risk
Likelihood:
Impact:
-
Unlimited BEAT token minting from a single performance
-
Complete inflation of BEAT token supply
-
Economic collapse of the memorabilia system
Proof of Concept
Below PoC demonstrates that unlimited BEAT token can be minted from a single performance.
function test_PassTransferFarming() public {
address wallet1 = makeAddr("wallet1");
address wallet2 = makeAddr("wallet2");
vm.deal(wallet1, 1 ether);
vm.prank(wallet1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(
block.timestamp + 1, 2 hours, 100e18
);
vm.warp(block.timestamp + 2);
vm.prank(wallet1);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(wallet1), 15e18 + 300e18);
vm.prank(wallet1);
festivalPass.safeTransferFrom(wallet1, wallet2, 3, 1, "");
vm.prank(wallet2);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(wallet2), 300e18);
}
Recommended Mitigation
Track attendance by pass token ID rather than by address, so that a holder can only mint once from a performance.
+mapping(uint256 => mapping(uint256 => bool)) public passAttendedPerformance; // passId => perfId => attended
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");
+ uint256 passId = _getOwnedPassId(msg.sender);
+ require(passId != 0, "Must own a pass");
+ require(!passAttendedPerformance[passId][performanceId], "Pass already used");
+ passAttendedPerformance[passId][performanceId] = true;