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

# `sweepUnclaimedBonus` accounting clamp divergence when sweeping post-resolution donations

Author Revealed upon completion

Description

Normal Behavior

When sweepUnclaimedBonus() sweeps excess funds back to the recoveryAddress, it accurately reserves the sum of totalEligibleStake and unclaimedBonus. Because the pool relies on the locked snapshotTotalBonus for actual staker claim math, sweeping out extra donated funds cleanly protects the underlying liabilities.

Specific Issue

If the free balance being swept consists of a large post-resolution donation, the amount swept can easily be much larger than totalBonus. The contract attempts to conditionally decrement the live totalBonus variable using a clamp: totalBonus -= amount <= totalBonus ? amount : totalBonus. If the donation was large enough, this clamp forcibly zeroes out totalBonus. Because staker math relies on the frozen snapshotTotalBonus, this doesn't disrupt payouts. However, if the moderator re-flags the pool later, a new snapshot will capture totalBonus (now 0) instead of the true remaining bonus, disrupting the sponsor's bonus allocation.

// src/ConfidencePool.sol:sweepUnclaimedBonus()
if (freeBalance <= reserved) revert NothingToSweep();
uint256 amount = freeBalance - reserved;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
// @> Root cause: If `amount` (a huge donation) is > `totalBonus`, this zeroes out `totalBonus` entirely
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

Risk

Likelihood:

  • A user or sponsor donates tokens directly to the pool (transfer without calling contributeBonus) after the pool resolves.

  • sweepUnclaimedBonus is called by the sponsor, sweeping a volume that exceeds the total documented bonus pool.

Impact:

  • The internal state variable totalBonus is permanently zeroes out.

  • If the pool is subsequently re-flagged by the moderator (while !claimsStarted), the new snapshot incorrectly records a zero bonus, robbing early claimers of their entitled yield.

Proof of Concept

Create a new test file test/unit/POCTest.t.sol inheriting from BaseConfidencePoolTest, paste the following code inside the contract body, and run it using the command forge test --match-test test_POC_SweepAccountingClamp -vvv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract POCTest is BaseConfidencePoolTest {
function test_POC_SweepAccountingClamp() external {
console.log("--- POC: Sweep Unclaimed Bonus Clamp ---");
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.warp(pool.expiry() + 1);
pool.claimExpired();
assertEq(pool.totalBonus(), 50 * ONE);
console.log("Total Bonus before donation sweep:", pool.totalBonus());
uint256 massiveDonation = 1000 * ONE;
token.mint(address(pool), massiveDonation);
console.log("Massive donation sent to pool:", massiveDonation);
vm.prank(alice);
pool.claimExpired();
assertEq(pool.totalEligibleStake(), 0);
pool.sweepUnclaimedBonus();
console.log("Total Bonus after donation sweep:", pool.totalBonus());
assertEq(pool.totalBonus(), 0);
console.log("Total Bonus was zeroed out despite snapshotTotalBonus remaining unchanged.");
}
}

Recommended Mitigation

A direct code-line fix (like simply removing the clamp) is not practically possible because it would either cause an arithmetic underflow (if amount > totalBonus) or leave the pool insolvent during a re-flag (if the bonus was swept but totalBonus wasn't decremented).

Instead of altering the clamp math, the protocol should explicitly segregate donations from the sweep mechanics. Add a dedicated sweepDonations() function that tracks excess balances strictly outside of totalBonus and eligibleStake. Alternatively, strictly document this edge case via NatSpec, explicitly warning sponsors: "Do not sweep unrecorded donations if a moderator re-flag is anticipated, as doing so permanently erases the stakers' bonus entitlement."

/// @inheritdoc IConfidencePool
+ /// @dev Warning: Do not sweep unrecorded donations if a moderator re-flag is anticipated.
+ /// Sweeping an amount greater than totalBonus will permanently erase the stakers' bonus entitlement
+ /// during the clamp, leading to zero bonus in future snapshots.
function sweepUnclaimedBonus() external nonReentrant {

Support

FAQs

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

Give us feedback!