Root + Impact
Description
-
Before a participant claim starts, the moderator may correct a SURVIVED outcome to good-faith CORRUPTED. A good-faith CORRUPTED resolution entitles the named attacker to the stake and bonus captured by that resolution snapshot.
-
Following a SURVIVED flag with no observed risk window, any account may call sweepUnclaimedBonus(). The function transfers the full accounted bonus to recoveryAddress and reduces totalBonus, but deliberately leaves claimsStarted == false. A subsequent correction to good-faith CORRUPTED remains callable yet snapshots the reduced totalBonus, so the attacker receives principal only while the recovery beneficiary retains the bonus.
-
Contest caveat: testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement() explicitly asserts that the corrected entitlement excludes funds already swept. This makes the behavior demonstrably intentional and creates substantial rejection risk.
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 (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
stakeToken.safeTransfer(recoveryAddress, amount);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
bountyEntitlement = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_
? snapshotTotalStaked + snapshotTotalBonus
: 0;
}
Risk
Likelihood:
-
Occurs after the moderator initially flags SURVIVED on a terminal CORRUPTED registry without a locally observed active-risk window.
-
The value transfer becomes final when a permissionless caller executes sweepUnclaimedBonus() before the moderator submits a good-faith CORRUPTED correction.
Impact:
-
The named good-faith attacker loses the full bonus component of the corrected bounty.
-
Bonus funds are redirected to the sponsor-controlled recoveryAddress before the corrected outcome can reserve them.
Proof of Concept
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract PreReflagBonusSweepPoC is BaseConfidencePoolTest {
function test_BonusSweepReducesCorrectedGoodFaithBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
assertFalse(pool.claimsStarted());
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0);
assertEq(pool.bountyEntitlement(), 100 * ONE);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE);
assertEq(token.balanceOf(recovery), 50 * ONE);
}
}
Recommended Mitigation
Treat sweeping accounted bonus as distribution finality. A sweep consisting only of unsolicited donations or arithmetic dust may remain non-finalizing, but movement of totalBonus should close the correction window.
function sweepUnclaimedBonus() external nonReentrant {
// ...calculate amount...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
+ if (accountedBonusSwept != 0 && !claimsStarted) {
+ claimsStarted = true;
+ }
}
stakeToken.safeTransfer(recoveryAddress, amount);
}