FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: medium

Correcting a whitehat after the first bounty deadline gives the corrected beneficiary no time to claim the full bounty

Author Revealed upon completion

M-03: Correcting a whitehat after the first bounty deadline gives the corrected beneficiary no time to claim the full bounty

Description

For a good-faith CORRUPTED resolution, the pool snapshots the full pool as the named whitehat's bounty and reserves it for CORRUPTED_CLAIM_WINDOW (180 days). Before any claim starts settlement, the moderator may call flagOutcome again to correct an outcome or the named attacker.

This behaviour is an explicit protocol commitment. docs/DESIGN.md §4 says a moderator may re-flag before the first claim to fix a typo in the outcome or attacker, and expressly identifies the first claim—not a timer—as the event that ends the correction window. DESIGN.md §12 further says the pool is reserved for the named whitehat for 180 days.

However, the contract anchors corruptedClaimDeadline permanently to the first-ever good-faith CORRUPTED flag. A later, valid re-flag changing an incorrectly named attacker to the actual whitehat keeps that original deadline. Once 180 days have elapsed, the correction is accepted but the newly named whitehat immediately reverts with ClaimWindowExpired; any caller can then sweep the whole bounty to recoveryAddress.

This directly contradicts the documented correction guarantee and bounty reservation: the protocol accepts the corrected beneficiary, yet gives that beneficiary no usable claim period.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// `DESIGN.md` §4 permits this re-flag until a claim starts.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ... terminal-state validation omitted ...
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_; // @> The corrected whitehat is accepted as the new beneficiary.
// ... snapshot accounting omitted ...
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// @> This always reuses the first flag's timestamp, including after the attacker
// address has been corrected. The current named whitehat can therefore receive an
// already-expired deadline.
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
}
function claimAttackerBounty() external nonReentrant {
// ... outcome, faith, and attacker checks omitted ...
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired(); // @> corrected attacker is blocked
// ...
}

Risk

Likelihood:

  • A good-faith CORRUPTED resolution initially names an incorrect, inaccessible, or disputed attacker address and remains unclaimed through the 180-day bounty period.

  • The moderator subsequently uses the explicitly supported pre-claim correction path to replace that address with the actual whitehat; the correction transaction itself succeeds even though the inherited deadline is already expired.

Impact:

  • The actual whitehat permanently loses its entire snapshotTotalStaked + snapshotTotalBonus bounty despite being the address recorded by the corrected, accepted outcome.

  • Any permissionless caller can immediately call sweepUnclaimedCorrupted, sending the entire pool to recoveryAddress; the corrected whitehat cannot prevent this by claiming first.

Proof of Concept

Create a new test file: test/unit/ConfidencePool.semantic.t.sol

Add the code below:

// SPDX-License-Identifier: MIT
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";
/// @notice POC: Business-logic regression tests derived from semantic state-machine review.
contract ConfidencePoolSemanticTest is BaseConfidencePoolTest {
function testPoC_correctedWhitehatCanReceiveZeroClaimWindowAfterOriginalDeadline() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
address mistakenlyNamedAttacker = makeAddr("mistakenlyNamedAttacker");
address correctedWhitehat = makeAddr("correctedWhitehat");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, mistakenlyNamedAttacker);
uint256 originalDeadline = pool.corruptedClaimDeadline();
// `flagOutcome` explicitly supports correcting the attacker before a claim. Once the
// first deadline has elapsed, however, the corrected address receives no usable window.
vm.warp(originalDeadline + 1);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, correctedWhitehat);
assertEq(pool.attacker(), correctedWhitehat, "correction is accepted");
assertEq(pool.corruptedClaimDeadline(), originalDeadline, "old deadline is retained");
vm.prank(correctedWhitehat);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE, "bounty is sweepable immediately");
}
}

Run the test:

forge test --match-test testPoC_correctedWhitehatCanReceiveZeroClaimWindowAfterOriginalDeadline -vvvv

The test passes: the corrected address is stored, its claim reverts, and the full 100-token bounty is swept to recoveryAddress.

Recommended Mitigation

Start a usable bounty window for the beneficiary of a successful correction. The protocol can preserve the existing anti-griefing objective by bounding the number of beneficiary corrections in its policy; it should not silently accept a correction that has no economic effect.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
+ address previousAttacker = attacker;
// ... validation omitted ...
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
if (willBeGoodFaithCorrupted) {
- if (_firstGoodFaithCorruptedAt == 0) {
+ // A successful correction must not nominate a beneficiary whose claim
+ // deadline has already elapsed.
+ if (_firstGoodFaithCorruptedAt == 0 || attacker_ != previousAttacker) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}
}

If the intended policy is instead an absolute, non-extendable deadline, the contract should reject attacker-address corrections after that deadline and DESIGN.md should disclose that limitation. It must not accept a corrected whitehat while making its claim impossible.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!