Root + Impact
Description
The protocol permits the moderator to re-flag an outcome before the first claim. The docs explicitly describe this as a mechanism for correcting mistakes:
“flagOutcome may be re-flagged pre-claim so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution.”
The design further states:
“A claim is the distribution-locking event. Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting.”
This is implemented by allowing flagOutcome() to overwrite an existing outcome while claimsStarted remains false:
function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith_,
address attacker_
) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
}
However, claims are enabled immediately after the outcome is flagged. There is no protected period in which the moderator can exercise the documented correction mechanism.
Consider an incident involving multiple attackers, A and B. The moderator decides that attacker A should receive the good-faith bounty but accidentally submits attacker B’s address.
Because attacker B controls the selected address, B can immediately call 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;
uint256 newBountyClaimed = bountyClaimed + payout;
bountyClaimed = newBountyClaimed;
if (payout > 0) {
corruptedReserve -= payout;
claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
}
For a good-faith CORRUPTED outcome, the bounty consists of the complete snapshot of staked principal and contributed bonus. Attacker B can therefore receive the entire pool in one transaction.
Once the claim succeeds, claimsStarted becomes true. The moderator’s attempt to replace attacker B with the intended attacker A then reverts with OutcomeAlreadySet.
The mechanism assumes that the first claimant is exercising a correct outcome. That assumption is invalid when the correction feature is needed: the incorrectly advantaged attacker has a direct incentive to claim immediately and eliminate the correction opportunity.
Risk
Likelihood:
The moderator must select an incorrect but controlled address, such as another participant in a multi-attacker incident. That address must claim before the moderator submits the correction.
Impact:
The mistakenly named attacker receives all staked principal and bonus. The intended attacker receives nothing, and the transfer cannot be corrected or recovered.
Proof of Concept
Add the following test to test/unit/AttackerCorrectionRace.t.sol and run:
forge test --offline --match-contract AttackerCorrectionRaceTest -vv
pragma solidity 0.8.26;
import {IAttackRegistry} from
"@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from
"src/interfaces/IConfidencePool.sol";
import {PoolStates} from
"src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from
"test/helpers/BaseConfidencePoolTest.sol";
contract AttackerCorrectionRaceTest is BaseConfidencePoolTest {
function testPoC_wrongAttackerClaimsEntirePoolAndPreventsCorrection()
external
{
address intendedAttackerA = makeAddr("intendedAttackerA");
address mistakenlyNamedAttackerB =
makeAddr("mistakenlyNamedAttackerB");
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
mistakenlyNamedAttackerB
);
vm.prank(mistakenlyNamedAttackerB);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(mistakenlyNamedAttackerB),
150 * ONE,
"wrong attacker drains the full pool"
);
assertEq(
token.balanceOf(intendedAttackerA),
0,
"intended attacker receives nothing"
);
assertEq(
token.balanceOf(address(pool)),
0,
"pool is empty after the mistaken claim"
);
assertTrue(
pool.claimsStarted(),
"mistaken claim permanently closes correction"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
intendedAttackerA
);
}
}
The test passes, showing that attacker B drains the complete pool and permanently prevents correction.
Recommended Mitigation
Wire _assertClaimsOpen() into claimSurvived / claimAttackerBounty / claimCorrupted / sweeps. Drop the claimsStarted latch for re-flag gating (or keep it only as a post-window safety check). The fix may look like:
uint256 public constant CORRECTION_WINDOW = 1 hours;
uint32 public firstFlaggedAt;
function flagOutcome(...) external onlyModerator {
if (outcome != UNRESOLVED && block.timestamp > firstFlaggedAt + CORRECTION_WINDOW) {
revert OutcomeAlreadySet();
}
if (firstFlaggedAt == 0) firstFlaggedAt = uint32(block.timestamp);
}
function _assertClaimsOpen() internal view {
if (outcome == UNRESOLVED) revert OutcomeNotSet();
if (firstFlaggedAt == 0 || block.timestamp <= firstFlaggedAt + CORRECTION_WINDOW) {
revert CorrectionWindowActive();
}
}