Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: low
Valid

Skipped Sepolia ETH for First-Timers Locked Behind Cooldown Without Retry Mechanism

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;
// dailyClaimCount = 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;
}
// Cooldown check earlier
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}

Risk

Likelihood:

  • Balance depletes via claims.

  • Refill occurs but re-depletes before retry.

Impact:

  • First-timers lose ETH on repeat skips.

  • Reduces faucet fairness.

Proof of Concept

function testSkippedSepEthFirstTimerLockedByCooldown() public {
// Increase limit to allow depletion without claim cap hit
vm.prank(owner);
raiseBoxFaucet.adjustDailyClaimLimit(200, true); // Now 300
uint256 drip = raiseBoxFaucet.sepEthAmountToDrip();
uint256 initialBalance = raiseBoxFaucet.getContractSepEthBalance(); // 1 ether
// Create 400 new users to deplete over 4 days
address[] memory depleters = new address[](400);
for (uint256 i = 0; i < 400; i++) {
depleters[i] = makeAddr(string(abi.encodePacked("depleter", i)));
}
// Day 1: 100 claims, deplete 0.5 ETH
for (uint256 i = 0; i < 100; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens(); // ETH dripped
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), initialBalance - 0.5 ether);
// Warp to Day 2, reset dailyDrips and dailyClaimCount
vm.warp(block.timestamp + 1 days + 1); // Ensure >1 day
// Day 2: 100 more claims, deplete remaining 0.5 ETH
for (uint256 i = 100; i < 200; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens(); // ETH dripped
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), 0);
// User1 first claim: tokens yes, ETH skipped (balance < drip), cooldown starts
uint256 user1EthPre = user1.balance;
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
assertEq(user1.balance, user1EthPre); // No ETH
assertFalse(raiseBoxFaucet.getHasClaimedEth(user1));
// Simulate owner refilling minimally to show risk of repeat skip
vm.deal(raiseBoxFaucetContractAddress, 1 ether);
// Warp 1 day: new day, but cooldown (3 days) blocks retry for user1 to claim ether as first timer
vm.warp(block.timestamp + 1 days + 1);
vm.expectRevert(RaiseBoxFaucet.RaiseBoxFaucet_ClaimCooldownOn.selector);
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
// Day 3: 100 claims, deplete 0.5 ETH from refill
for (uint256 i = 200; i < 300; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens(); // ETH dripped
}
assertEq(raiseBoxFaucet.getContractSepEthBalance(), initialBalance - 0.5 ether);
// Warp to Day 4, reset dailyDrips and dailyClaimCount
vm.warp(block.timestamp + 1 days + 1); // Ensure >1 day
// Day 4: 100 more claims, deplete remaining 0.5 ETH
for (uint256 i = 300; i < 400; i++) {
vm.prank(depleters[i]);
raiseBoxFaucet.claimFaucetTokens(); // ETH dripped
}
// Warp remaining 1 day: cooldown passed (total 4 days from user1 claim)
vm.warp(block.timestamp + 1 days);
// Balance still 0 (no other refill), retry skips ETH
vm.prank(user1);
raiseBoxFaucet.claimFaucetTokens();
assertEq(user1.balance, user1EthPre); // Still no ETH, repeat skip
}

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

User can't retry for ETH without waiting 3 days, even though they never received it

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.