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

Pre-finality bonus sweep can underfund a corrected good-faith CORRUPTED bounty

Author Revealed upon completion

Root + Impact

Description

Confidence Pools allow the moderator to correct or reflag an outcome before the first claim. This is intentional: claimsStarted is the finality latch, and the pre-claim window exists so an incorrect SURVIVED/CORRUPTED decision or attacker address can be corrected before value distribution is locked.

In a no-observed-risk-window case, sweepUnclaimedBonus() treats the whole bonus as surplus when the pool is flagged SURVIVED, transfers that bonus to recoveryAddress, and decrements totalBonus. However, the function intentionally does not set claimsStarted. As a result, the moderator can still correct the outcome to good-faith CORRUPTED after the bonus has already been swept. The corrected CORRUPTED bounty then snapshots the reduced totalBonus, causing the named good-faith attacker to receive only staked principal instead of principal plus bonus.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd outcome / attacker before
// any participant locks in the wrong distribution.
// The window closing on the FIRST claim
// (`claimsStarted`) is by design — a value-movement finality latch, not a front-runnable
// moderator privilege. See docs/DESIGN.md (re-flag window).
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
...
@> snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
@> corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
...
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// Reserve principal still owed to non-claimers plus any bonus they're entitled to.
// When
// `riskWindowStart == 0` (no observable risk), `_bonusShare` returns 0 for everyone, so
// the bonus is not owed to any staker and the entire snapshotTotalBonus is sweepable.
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus;
@> }
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// Bonus is only unreserved when no staker is owed it (no risk window, or no stakers left).
// In that case the sweep removes it from the pool, so drop it from the live `totalBonus`
// too — keeping the accounting honest for any later re-snapshot.
@> if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
@> }
// Intentionally does NOT set claimsStarted.
// A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
@> stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

  • The pool observes registry state lazily. A quiet pool can first observe the agreement when the registry is already terminal CORRUPTED, leaving riskWindowStart == 0.

  • The moderator-supported pre-claim correction window remains open after sweepUnclaimedBonus(), and any caller can trigger the sweep before the moderator corrects an initial SURVIVED decision to good-faith CORRUPTED.

Impact:

  • A named good-faith attacker can be underpaid by up to the entire bonus pool.

  • The swept bonus is diverted to recoveryAddress, even though the later corrected good-faith CORRUPTED outcome should have included that bonus in the attacker bounty.

Proof of Concept

The PoC demonstrates a pool with 300 stake and 1000 bonus. The registry is already CORRUPTED, but the pool never observed an active risk state, so riskWindowStart == 0.

The moderator first flags SURVIVED, leaving the pre-claim correction window open because claimsStarted == false. A permissionless caller then calls sweepUnclaimedBonus(). Since riskWindowStart == 0, the function treats the whole 1000 bonus as surplus, transfers it to recoveryAddress, and reduces totalBonus to zero.

The moderator then validly corrects the outcome to good-faith CORRUPTED. Because the bonus was already swept, the corrected bounty snapshots snapshotTotalBonus == 0, so the attacker receives only 300 stake instead of the expected 1300 stake + bonus. The 1000 bonus remains with recovery.

function testI38_POC_PreClaimSweepDrainsBonusBeforeCorrectedGoodFaithCorruptedBounty() external {
uint256 aliceStake = 100 * ONE;
uint256 bobStake = 200 * ONE;
uint256 bonusAmount = 1_000 * ONE;
address attacker = makeAddr("attacker");
address sweeper = makeAddr("sweeper");
_stake(alice, aliceStake);
_stake(bob, bobStake);
_contributeBonus(carol, bonusAmount);
uint256 totalStake = aliceStake + bobStake;
uint256 expectedCorrectedBounty = totalStake + bonusAmount;
assertEq(pool.riskWindowStart(), 0, "pool has not observed active risk");
assertEq(pool.totalEligibleStake(), totalStake, "eligible stake");
assertEq(pool.totalBonus(), bonusAmount, "bonus live before resolution");
assertEq(token.balanceOf(address(pool)), expectedCorrectedBounty, "pool holds stake plus bonus");
// The registry is already terminal CORRUPTED, but the pool never observed
// UNDER_ATTACK / PROMOTION_REQUESTED. This leaves riskWindowStart == 0.
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator initially judges the corruption as not payable by this pool
// and flags SURVIVED. This is still before any claim starts finality.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "initial outcome survived");
assertEq(pool.riskWindowStart(), 0, "risk window still unobserved");
assertGt(pool.riskWindowEnd(), 0, "terminal state observed");
assertEq(pool.snapshotTotalStaked(), totalStake, "survived snapshot stake");
assertEq(pool.snapshotTotalBonus(), bonusAmount, "survived snapshot bonus");
assertEq(pool.claimsStarted(), false, "pre-claim correction window remains open");
assertEq(pool.totalBonus(), bonusAmount, "bonus still live before sweep");
uint256 recoveryBeforeSweep = token.balanceOf(recovery);
uint256 sweeperBefore = token.balanceOf(sweeper);
// BUG: Because riskWindowStart == 0, sweepUnclaimedBonus treats the entire
// bonus as surplus and transfers it to recovery, but it does not set
// claimsStarted. Reflagging remains possible after value has moved.
vm.prank(sweeper);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(sweeper), sweeperBefore, "sweeper receives nothing");
assertEq(
token.balanceOf(recovery) - recoveryBeforeSweep,
bonusAmount,
"recovery receives entire bonus before finality"
);
assertEq(token.balanceOf(address(pool)), totalStake, "pool now holds only stake");
assertEq(pool.totalBonus(), 0, "bonus accounting was reduced");
assertEq(pool.claimsStarted(), false, "sweep did not close correction window");
// Moderator corrects the pre-claim outcome to good-faith CORRUPTED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "corrected outcome corrupted");
assertEq(pool.snapshotTotalStaked(), totalStake, "corrected snapshot stake");
// The corrected snapshot now sees zero bonus because it was already swept.
assertEq(pool.snapshotTotalBonus(), 0, "corrected snapshot lost bonus");
assertEq(pool.bountyEntitlement(), totalStake, "actual bounty is stake only");
assertTrue(
pool.bountyEntitlement() < expectedCorrectedBounty,
"attacker bounty is underfunded versus corrected outcome"
);
assertEq(
expectedCorrectedBounty - pool.bountyEntitlement(),
bonusAmount,
"attacker loses the entire bonus"
);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(attacker) - attackerBefore,
totalStake,
"attacker receives only stake, not stake plus bonus"
);
assertEq(token.balanceOf(address(pool)), 0, "pool empty after underfunded bounty claim");
assertEq(
token.balanceOf(recovery) - recoveryBeforeSweep,
bonusAmount,
"recovery keeps bonus that corrected GF attacker should have received"
);
}

Recommended Mitigation

Ensure that value needed by any still-possible corrected outcome cannot be moved before the outcome becomes final.

In particular, sweepUnclaimedBonus() should not be able to remove accounted bonus while the pre-claim reflag window remains open and a later corrected CORRUPTED outcome can still depend on that bonus.

Possible approaches include:

  • Make sweepUnclaimedBonus() close the reflag window when it removes accounted bonus, for example by setting claimsStarted = true.

  • Or reserve accounted bonus while claimsStarted == false, so only true raw excess/donations can be swept before finality.

  • Or otherwise prevent reflagging after any sweep that changes accounting used by a later outcome snapshot.

The key invariant is that a corrected outcome must not be computed from accounting that was already depleted by a pre-finality sweep.

Support

FAQs

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

Give us feedback!