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

Pre-claim correction window can be broken by public `sweepUnclaimedBonus`, permanently reducing a later corrected CORRUPTED payout

Author Revealed upon completion

Root + Impact

Description

The protocol intentionally allows the moderator to correct a previously flagged outcome before any claim starts. This means a pool flagged as SURVIVED can still be re-flagged to CORRUPTED as long as claimsStarted == false.

The issue is that sweepUnclaimedBonus() remains publicly callable during this correction window and does not set claimsStarted. A third party can sweep protocol-accounted bonus after an incorrect SURVIVED flag, then the moderator can still re-flag to good-faith CORRUPTED, but the attacker bounty is now calculated from the reduced pool state.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (newOutcome == PoolStates.Outcome.SURVIVED) {
if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
}
outcome = newOutcome;
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
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 (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood:

  • During a real CORRUPTED registry state, the moderator can temporarily flag SURVIVED before correcting to CORRUPTED.

  • Any public caller can monitor the incorrect SURVIVED flag and call sweepUnclaimedBonus() before the moderator correction transaction lands.

Impact:

  • Protocol-accounted bonus can be permanently moved to recoveryAddress before a valid good-faith CORRUPTED correction.

  • The whitehat attacker’s bountyEntitlement is reduced from stake + bonus to only the remaining pool balance, breaking the intended correction window.

Proof of Concept

// 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 SweepUnclaimedBonusPOCTest is BaseConfidencePoolTest {
function testPOC_PublicSweepReducesCorrectedCorruptedBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expectedCorruptedBounty = 150 * ONE;
assertEq(token.balanceOf(address(pool)), expectedCorruptedBounty, "pool starts with stake + bonus");
assertEq(pool.totalBonus(), 50 * ONE, "bonus is protocol-accounted");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(bob);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "public sweep removes the bonus");
assertEq(token.balanceOf(address(pool)), 100 * ONE, "only stake remains");
assertEq(pool.totalBonus(), 0, "bonus accounting is zeroed");
assertFalse(pool.claimsStarted(), "sweep does not close the re-flag window");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE, "corrected bounty only includes remaining stake");
assertLt(pool.bountyEntitlement(), expectedCorruptedBounty, "attacker entitlement was reduced");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE, "attacker receives less than stake + bonus");
}
}

Recommended Mitigation

+ bool public survivedFromCorruptedState;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (newOutcome == PoolStates.Outcome.SURVIVED) {
if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
+ survivedFromCorruptedState = state == IAttackRegistry.ContractState.CORRUPTED;
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
+ survivedFromCorruptedState = false;
}
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
+ if (riskWindowStart != 0 || (outcome == PoolStates.Outcome.SURVIVED && survivedFromCorruptedState && !claimsStarted)) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
+ if (
+ outcome == PoolStates.Outcome.SURVIVED
+ && survivedFromCorruptedState
+ && !claimsStarted
+ && totalEligibleStake == 0
+ && msg.sender != outcomeModerator
+ ) revert NotModerator();
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
+ if (
+ outcome == PoolStates.Outcome.SURVIVED
+ && survivedFromCorruptedState
+ && !claimsStarted
+ && totalEligibleStake == 0
+ && msg.sender == outcomeModerator
+ ) {
+ claimsStarted = true;
+ }
stakeToken.safeTransfer(recoveryAddress, amount);
}

Support

FAQs

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

Give us feedback!