Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Impact: medium
Likelihood: high

One-time ETH drip logic may block legitimate future users

Author Revealed upon completion

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.

// Root cause in the codebase with @> marks to highlight the relevant section
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.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../contracts/RaiseBoxFaucet.sol"; // adjust path if needed
contract EthDripOneTimePOC is Test {
RaiseBoxFaucet faucet;
address user = address(0xBEEF);
// test contract will be owner because it deploys the faucet in setUp
function setUp() public {
// constructor(name, symbol, faucetDrip, sepEthDrip, dailySepEthCap)
faucet = new RaiseBoxFaucet(
"RaiseBox Token",
"RBT",
1000 ether, // faucetDrip (token amount, irrelevant for ETH PoC)
0.01 ether, // sepEthDrip (eth to drip on first claim)
1 ether // dailySepEthCap
);
// Fund faucet with initial Sepolia ETH to allow first drip
// test contract is owner so can send ETH directly to contract
(bool ok,) = address(faucet).call{value: 0.05 ether}("");
require(ok, "funding failed");
// Ensure user starts with zero ETH (clean slate for assertions)
vm.deal(user, 0);
}
function test_user_receives_eth_only_once_even_after_refill() public {
// ---------- First claim: should receive sepEth drip ----------
uint256 userBefore = user.balance;
vm.prank(user);
faucet.claimFaucetTokens(); // first claim => triggers ETH drip
uint256 userAfterFirst = user.balance;
assertGt(userAfterFirst, userBefore, "User did not receive first-time ETH drip");
// ---------- Warp past 3-day cooldown ----------
vm.warp(block.timestamp + 4 days);
// ---------- Second claim: should NOT receive ETH again ----------
vm.prank(user);
faucet.claimFaucetTokens(); // tokens may be received but ETH should NOT
uint256 userAfterSecond = user.balance;
assertEq(userAfterSecond, userAfterFirst, "User unexpectedly received ETH on second claim");
// ---------- Owner refills faucet ETH ----------
// test contract is owner (deployer), send more ETH to faucet via refill function
uint256 refillAmount = 0.1 ether;
faucet.refillSepEth{value: refillAmount}(refillAmount); // owner action
// ---------- Third claim (after refill): still should NOT receive ETH ----------
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");
}
// Allow the test contract to receive ETH if needed
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");
+ }

Support

FAQs

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