Skipped Sepolia ETH for First-Timers Locked Behind Cooldown Without Retry Mechanism
Description
In claimFaucetTokens, first-time claimers skipped for Sepolia ETH due to low balance keep hasClaimedEth false, but 3-day cooldown blocks retry. No separate ETH retry exists, risking frustrating denial for first timers if balance depletes again post-cooldown.
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
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 < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
Risk
Likelihood:
Impact:
Proof of Concept
function testSkippedSepEthFirstTimerLockedByCooldown() public {
vm.prank(owner);
raiseBoxFaucet.adjustDailyClaimLimit(200, true);
uint256 drip = raiseBoxFaucet.sepEthAmountToDrip();
uint256 initialBalance = raiseBoxFaucet.getContractSepEthBalance();
address[] memory depleters = new address[](400);
for (uint256 i = 0; i < 400; i++) {
depleters[i] = makeAddr(string(abi.encodePacked("depleter", i)));
}
for (uint256 i = 0; i < 100; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens();
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), initialBalance - 0.5 ether);
vm.warp(block.timestamp + 1 days + 1);
for (uint256 i = 100; i < 200; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens();
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), 0);
uint256 user1EthPre = user1.balance;
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
assertEq(user1.balance, user1EthPre);
assertFalse(raiseBoxFaucet.getHasClaimedEth(user1));
vm.deal(raiseBoxFaucetContractAddress, 1 ether);
vm.warp(block.timestamp + 1 days + 1);
vm.expectRevert(RaiseBoxFaucet.RaiseBoxFaucet_ClaimCooldownOn.selector);
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
for (uint256 i = 200; i < 300; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens();
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), initialBalance - 0.5 ether);
vm.warp(block.timestamp + 1 days + 1);
for (uint256 i = 300; i < 400; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens();
}
vm.warp(block.timestamp + 1 days);
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
assertEq(user1.balance, user1EthPre);
}
POC Explanation: Test depletes 1 ETH with 200 users over 2 days. User1 claims post-depletion: skips ETH, cooldown starts. Refills 1 ETH; during 3-day wait, 200 more users re-deplete. Post-cooldown retry: claims tokens but skips ETH again (balance 0), proving repeat denial.
Recommended Mitigation
+event SepEthClaimedLater(address indexed user, uint256 amount);
+error RaiseBoxFaucet_AlreadyClaimedEth();
+/// @notice Retry ETH for skipped first-timers, shorter cooldown
+function retrySepEthClaim() public {
+ if (hasClaimedEth[msg.sender]) revert RaiseBoxFaucet_AlreadyClaimedEth();
+ if (block.timestamp < lastClaimTime[msg.sender] + 1 days) revert RaiseBoxFaucet_ClaimCooldownOn(); // 1-day for retry
+
+ uint256 currentDay = block.timestamp / 24 hours;
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
+
+ if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ hasClaimedEth[msg.sender] = true;
+ dailyDrips += sepEthAmountToDrip;
+ (bool success,) = msg.sender.call{value: sepEthAmountToDrip}("");
+ require(success, "ETH transfer failed");
+ emit SepEthClaimedLater(msg.sender, sepEthAmountToDrip);
+ } else {
+ emit SepEthDripSkipped(msg.sender, "Retry failed");
+ }
+}
Mitigation Key Points: Add retrySepEthClaim for ETH-only retry (1-day cooldown, no tokens). Set flag on success. Emit event. Allows fair recovery without full claim wait; checks prevent abuse.