FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

[L-03] claimAttackerBounty() silently succeeds with zero payout when pool balance is zero

Author Revealed upon completion

Root + Impact

Description

claimAttackerBounty() is the named whitehat's only mechanism to collect their bounty in a good-faith CORRUPTED outcome. The function computes payout as min(remaining entitlement, freeBalance), so if the pool's token balance is zero, payout is also zero. Rather than reverting, the function silently emits an event and returns with no tokens transferred and no state progressed.

// ConfidencePool.sol line 439-452
uint256 remaining = bountyEntitlement - bountyClaimed;
// @> freeBalance can be 0 if pool balance is unexpectedly drained
uint256 freeBalance = stakeToken.balanceOf(address(this));
// @> payout silently resolves to 0 - no revert, no guard
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
uint256 newBountyClaimed = bountyClaimed + payout; // unchanged when payout == 0
bountyClaimed = newBountyClaimed;
if (payout > 0) { // entire block skipped
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
// @> Event emitted with amount=0 - misleading to off-chain monitors
emit AttackerBountyClaimed(attacker, 0, bountyClaimed, bountyEntitlement);

Because bountyClaimed is unchanged and claimsStarted is not set, claimCorrupted() remains permanently blocked by the MustClaimBountyFirst guard. The attacker sees a AttackerBountyClaimed event and may incorrectly believe they received their funds.

Risk

Likelihood:

  • Under normal operation, the pool holds the full staked + bonus balance when claimAttackerBounty() is first called; no other path can drain it while the good-faith flag is set.

  • The zero-balance path becomes reachable if a fee-on-transfer or rebasing token passes the factory allowlist (governance error), or via a direct ERC20 transfer that reduces balance below the entitlement.

Impact:

  • The named whitehat attacker submits a transaction, pays gas, receives no tokens, and is given false confirmation via the emitted event, a poor and potentially confusing experience during what is already a high-stakes claim.

  • claimsStarted stays false and bountyClaimed stays at 0, so claimCorrupted() remains gated by MustClaimBountyFirst indefinitely. The sweep path to recoveryAddress is permanently blocked until bountyClaimed == bountyEntitlement, which can never be reached if the balance stays zero.

Proof of Concept

This scenario demonstrates how a zero-balance edge case silently corrupts the pool's claim accounting. The named whitehat calls claimAttackerBounty() expecting their bounty, but because the pool balance is zero, the payout resolves to zero. The function does not revert; it emits a AttackerBountyClaimed event with amount = 0 and returns, leaving bountyClaimed unchanged. Because bountyClaimed < bountyEntitlement is still true after this call, claimCorrupted() remains permanently blocked by the MustClaimBountyFirst guard, and no funds can ever leave the pool through either path.

// Setup: pool staked with 1000e18 tokens, moderator flags good-faith CORRUPTED.
// bountyEntitlement = 1000e18, bountyClaimed = 0, corruptedClaimDeadline = block.timestamp + 180 days
// Precondition: pool balance becomes 0 (e.g. fee-on-transfer token silently reduced it to 0)
// stakeToken.balanceOf(pool) == 0
// Step 1: Named whitehat calls claimAttackerBounty()
pool.claimAttackerBounty();
// freeBalance = stakeToken.balanceOf(address(this)) = 0
// payout = min(1000e18, 0) = 0
// bountyClaimed += 0 → still 0
// claimsStarted not set (payout == 0, body skipped)
// emit AttackerBountyClaimed(attacker, 0, 0, 1000e18) ← attacker thinks they claimed
// Step 2: Anyone tries to call claimCorrupted() to sweep remainder
pool.claimCorrupted();
// if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst()
// → reverts: 0 < 1000e18
// Neither the attacker nor recoveryAddress can receive any tokens.
// Pool is permanently stuck.

Recommended Mitigation

uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
+ if (payout == 0) revert NothingToSweep();
uint256 newBountyClaimed = bountyClaimed + payout;

Support

FAQs

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

Give us feedback!