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

Bonus sweep shorts corrected whitehat bounty

Author Revealed upon completion

Affected: ConfidencePool.sol:474-509 · ConfidencePool.sol:355-362

Description

When riskWindowStart == 0, sweepUnclaimedBonus() transfers the accounted bonus to recoveryAddress and reduces totalBonus, but intentionally leaves claimsStarted = false. A subsequent legitimate correction from SURVIVED to good-faith CORRUPTED re-snapshots the now-depleted totalBonus, so bountyEntitlement collapses to principal-only.

// L499-506
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. [anti-donation-grief]
stakeToken.safeTransfer(recoveryAddress, amount);
// L358, L362 — correction re-reads live totals
snapshotTotalBonus = totalBonus;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

Sequence: moderator flags SURVIVED (out-of-scope) → permissionless sweepUnclaimedBonus() → moderator corrects to good-faith CORRUPTED → whitehat claims principal only; the swept bonus is unrecoverable. Breaks DESIGN.md §4 (value movement locks correction) and §12 (good-faith bounty = entire pool).

Risk

Likelihood: Documented, honest sequence — DESIGN §8 supports out-of-scope SURVIVED on CORRUPTED registry; DESIGN §4 keeps correction open until first value movement.

Impact: Permanent misallocation of totalBonus to recoveryAddress; whitehat is short the full bonus and pool balance is zero.

Actor: sweepUnclaimedBonus() is permissionless.

Recommended Mitigation

Set claimsStarted only when the sweep removes accounted bonus. Dust/donation-only sweeps still leave the window open.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
+ if (accountedBonusSwept != 0 && !claimsStarted) claimsStarted = true;
}

Proof of Concept

Save as test/M1BonusSweepReflagPoC.t.sol and run forge test --match-contract M1BonusSweepReflagPoC -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract M1BonusSweepReflagPoC is BaseConfidencePoolTest {
uint256 internal constant PRINCIPAL = 100 * ONE;
uint256 internal constant BONUS = 50 * ONE;
function testBonusSweepShortsCorrectedWhitehatBounty() external {
_stake(alice, PRINCIPAL);
_contributeBonus(carol, BONUS);
// Canonical lifecycle; no pool call during UNDER_ATTACK -> riskWindowStart stays 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(block.timestamp + 7 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
pool.sweepUnclaimedBonus();
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), PRINCIPAL);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), PRINCIPAL); // whitehat: principal only
assertEq(token.balanceOf(recovery), BONUS); // recovery: full bonus already gone
assertEq(token.balanceOf(address(pool)), 0); // shortfall unrecoverable
}
}

Support

FAQs

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

Give us feedback!