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

# Mis-named good-faith attacker can drain the whole pool instantly, defeating the pre-claim correction window

Author Revealed upon completion

Summary

docs/DESIGN.md §4 promises the moderator can re-flag to "fix a typo'd outcome/attacker before any participant locks in the wrong distribution." This guarantee fails for a wrong attacker address: there is no delay between flagOutcome(CORRUPTED, goodFaith=true, attacker) and claimAttackerBounty(), so the named address can take the entire pool in the same block and latch claimsStarted, permanently foreclosing the correction.

Description

flagOutcome immediately makes the whole pool the named address's entitlement (src/ConfidencePool.sol:362). claimAttackerBounty only checks the upper deadline bound — there is no minimum-wait gate:

if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired(); // L437 — passes immediately
...
if (payout > 0) { ... claimsStarted = true; stakeToken.safeTransfer(attacker, payout); } // L446-449

Setting claimsStarted = true makes the moderator's corrective re-flag revert OutcomeAlreadySet (src/ConfidencePool.sol:327). A dead-address typo is self-correcting (nobody claims); only a live/adversarial address triggers the loss — the exact case the correction window is meant to defend.

Risk

Likelihood: Low — requires a moderator naming error and a live/adversarial named address.

Impact: High — the entire pool (all staker principal + bonus) is transferred to the wrong party, irreversibly.

Proof of Concept

test/poc/ManualReviewPoC.t.sol::testPoC_misNamedAttackerDrainsPoolAndForeclosesCorrection:

function testPoC_misNamedAttackerDrainsPoolAndForeclosesCorrection() external {
_stake(alice, 600 * ONE);
_stake(bob, 400 * ONE);
_contributeBonus(carol, 200 * ONE); // pool = 1000 principal + 200 bonus
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator intends `whitehat` but is fed the WRONG address.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wrongAttacker);
// Same block: the wrong address takes the ENTIRE pool.
vm.prank(wrongAttacker);
pool.claimAttackerBounty();
// Correction now reverts -> foreclosed, loss permanent.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(token.balanceOf(wrongAttacker), 1200 * ONE);
assertEq(token.balanceOf(whitehat), 0);
}

Result (forge test --match-test testPoC_misNamedAttacker -vvvv):

[PASS] testPoC_misNamedAttackerDrainsPoolAndForeclosesCorrection()
├─ ConfidencePool::flagOutcome(2, true, wrongAttacker)
│ └─ emit OutcomeFlagged(moderator, outcome: 2, goodFaith: true, attacker: wrongAttacker)
├─ ConfidencePool::claimAttackerBounty()
│ ├─ emit Transfer(from: pool, to: wrongAttacker, value: 1.2e21) // whole pool
│ └─ emit AttackerBountyClaimed(wrongAttacker, 1.2e21, ...)
├─ ConfidencePool::flagOutcome(2, true, whitehat)
│ └─ ← [Revert] OutcomeAlreadySet() // correction foreclosed

Recommended Mitigation

Add a mandatory delay between the good-faith flag and the first bounty claim, so the documented correction window is real. This keeps the 180-day claim window intact while giving the moderator time to correct a mistyped attacker before value moves.

+ uint256 public constant CORRECTION_DELAY = 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 < _firstGoodFaithCorruptedAt + CORRECTION_DELAY) revert CorrectionWindowOpen();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();

(add error CorrectionWindowOpen(); to IConfidencePool).

Support

FAQs

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

Give us feedback!