Summary
sweepUnclaimedBonus() can move accounted bonus to recoveryAddress while the moderator’s pre-claim re-flag window is still open. If the moderator later corrects SURVIVED to good-faith CORRUPTED, the new snapshot uses the already-reduced totalBonus, so the attacker bounty excludes bonus that was part of the pool before the correction.
Vulnerability Details
Vulnerable code:
flagOutcome() intentionally allows the moderator to re-flag before any claim starts:
322: function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
323:
324:
327: @> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
354: outcome = newOutcome;
357: snapshotTotalStaked = totalEligibleStake;
358: @> snapshotTotalBonus = totalBonus;
361: corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
362: @> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
The problem is that sweepUnclaimedBonus() can move real accounted bonus before that re-flag window is closed:
474: function sweepUnclaimedBonus() external nonReentrant {
475: if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
476: revert OutcomeNotEligibleForSweep();
477: }
...
491: uint256 freeBalance = stakeToken.balanceOf(address(this));
492: uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
...
499: @> if (totalEligibleStake == 0 || riskWindowStart == 0) {
500: @> totalBonus -= amount <= totalBonus ? amount : totalBonus;
501: }
...
503:
506: @> stakeToken.safeTransfer(recoveryAddress, amount);
When riskWindowStart == 0, the function treats the bonus as sweepable, transfers it to recoveryAddress, and reduces totalBonus. But it does not set claimsStarted, so the moderator can still re-flag afterward. A later good-faith CORRUPTED flag snapshots the already-drained totalBonus, reducing the attacker’s bounty.
Why this is not intended
The code comments show that the moderator is allowed to fix an outcome only before users start relying on the current distribution.
sweepUnclaimedBonus() does not set claimsStarted because otherwise someone could send a tiny amount of tokens directly to the pool and use that donation to block the moderator from correcting a mistake. That part makes sense.
The problem is that the function does not separate a direct donation from the pool’s real recorded bonus.
In the second test, the pool has 100 tokens of stake, 50 tokens of bonus, and a 25-token direct donation. The sweep sends 75 tokens to recoveryAddress, which includes both the donation and the full 50-token bonus. Even after that, the moderator can still change the outcome. When the outcome is corrected to good-faith CORRUPTED, the attacker receives only the 100-token stake instead of 150 tokens.
The third test makes the issue even clearer because there is no donation at all. The pool has no stake and only a 50-token bonus. That bonus is fully swept, but the moderator can still correct the outcome afterward. Since the new snapshot sees zero stake and zero bonus, the attacker bounty becomes zero.
So this is not only about safely removing random tokens sent by outsiders. The same logic also lets the pool’s recorded bonus leave before the outcome is final. Once that bonus has been moved, a later correction should not be allowed to calculate the bounty from the reduced amount.
The comments in flagOutcome() say re-flagging is allowed only before value movement creates reliance:
323:
324:
325:
326:
So the intended rule is not “re-flagging remains open after pool value moves.” The intended rule is that re-flagging stays open before claims lock in a distribution.
sweepUnclaimedBonus() avoids setting claimsStarted to prevent a tiny donation from griefing the moderator’s correction window:
That makes sense for dust or unaccounted donations. It does not cover the PoC case, where the swept amount is the real 50 * ONE bonus added through contributeBonus(). Once that accounted bonus leaves the pool, a later corrected snapshot should not silently lose it.
Likelihood: Medium
This requires a particular order of events: the moderator first flags the pool as SURVIVED or EXPIRED, someone calls sweepUnclaimedBonus() before any claim, and the moderator later corrects the result to good-faith CORRUPTED.
The sequence is specific, but every step is allowed by the current code. The sweep is permissionless, and it does not close the moderator’s correction window.
Impact: Medium
The attacker receives less than the bounty that would have been available before the bonus was swept.
In the second test, the attacker should receive the 100-token stake plus the 50-token bonus. Instead, the bonus is sent to recoveryAddress, and the attacker receives only 100 tokens. The attacker loses 50 tokens from the corrected bounty.
In the third test, the pool contains only a 50-token bonus and no stake. After the bonus is swept, the corrected CORRUPTED outcome gives the attacker nothing. The entire bounty is removed.
The tokens are not burned or stuck. They are sent to recoveryAddress. The issue is that they go to the wrong side of the final distribution, which reduces or completely removes the attacker’s bounty..
test/unit/AuditReflagSweepBonusTest.t.sol
Proof Of Concept
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract AuditReflagSweepBonusTest is BaseConfidencePoolTest {
function testAccountedBonusSweepReducesLaterGoodFaithCorruptedBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalStaked(), 100 * ONE);
assertEq(pool.snapshotTotalBonus(), 50 * ONE);
assertFalse(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE);
assertEq(pool.snapshotTotalBonus(), 0);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, 100 * ONE);
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
}
function testDonationPlusAccountedBonusSweepStillReducesCorrectedBounty() 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));
token.mint(dave, 25 * ONE);
vm.prank(dave);
token.transfer(address(pool), 25 * ONE);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 75 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE);
assertEq(pool.snapshotTotalBonus(), 0);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, 100 * ONE);
assertEq(token.balanceOf(recovery) - recoveryBefore, 75 * ONE);
}
function testNoStakerAccountedBonusSweepRemovesCorrectedBounty() external {
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalStaked(), 0);
assertEq(pool.snapshotTotalBonus(), 50 * ONE);
assertFalse(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 0);
assertEq(pool.snapshotTotalBonus(), 0);
}
}
forge test --match-path test/unit/AuditReflagSweepBonusTest.t.sol -vvv
Ran 3 test for test/unit/AuditReflagSweepBonusTest.t.sol:AuditReflagSweepBonusTest
Suite result: ok. 3 passed; 0 failed; 0 skipped
Tools Used
Manual review, Foundry test.
Recommendations
Close the re-flag window when sweepUnclaimedBonus() moves accounted bonus, while still allowing harmless dust/donation sweeps to avoid blocking moderator correction.
@@
- if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ if (totalEligibleStake == 0 || riskWindowStart == 0) {
+ uint256 accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
+ if (accountedBonusSwept != 0 && !claimsStarted) {
+ claimsStarted = true;
+ }
}
This keeps the intended donation/dust behavior intact, but once real accounted bonus leaves the pool, the outcome becomes final just like other value-moving paths