Root + Impact
Description
- `claimAttackerBounty()` has no delay or grace period between the moment `flagOutcome` flags a good-faith CORRUPTED outcome and the named attacker's eligibility to claim. - The named attacker can call `claimAttackerBounty()` in the very next transaction after the flag lands, draining the entire snapshot (stake + bonus) immediately.
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();
Risk
Likelihood:
-
A moderator flagging good-faith CORRUPTED must supply an attacker address by hand; address typos/copy-paste errors are a common, realistic class of on-chain mistake.
-
-
OutcomeFlagged is a public event, so any party is capable of monitoring for a flag and reacting instantly if the named address is claimable by them.
Impact:
-
The entire pool (stakers' principal + bonus, not just the attacker's fair bounty) can be drained in the transaction immediately following the flag.
-
-
Because claimAttackerBounty sets claimsStarted = true on any nonzero payout, flagOutcome's re-flag guard permanently blocks any moderator correction from that point on — there is no recovery path once a single claim has landed.
Proof of Concept:
function test_zeroTimelockOnGoodFaithCorruptedClaim() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 received = token.balanceOf(attacker) - attackerBefore;
assertEq(
received,
180 * ONE,
"BUG CONFIRMED: full pool drained with zero grace period"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
This test stakes 100 ETH (alice) and 50 ETH (bob), contributes 30 ETH bonus (carol), then transitions the registry through UNDER_ATTACK to CORRUPTED. The moderator flags good-faith CORRUPTED naming attacker with zero elapsed time and no grace period. attacker immediately calls claimAttackerBounty() in the very next call and receives the full 180 ETH pool (100 + 50 stake + 30 bonus). The final assertion confirms the moderator can no longer correct this via a re-flag to SURVIVED, since claimsStarted is already true.
Recommended Mitigation:
+ uint32 public constant CLAIM_GRACE_PERIOD = 1 days;
+
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();
+ if (block.timestamp < outcomeFlaggedAt + CLAIM_GRACE_PERIOD) revert ClaimTooEarly()
Adding CLAIM_GRACE_PERIOD enforces a minimum delay (e.g. 24 hours) between the good-faith CORRUPTED flag and the earliest the named attacker can claim. This gives the moderator a window to notice and correct an address error via re-flag before claimsStarted locks the outcome, without weakening the existing 180-day claim deadline or its anti-extension protection.