Raisebox Faucet

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

High — Daily ETH Cap Bypass via dailyDrips = 0 Reset in else Branch (non-first-time/paused path)

Author Revealed upon completion

Root + Impact

Description

  • Normal behaviour: The contract should track how much Sepolia ETH is dripped per calendar day in dailyDrips, and enforce dailySepEthCap so the faucet cannot exceed the daily ETH budget. The counter should reset once per new day only.

  • Problem: In claimFaucetTokens(), the else branch (executed when the caller has already claimed ETH before, or when drips are paused) sets dailyDrips = 0. This lets any non-first-time claimer (or calls during pause) forcibly reset the counter mid-day, allowing subsequent first-time claimers to receive ETH even after the daily cap has been reached.

// Root cause in the codebase with @> marks to highlight the relevant section
function claimFaucetTokens() public {
...
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");
...
} else {
emit SepEthDripSkipped(...);
}
} else {
dailyDrips = 0; // @> BUG: resets dailyDrips mid-day for non-first/pause path
}
...
}

Risk

Likelihood:

  • Occurs whenever a non-first-time claimer calls claimFaucetTokens() during the same day, or during periods where sepEthDripsPaused is true.

  • Happens under normal usage: once some first-timers have consumed part of the daily cap, any subsequent non-first-time claim can zero out the counter.


Impact:

  • Daily ETH cap is bypassed, allowing the contract to distribute more ETH than budgeted for the day.

  • ETH funds can be drained faster than intended, breaking expected economic controls.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract DailyCapBypassPoC is Test {
RaiseBoxFaucet faucet;
address firstTimer1 = address(0xA1);
address firstTimer2 = address(0xA2);
address nonFirst = address(0xB1);
function setUp() public {
faucet = new RaiseBoxFaucet("raiseboxtoken","RB", 1000e18, 0.01 ether, 0.02 ether);
vm.deal(address(faucet), 10 ether); // fund faucet with enough ETH
// advance time so cooldowns aren't an issue for first calls
vm.warp(block.timestamp + 1);
}
function test_BypassDailyCap() public {
// 1) First-timer #1 claims ETH: dailyDrips = 0.01
vm.prank(firstTimer1);
faucet.claimFaucetTokens();
// 2) First-timer #2 claims ETH: dailyDrips = 0.02 == dailySepEthCap (cap reached)
vm.prank(firstTimer2);
faucet.claimFaucetTokens();
// 3) Non-first-time user calls (already claimed earlier in a prior day in real scenarios).
// For the PoC, simulate they have claimed before:
// Directly mark hasClaimedEth to emulate a previous claim
// (In a full test you'd do a claim on a previous day, then warp back to "today".)
vm.store(
address(faucet),
// storage slot for hasClaimedEth[nonFirst] is omitted here for brevity in PoC
// In a full Foundry PoC, compute the storage slot or call once on an earlier day.
bytes32(uint256(0)) // placeholder: in real PoC, set hasClaimedEth[nonFirst] = true properly
);
// Call as non-first-time claimant to hit the `else` branch:
vm.prank(nonFirst);
faucet.claimFaucetTokens(); // BUG: dailyDrips reset to 0
// 4) Now another first-timer can claim and receive ETH again even though cap was reached
address firstTimer3 = address(0xA3);
vm.prank(firstTimer3);
faucet.claimFaucetTokens(); // Should be blocked by cap, but succeeds due to reset
}
}

Explanation: The sequence consumes the daily cap with two first-time claims (0.01 + 0.01 = 0.02). A non-first-time claimer then calls claimFaucetTokens() and triggers the else branch, which sets dailyDrips = 0. This mid-day reset allows another first-time claimer to receive ETH again, exceeding the daily budget. In a full Foundry test, you would prepare nonFirst as a returning user by performing their initial claim on a prior day (or manipulating storage/warp) to ensure they hit the else path.

Recommended Mitigation

- dailyDrips = 0;
- }
+ } else {
+ // Do NOT reset dailyDrips here.
+ // Non-first-time claimers or paused state must not affect the daily ETH counter.
+ }
- /**
- * @param lastFaucetDripDay tracks the last day a claim was made
- * @notice resets the @param dailyClaimCount every 24 hours
- */
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
+ // Use a single, consistent day-bucket reset at the start of the function:
+ // uint256 currentDay = block.timestamp / 1 days;
+ // if (currentDay > lastDripDay) {
+ // lastDripDay = currentDay;
+ // dailyDrips = 0; // reset ONLY at day boundary
+ // dailyClaimCount = 0; // optional: if claim count is also per-day
+ // }

Explanation: DailyDrips must reset only when the calendar day changes, not based on caller type or paused state. Remove the dailyDrips = 0 assignment from the else path and centralize all “daily” resets behind a day-bucket check (block.timestamp / 1 days).

Support

FAQs

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