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

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
+ }
}

Support

FAQs

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

Give us feedback!