Raisebox Faucet

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

Lock on faucet tokens

Root + Impact

Description

  • The contract must emit tokens as long as it has enough balance to cover the faucetDrip value. The balance check is intended for preventing claims where the contract lacks sufficient tokens to cover the drip value

  • The balance check is done with a less than or equal to operator instead of a less than operator, causing the function to revert when the contract balance is exactly equal to faucetDrip. This prevents the contract from paying out its final faucetDrip amount, making those tokens stuck forever within the contract

function claimFaucetTokens() public {
// ... other checks ...
@> if (balanceOf(address(this)) <= faucetDrip) {
@> revert RaiseBoxFaucet_InsufficientContractBalance();
@> }
// ... rest of function ...
_transfer(address(this), faucetClaimer, faucetDrip);
}

Risk

Likelihood:

  • This condition occurs naturally when the contract balance decreases to exactly faucetDrip

  • No external manipulation required

Impact:

  • The final faucetDrip amount becomes permanently locked

  • Requires owner to mint additional tokens to continue faucet operations

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract OffByOneTest is Test {
RaiseBoxFaucet public faucet;
address public owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
faucet = new RaiseBoxFaucet(
"TestToken",
"TEST",
1000 * 10**18, // faucetDrip
0.005 ether, // sepEthDrip
0.1 ether // dailySepEthCap
);
vm.deal(address(faucet), 10 ether);
vm.stopPrank();
}
function testOffByOneLocksFinalDrip() public {
console.log("=== Off by one ===");
console.log("");
vm.prank(owner);
faucet.burnFaucetTokens(faucet.balanceOf(address(faucet)) - 2000 * 10**18);
uint256 initialBalance = faucet.balanceOf(address(faucet));
console.log("Initial faucet balance:", initialBalance / 1e18, "tokens");
console.log("Faucet drip amount:", 1000, "tokens");
console.log("");
// First user claims
address user1 = makeAddr("user1");
vm.prank(user1);
faucet.claimFaucetTokens();
uint256 balanceAfterFirstClaim = faucet.balanceOf(address(faucet));
console.log("Balance after first claim:", balanceAfterFirstClaim / 1e18, "tokens");
console.log("User1 received:", faucet.balanceOf(user1) / 1e18, "tokens");
console.log("");
// Now exactly 1000 tokens remain
assertEq(balanceAfterFirstClaim, 1000 * 10**18, "Should have exactly 1000 tokens left");
// Second user tries to claim
vm.warp(block.timestamp + 3 days + 1);
address user2 = makeAddr("user2");
console.log("Second user attempting to claim...");
console.log("Balance check: balanceOf(contract) <= faucetDrip");
console.log("Balance check:", balanceAfterFirstClaim / 1e18, "<=", 1000);
console.log("Result: TRUE - REVERTS");
console.log("");
vm.prank(user2);
vm.expectRevert(RaiseBoxFaucet.RaiseBoxFaucet_InsufficientContractBalance.selector);
faucet.claimFaucetTokens();
uint256 finalBalance = faucet.balanceOf(address(faucet));
console.log("=== Results ===");
console.log("Final locked balance:", finalBalance / 1e18, "tokens");
console.log("User2 received:", faucet.balanceOf(user2) / 1e18, "tokens");
console.log("");
assertEq(finalBalance, 1000 * 10**18, "1000 tokens should be locked");
assertEq(faucet.balanceOf(user2), 0, "User2 should have received nothing");
}
function testCorrectBehaviorWithMoreTokens() public {
console.log("=== Normal claim with sufficient balance ===");
console.log("");
// Burn to leave 1001 tokens
vm.prank(owner);
faucet.burnFaucetTokens(faucet.balanceOf(address(faucet)) - 1001 * 10**18);
uint256 balance = faucet.balanceOf(address(faucet));
console.log("Faucet balance:", balance / 1e18, "tokens");
console.log("Faucet drip:", 1000, "tokens");
console.log("");
address user = makeAddr("normalUser");
vm.prank(user);
faucet.claimFaucetTokens();
console.log("Claim succeeded");
console.log("User received:", faucet.balanceOf(user) / 1e18, "tokens");
console.log("Remaining balance:", faucet.balanceOf(address(faucet)) / 1e18, "tokens");
console.log("");
console.log("Works correctly when balance > faucetDrip");
}
}

Recommended Mitigation

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
- if (balanceOf(address(this)) <= faucetDrip) {
+ if (balanceOf(address(this)) < faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
// ... rest of function
}
Updates

Lead Judging Commences

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

Off-by-one error in `claimFaucetTokens` prevents claiming when the balance is exactly equal to faucetDrip

Support

FAQs

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