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

Accounted bonus can be swept before moderator correction, causing good-faith CORRUPTED bounty to exclude the sponsor bonus

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: while moderator re-flagging is still allowed, a corrected good-faith CORRUPTED outcome should settle from the same economic pool that existed before the mistaken flag.

  • I observed that sweepUnclaimedBonus() can move accounted totalBonus to recoveryAddress without setting claimsStarted when riskWindowStart == 0. Since claimsStarted remains false, the moderator can still correct the outcome to good-faith CORRUPTED, but the later bounty snapshot uses the reduced totalBonus. The attacker bounty is therefore computed without the sponsor bonus that was already swept.

// src/ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed until claimsStarted.
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
@> snapshotTotalBonus = totalBonus;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Does not set claimsStarted, so moderator correction remains open.
@> stakeToken.safeTransfer(recoveryAddress, amount);
}

The issue is not that bonus sweeping exists. The issue is that sweeping accounted totalBonus changes the economics of a later valid correction while the protocol still considers the correction window open.

Risk

Likelihood:

  • This occurs when a pool has no observed risk window (riskWindowStart == 0), the moderator first flags SURVIVED, and sweepUnclaimedBonus() is called before the moderator corrects to good-faith CORRUPTED.

  • The path uses normal protocol functions. No non-standard token behavior, reentrancy, callback, or privileged sweeper is required.

Impact:

  • A valid good-faith attacker can be underpaid by the entire sponsor bonus.

  • The bonus is diverted to recoveryAddress, while the later corrected bountyEntitlement includes only staked principal.

Proof of Concept

Create this file:

cat > test/unit/SweepBonusBeforeCorrectionPoC.t.sol <<'EOF'
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract SweepBonusBeforeCorrectionPoC is BaseConfidencePoolTest {
function testPoC_SweepBeforeGoodFaithCorrectionUnderpaysAttacker() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
// Registry is CORRUPTED, but moderator initially flags SURVIVED.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no active risk window was observed");
assertEq(pool.snapshotTotalStaked(), stakeAmount);
assertEq(pool.snapshotTotalBonus(), bonusAmount);
assertFalse(pool.claimsStarted(), "reflag window is still open");
uint256 recoveryBefore = token.balanceOf(recovery);
// Anyone can sweep the accounted bonus before moderator correction.
vm.prank(dave);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonusAmount);
assertEq(pool.totalBonus(), 0, "accounted bonus was removed");
assertFalse(pool.claimsStarted(), "sweep did not close reflag window");
// Moderator corrects to good-faith CORRUPTED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// BUG: corrected bounty excludes the already-swept sponsor bonus.
assertEq(pool.snapshotTotalStaked(), stakeAmount);
assertEq(pool.snapshotTotalBonus(), 0);
assertEq(pool.bountyEntitlement(), stakeAmount);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(attacker) - attackerBefore,
stakeAmount,
"attacker receives only stake, not stake + bonus"
);
assertEq(
token.balanceOf(recovery) - recoveryBefore,
bonusAmount,
"bonus remains diverted to recovery"
);
assertEq(token.balanceOf(address(pool)), 0);
}
}
EOF

Run:

forge test --match-path test/unit/SweepBonusBeforeCorrectionPoC.t.sol -vvv

Observed output:

Ran 1 test for test/unit/SweepBonusBeforeCorrectionPoC.t.sol:SweepBonusBeforeCorrectionPoC
[PASS] testPoC_SweepBeforeGoodFaithCorrectionUnderpaysAttacker() (gas: 601768)
Suite result: ok. 1 passed; 0 failed; 0 skipped

The PoC proves that:

  1. SURVIVED is initially flagged while claimsStarted == false.

  2. sweepUnclaimedBonus() transfers the accounted 50e18 bonus to recoveryAddress.

  3. The correction window remains open because claimsStarted is still false.

  4. A later good-faith CORRUPTED correction snapshots totalBonus == 0.

  5. The attacker receives only 100e18 stake instead of 150e18 stake plus bonus.

Recommended Mitigation

Separate dust/donation sweeping from accounted bonus sweeping. Accounted totalBonus should not be swept while moderator correction is still possible unless the sweep itself closes finality.

function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
+
+ // Sweeping accounted bonus changes later corrected payout economics.
+ if (accountedBonusSwept != 0) {
+ claimsStarted = true;
+ }
}
stakeToken.safeTransfer(recoveryAddress, amount);
}

A stricter fix is to disallow sweeping accounted totalBonus while re-flagging is still possible. Donation/dust-only sweeps can remain non-final because they were never part of totalBonus.

Support

FAQs

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

Give us feedback!