FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

sweepUnclaimedBonus can erase a whitehat's bounty during an outcome correction

Author Revealed upon completion

Root + Impact

sweepUnclaimedBonus moves the whole bonus out of the pool but is the only value-moving function that does not latch claimsStarted. A moderator who flags SURVIVED, lets the bonus sweep, and then uses the supported pre-claim window to correct the outcome to good-faith CORRUPTED snapshots a zero totalBonus, so the named whitehat's bounty entitlement is zero. The bonus has already been transferred to recoveryAddress and is no longer enforceably claimable through the pool.

Description

  • Every value-moving settlement function latches claimsStarted, which closes the moderator's pre-claim re-flag window (DESIGN.md §4) once funds have moved. Good-faith CORRUPTED sets bountyEntitlement from the totalBonus snapshot taken at flag time.

  • sweepUnclaimedBonus deliberately does not set claimsStarted, so a dust donation cannot grief the window, but it also zeroes totalBonus. On a pool that holds bonus and no stake, the moderator can flag SURVIVED, sweep the bonus to recoveryAddress, then re-flag good-faith CORRUPTED. The correction snapshots totalBonus == 0, so bountyEntitlement == 0 and claimAttackerBounty reverts. Tracked funds therefore leave under one outcome while that outcome remains mutable.

// src/ConfidencePool.sol::sweepUnclaimedBonus — zeroes tracked bonus but does NOT latch finality
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
// Intentionally does NOT set claimsStarted (dust-donation grief guard)
stakeToken.safeTransfer(recoveryAddress, amount);
// src/ConfidencePool.sol::flagOutcome — re-flag allowed while claimsStarted is false
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // snapshot now 0

Risk

Likelihood:

  • Arises on a bonus-only pool (no stake) whose Agreement is genuinely corrupted by a good-faith whitehat, when the moderator first resolves SURVIVED and the bonus is swept before the correction. The sweep is permissionless, so anyone can trigger it in the window between the two flags.

  • Requires a moderator outcome correction, which is an intended but uncommon path.

Likelihood is Low.

Impact:

  • The whitehat loses the entire enforceable sponsor-funded bounty after a valid good-faith corruption. Although the sponsor-controlled recoveryAddress can voluntarily forward the tokens, the corrected outcome gives the whitehat no on-chain claim to them.

  • Bounded by the bonus of a single bonus-only pool; no staker principal is involved.

Impact is Medium.

Proof of Concept

poc/SweepUnclaimedBonusFinalityPoc.t.sol passes with:

forge test --contracts audit/findings/low/poc \
--match-path audit/findings/low/poc/SweepUnclaimedBonusFinalityPoc.t.sol -vv
function test_poc_sweepErasesWhitehatBountyDuringOutcomeCorrection() external {
// Pool holds a sponsor bonus and never attracted stake.
_contributeBonus(alice, BONUS);
assertEq(token.balanceOf(address(pool)), BONUS);
// A good-faith whitehat corrupts the in-scope protocol.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// The moderator first (mis)flags SURVIVED. `sweepUnclaimedBonus` moves the whole bonus to
// recoveryAddress WITHOUT latching `claimsStarted`, so the outcome stays mutable.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), BONUS);
assertEq(token.balanceOf(address(pool)), 0);
// Using the supported pre-claim correction window, the moderator re-flags the true
// good-faith CORRUPTED outcome naming the whitehat. The new snapshot sees totalBonus == 0.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 0);
// The whitehat can never claim the bounty he legitimately earned.
vm.prank(attacker);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
// The bonus is outside the pool and the whitehat has no enforceable claim to it.
assertEq(token.balanceOf(recovery), BONUS);
assertEq(token.balanceOf(attacker), 0);
}

Recommended Mitigation

Latch claimsStarted whenever sweepUnclaimedBonus removes tracked bonus from the pool. Donation-only surplus sweeps (where no tracked bonus moves) stay exempt, so an unsolicited transfer still cannot grief the correction window.

// src/ConfidencePool.sol::sweepUnclaimedBonus
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 trackedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ if (trackedBonusSwept != 0) {
+ totalBonus -= trackedBonusSwept;
+ claimsStarted = true; // tracked bonus left the pool: close the re-flag window
+ }
}
Updates

Lead Judging Commences

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

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!