Beatland Festival

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

Missing Bonus Claim Restriction on `buyPass()` Lets Users Exploit BEAT Minting Mechanism

Root + Impact

Description

  • When a user purchases a festival pass via the buyPass() function, they are granted a ERC1155 token corresponding to the pass type. Additionally, VIP and Backstage pass holders receive a one-time welcome bonus of BEAT tokens as an incentive.

  • There is no mechanism in place to track whether a user has already received a BEAT welcome bonus. As a result, a user can repeatedly call buyPass() with collectionId == BACKSTAGE_PASS, paying the required ETH fee each time, and receive the 15 BEAT bonus on every purchase. This allows a malicious user to farm BEAT tokens by looping purchases, potentially draining the BEAT token supply and breaking its intended reward distribution system.

// Buy a festival pass
function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
@> uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
@> if (bonus > 0) {
@> // Mint BEAT tokens to buyer
@> BeatToken(beatToken).mint(msg.sender, bonus);
@> }
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • This will occur when any user purchases a VIP or BACKSTAGE pass and receives the BEAT welcome bonus, then purchases additional passes of the same type and receives the bonus again—since there is no restriction or tracking mechanism to prevent repeated bonuses.

  • This is especially likely in a live environment where users become aware that the bonus can be farmed and intentionally exploit the system to repeatedly gain free BEAT tokens at a fixed cost, draining token supply.

Impact:

  • Users can farm BEAT tokens indefinitely, depleting the token's supply or undermining its intended distribution mechanics, which could severely affect tokenomics and fairness.

  • This undermines trust in the platform’s economic model and could lead to inflation on BEAT, reducing its actual value, and affecting the broader system’s financial balance.

Proof of Concept

Add this test to FestivalPass.t.sol and run with forge test --mt testCanFarmBackstageBonus -vv.

function testCanFarmBackstageBonus() public {
// Amount of bonus tokens granted to Backstage Pass holders
uint256 backstageBonus = 15e18;
vm.startPrank(user1);
// user1 buys a Backstage Pass 10 times
for (uint256 i = 0; i < 10; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
}
vm.stopPrank();
// user1 farms a total of 150 Beat Tokens
uint256 expected = backstageBonus * 10;
uint256 actual = beatToken.balanceOf(user1);
console.log("Expected total BEAT tokens farmed: ", expected / 1e18);
console.log("Actual total BEAT tokens farmed: ", actual / 1e18);
assertEq(actual, expected, "Should be able to farm BEAT with repeated BACKSTAGE purchases");
}

Recommended Mitigation

Track whether a user has already claimed their welcome BEAT bonus for each pass type and ensure the bonus is only awarded once. This can be done by maintaining a mapping that records whether a user has received the bonus for a given pass type.

+ mapping(address => mapping(uint256 => bool)) public hasClaimedBonus;
.
.
.
// Buy a festival pass
function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
- uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
- if (bonus > 0) {
- // Mint BEAT tokens to buyer
- BeatToken(beatToken).mint(msg.sender, bonus);
- }
+ uint256 bonus = 0;
+ if (collectionId == VIP_PASS && !hasClaimedBonus[msg.sender][VIP_PASS]) {
+ bonus = 5e18;
+ hasClaimedBonus[msg.sender][VIP_PASS] = true;
+ } else if (collectionId == BACKSTAGE_PASS && !hasClaimedBonus[msg.sender][BACKSTAGE_PASS]) {
+ bonus = 15e18;
+ hasClaimedBonus[msg.sender][BACKSTAGE_PASS] = true;
+ }
+ if (bonus > 0) {
+ BeatToken(beatToken).mint(msg.sender, bonus);
+ }
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

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

Support

FAQs

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