Root + Impact
Description
Expected behavior:
Each first-time user should receive a small Sepolia ETH drip the first time they claim tokens — while ensuring the faucet remains sustainable for future users.
Actual behavior:
The current logic uses a permanent flag:mapping(address => bool) private hasClaimedEth;
Once hasClaimedEth[user] is set to true, that user never receives ETH again, even if:
1.they’ve waited through multiple 3-day claim cycles,
2.the faucet’s ETH balance has been replenished, or
3the daily ETH drip cap has reset.
This creates permanent exclusion, and may prevent fair distribution if users lose or rotate wallets.
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
@>hasClaimedEth[faucetClaimer] = true;
...
}
Risk
Likelihood
1 High — This will affect all legitimate users over time.
2.Occurs naturally in normal operation.
Impact
1.Reduced usability: Users can never get ETH again even across long test periods.
2.Poor UX for testers: Faucet may become unusable for returning users who need fresh gas.
3.Operational inefficiency: Owners must manually top up or whitelist new addresses to continue testing.
Proof of Concept
Explanation
The test uses vm.warp(...) to advance time past the 3-day cooldown so that subsequent claimFaucetTokens() calls succeed in terms of token cooldown, but not ETH, which this PoC demonstrates.
The test funds the faucet initially and later calls the owner-only refillSepEth(...) to show that even after replenishing ETH, the user remains ineligible because the hasClaimedEth flag never resets.
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../contracts/RaiseBoxFaucet.sol";
contract EthDripOneTimePOC is Test {
RaiseBoxFaucet faucet;
address user = address(0xBEEF);
function setUp() public {
faucet = new RaiseBoxFaucet(
"RaiseBox Token",
"RBT",
1000 ether,
0.01 ether,
1 ether
);
(bool ok,) = address(faucet).call{value: 0.05 ether}("");
require(ok, "funding failed");
vm.deal(user, 0);
}
function test_user_receives_eth_only_once_even_after_refill() public {
uint256 userBefore = user.balance;
vm.prank(user);
faucet.claimFaucetTokens();
uint256 userAfterFirst = user.balance;
assertGt(userAfterFirst, userBefore, "User did not receive first-time ETH drip");
vm.warp(block.timestamp + 4 days);
vm.prank(user);
faucet.claimFaucetTokens();
uint256 userAfterSecond = user.balance;
assertEq(userAfterSecond, userAfterFirst, "User unexpectedly received ETH on second claim");
uint256 refillAmount = 0.1 ether;
faucet.refillSepEth{value: refillAmount}(refillAmount);
vm.warp(block.timestamp + 4 days);
vm.prank(user);
faucet.claimFaucetTokens();
uint256 userAfterThird = user.balance;
assertEq(userAfterThird, userAfterSecond, "User unexpectedly received ETH after owner refill");
}
receive() external payable {}
}
Recommended Mitigation
Explanation:
This change resets hasClaimedEth eligibility after the claim cooldown or a new faucet day. It prevents permanent exclusion and ensures sustainable, fair gas distribution for testnet users while maintaining protection against spam
- remove this code
+ add this code
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- hasClaimedEth[faucetClaimer] = true;
- ...
- }
+ uint256 currentDay = block.timestamp / 1 days;
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
+
+ if ((block.timestamp - lastClaimTime[faucetClaimer]) > CLAIM_COOLDOWN && !sepEthDripsPaused) {
+ hasClaimedEth[faucetClaimer] = false; // Reset eligibility after cooldown
+ }
+
+ if (!hasClaimedEth[faucetClaimer]) {
+ hasClaimedEth[faucetClaimer] = true;
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ require(success, "ETH drip failed");
+ }