Raisebox Faucet

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

Incorrect Balance Check Prevents Last Token Claim

Description

The claimFaucetTokens() function uses an incorrect comparison operator (<= instead of <) when checking if the contract has sufficient tokens. This prevents the last faucetDrip amount of tokens from ever being claimed, effectively locking those tokens in the contract permanently.

Expected Behavior

Users should be able to claim tokens as long as the contract balance is greater than or equal to faucetDrip. The last user should be able to claim when balanceOf(address(this)) == faucetDrip.

Actual Behavior

The function reverts when balanceOf(address(this)) == faucetDrip, preventing the last claim even though there are exactly enough tokens available. This leaves faucetDrip amount of tokens permanently locked in the contract.

Root Cause

The balance check on line 175 uses the wrong comparison operator:

function claimFaucetTokens() public {
// ... other checks ...
if (balanceOf(address(this)) <= faucetDrip) { // ⚠️ WRONG: Should be <
revert RaiseBoxFaucet_InsufficientContractBalance();
}
// ... rest of function ...
_transfer(address(this), faucetClaimer, faucetDrip);
}

Problem Analysis:

  • If contract has 1000 tokens and faucetDrip = 1000 tokens

  • Condition: 1000 <= 1000 evaluates to true

  • Function reverts even though there are exactly enough tokens for one more claim

  • The 1000 tokens become permanently locked

Risk Assessment

Impact

Medium impact because:

  1. Permanent token lock: The last faucetDrip amount of tokens can never be claimed

  2. Wasted resources: Tokens that should be distributed remain locked

  3. Unfair to users: One user is denied their rightful claim

  4. Reduced utility: The faucet cannot fully distribute its token supply

  5. Owner intervention required: Owner must mint new tokens or users lose access

Likelihood

High likelihood because:

  • Happens every time the contract balance reaches exactly faucetDrip

  • Inevitable in normal operation as tokens are claimed

  • No workaround available for users

  • Affects every deployment of this contract

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract InsufficientBalanceCheckTest is Test {
RaiseBoxFaucet faucet;
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address owner;
function setUp() public {
owner = address(this);
faucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18, // 1000 tokens per claim
0.005 ether,
0.5 ether
);
vm.warp(3 days);
}
function testLastTokensCannotBeClaimed() public {
uint256 initialBalance = faucet.balanceOf(address(faucet));
uint256 faucetDripAmount = faucet.faucetDrip();
console.log("Initial contract balance:", initialBalance / 1e18, "tokens");
console.log("Faucet drip amount:", faucetDripAmount / 1e18, "tokens");
// Transfer tokens OUT to leave exactly faucetDrip amount
// Note: We use transfer() instead of burnFaucetTokens() to avoid that function's bug
uint256 amountToTransfer = initialBalance - faucetDripAmount;
vm.prank(address(faucet));
faucet.transfer(owner, amountToTransfer);
uint256 remainingBalance = faucet.balanceOf(address(faucet));
console.log("Remaining balance after transfer:", remainingBalance / 1e18, "tokens");
console.log("Remaining balance equals faucetDrip?", remainingBalance == faucetDripAmount);
// Try to claim the last tokens
vm.warp(3 days);
vm.prank(user1);
vm.expectRevert(RaiseBoxFaucet.RaiseBoxFaucet_InsufficientContractBalance.selector);
faucet.claimFaucetTokens();
console.log("");
console.log("RESULT: Claim reverted even though balance == faucetDrip");
console.log("The last", faucetDripAmount / 1e18, "tokens are permanently locked!");
// Verify tokens are still locked
assertEq(faucet.balanceOf(address(faucet)), faucetDripAmount, "Tokens remain locked");
assertEq(faucet.balanceOf(user1), 0, "User received nothing");
}
function testMultipleUsersExhaustSupply() public {
// Create a scenario with small supply
RaiseBoxFaucet smallFaucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18, // 1000 tokens per claim
0.005 ether,
0.5 ether
);
// Transfer out most tokens, leave exactly 3000 tokens (3 claims worth)
// Note: We use transfer() instead of burnFaucetTokens() to avoid that function's bug
uint256 initialBalance = smallFaucet.balanceOf(address(smallFaucet));
uint256 amountToTransfer = initialBalance - (3000 * 10 ** 18);
vm.prank(address(smallFaucet));
smallFaucet.transfer(owner, amountToTransfer);
console.log("=== Testing with 3000 tokens (3 claims) ===");
console.log("Contract balance:", smallFaucet.balanceOf(address(smallFaucet)) / 1e18, "tokens");
vm.warp(3 days);
// User1 claims successfully
vm.prank(user1);
smallFaucet.claimFaucetTokens();
console.log("After user1 claim:", smallFaucet.balanceOf(address(smallFaucet)) / 1e18, "tokens");
assertEq(smallFaucet.balanceOf(user1), 1000 * 10 ** 18);
// User2 claims successfully
vm.warp(6 days);
vm.prank(user2);
smallFaucet.claimFaucetTokens();
console.log("After user2 claim:", smallFaucet.balanceOf(address(smallFaucet)) / 1e18, "tokens");
assertEq(smallFaucet.balanceOf(user2), 1000 * 10 ** 18);
// User3 should be able to claim the last 1000 tokens, but CANNOT
vm.warp(9 days);
vm.prank(user3);
vm.expectRevert(RaiseBoxFaucet.RaiseBoxFaucet_InsufficientContractBalance.selector);
smallFaucet.claimFaucetTokens();
console.log("After user3 failed claim:", smallFaucet.balanceOf(address(smallFaucet)) / 1e18, "tokens");
console.log("");
console.log("BUG: User3 cannot claim the last 1000 tokens!");
console.log("Tokens are permanently locked in the contract.");
assertEq(smallFaucet.balanceOf(address(smallFaucet)), 1000 * 10 ** 18, "Last tokens locked");
assertEq(smallFaucet.balanceOf(user3), 0, "User3 got nothing");
}
}

Console Output

Initial contract balance: 1000000000 tokens
Faucet drip amount: 1000 tokens
Remaining balance after transfer: 1000 tokens
Remaining balance equals faucetDrip? true
RESULT: Claim reverted even though balance == faucetDrip
The last 1000 tokens are permanently locked!
=== Testing with 3000 tokens (3 claims) ===
Contract balance: 3000 tokens
After user1 claim: 2000 tokens
After user2 claim: 1000 tokens
After user3 failed claim: 1000 tokens
BUG: User3 cannot claim the last 1000 tokens!
Tokens are permanently locked in the contract.

Recommended Mitigation

Change the comparison operator from <= to <:

// Before: Vulnerable code
function claimFaucetTokens() public {
// ... other checks ...
if (balanceOf(address(this)) <= faucetDrip) { // ⚠️ WRONG
revert RaiseBoxFaucet_InsufficientContractBalance();
}
// ... rest of function ...
_transfer(address(this), faucetClaimer, faucetDrip);
}
// After: Fixed code
function claimFaucetTokens() public {
// ... other checks ...
if (balanceOf(address(this)) < faucetDrip) { // ✅ CORRECT
revert RaiseBoxFaucet_InsufficientContractBalance();
}
// ... rest of function ...
_transfer(address(this), faucetClaimer, faucetDrip);
}

Explanation

The fix ensures that:

  1. Complete distribution: All tokens can be claimed, including the last faucetDrip amount

  2. Correct logic: Reverts only when balance is truly insufficient (< faucetDrip)

  3. No locked tokens: No tokens remain permanently locked in the contract

  4. Fair access: The last eligible user can successfully claim their tokens

  5. Proper validation: Still prevents claims when there aren't enough tokens available

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 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.