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

claimSurvived, claimExpired, and withdraw pay from the ledger with no cap against live token balance, unlike the CORRUPTED-path payout functions

Author Revealed upon completion

Root + Impact

`ConfidencePool` has two payout styles:

  • **Balance-aware:** `claimAttackerBounty`, `claimCorrupted`, `sweepUnclaimedCorrupted` cap the payout to `stakeToken.balanceOf(address(this))`. If the balance is short, they just pay less — never revert.

  • **Ledger-only:** `claimSurvived`, `claimExpired`, `withdraw` pay whatever the internal ledger (`eligibleStake` + bonus) says is owed, with no balance check.

If the pool's actual balance ever drops below what the ledger owes (e.g. a mistakenly-allowlisted rebasing token — already called out in `ConfidencePoolFactory.sol:26-30`), the two styles fail differently: balance-aware functions quietly pay less to everyone; ledger-only functions pay early claimants in full and then hard-revert for everyone after the balance runs out, until someone tops it up.


Description

- Normal behavior: when a claim/withdraw entitlement is paid out, the contract should never attempt to transfer more than it actually holds. `claimAttackerBounty`, `claimCorrupted`, and `sweepUnclaimedCorrupted` all follow this. The transfer cap is capped to `stakeToken.balanceOf(address(this))`.

- The issue: `claimSurvived`, the claim tail of `claimExpired`, and `withdraw` skip this check entirely, they transfer whatever the internal ledger (`eligibleStake` + `_bonusShare`) says is owed, with no comparison against the contract's actual balance first.

// ConfidencePool.sol — claimSurvived (ledger-only, no balance check)
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout); // no balanceOf(address(this)) check

Risk

Likelihood:

- Occurs whenever the pool's actual token balance falls below what the ledger tracks as owed - e.g. the factory owner allowlists a token that can lose balance independent of a transfer (already flagged in `ConfidencePoolFactory.sol:26-30` as a fee-on-sender/negative-rebase risk).

- Occurs on every claim made after that shortfall exists, for as long as the shortfall persists, not a one-time edge case but a recurring state once triggered.

Impact:

- Claimants who call after the balance is exhausted have their `safeTransfer` revert; checks-effects-interactions means the revert rolls back cleanly, so no funds are lost, but the claimant is blocked until someone restores the balance.

- Inconsistent behavior across the contract: the CORRUPTED-path functions degrade gracefully (pay less, never revert) for the identical trigger, while SURVIVED/EXPIRED/withdraw hard-fail for late claimants instead.

Proof of Concept

The poc can be run in a standalone test file against the ConfidencePool contract. Basically, what this does is to let the Factory allow a rebasing token (fee on transfer) to be on the allow list, so for every tx done, the fee is reduced and since the code does not check against the actual balance and depends solely on the internal ledger, there will be a point where the internal ledger is above the actual balance and will revert on claimSurvived, claimExpired and Withdraw. If the pool is topped, this can be avoided, but it's better to mitigate rather than allow it pass through

// 1. Owner allowlists a token that can lose balance without a transfer (e.g. negative-rebase)
factory.setStakeTokenAllowed(address(rebasingToken), true);
// 2. Pool created, multiple stakers deposit
pool.stake(amount); // staker A
pool.stake(amount); // staker B
// 3. Token rebases down — pool balance now < totalEligibleStake + owed bonus
rebasingToken.rebase(-shortfallAmount);
// 4. Pool resolves SURVIVED/EXPIRED; staker A claims first and is paid in full
vm.prank(stakerA);
pool.claimSurvived(); // succeeds
// 5. Staker B's claim reverts — balance is now insufficient
vm.prank(stakerB);
vm.expectRevert(); // safeTransfer reverts, stakerB stuck until balance is topped up
pool.claimSurvived();

Recommended Mitigation

Adding the check to select min(freeBalance, payout) to pay out to users

function claimSurvived() external nonReentrant {
...
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
+ uint256 freeBalance = stakeToken.balanceOf(address(this));
+ if (payout > freeBalance) payout = freeBalance;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
// Apply the same `payout = min(owed, freeBalance)` pattern already used in `claimAttackerBounty` to `claimSurvived`, the claim tail of `claimExpired`, and `withdraw`.

Support

FAQs

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

Give us feedback!