Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

[H-2] Pass holder can farm unlimited BEAT tokens by transferring pass between wallets

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"); // Only checks current ownership
require(!hasAttended[performanceId][msg.sender], "Already attended"); // Per-address tracking
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:

  • Any pass holder with multiple wallets can exploit this

  • ERC1155 `safeTransferFrom` is a standard feature users will naturally use

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);
// Wallet1 buys backstage pass (3x multiplier)
vm.prank(wallet1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
// Create performance with 100 BEAT reward
vm.prank(organizer);
uint256 perfId = festivalPass.createPerformance(
block.timestamp + 1, 2 hours, 100e18
);
vm.warp(block.timestamp + 2);
// Wallet1 attends - gets 300 BEAT (100 * 3x)
vm.prank(wallet1);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(wallet1), 15e18 + 300e18); // bonus + reward
// Transfer pass to wallet2
vm.prank(wallet1);
festivalPass.safeTransferFrom(wallet1, wallet2, 3, 1, "");
// Wallet2 attends SAME performance - gets another 300 BEAT!
vm.prank(wallet2);
festivalPass.attendPerformance(perfId);
assertEq(beatToken.balanceOf(wallet2), 300e18);
// Can repeat infinitely with more wallets
}

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;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 11 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!