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

sweepUnclaimedBonus during the open re-flag window strips the good-faith CORRUPTED whitehat of the entire bonus pool

Author Revealed upon completion

Description

A good-faith CORRUPTED outcome must award the entire pool (principal + bonus) to the named whitehat (DESIGN.md §12). A permissionless sweepUnclaimedBonus() run while the outcome is still mutable (before any claim) permanently removes the bonus, so a later good-faith CORRUPTED re-flag awards the whitehat only the principal — the bonus is misrouted to recoveryAddress.

Root + Impact

sweepUnclaimedBonus() (ConfidencePool.sol:474) requires only outcome ∈ {SURVIVED, EXPIRED} and, by design, does not latch claimsStarted (:503-506, to protect the re-flag window from a 1-wei donation griefer). On a SURVIVED outcome with riskWindowStart == 0 it reserves only totalEligibleStake (:483-488), sweeps the whole bonus to recoveryAddress, and permanently sets totalBonus = 0 (:499-501).

Because claimsStarted stays false, the moderator can still re-flag SURVIVED → good-faith CORRUPTED (:327, allowed pre-claim; SURVIVED is valid on a CORRUPTED registry per DESIGN.md §8). flagOutcome re-snapshots snapshotTotalBonus = totalBonus = 0 (:358), so bountyEntitlement = snapshotTotalStaked + 0 (:362) — the whitehat loses the entire bonus.

This violates §12 (entire-pool bounty) and §4's own rule ("once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting"): the bonus leaves via the sweep, yet the re-flag is still honored and breaks the distribution. Impact: a legitimate good-faith whitehat is shorted the entire bonus (misrouted to recoveryAddress); value is conserved (no insolvency) → Medium.

Risk

Likelihood: Medium — all preconditions are documented as normal: riskWindowStart == 0 (nobody poked during active-risk, §5); the moderator uses the supported SURVIVED → good-faith CORRUPTED correction (§4); any EOA calls sweepUnclaimedBonus() in between (front-runnable the instant SURVIVED is flagged).

Impact: Medium — the good-faith whitehat is deprived of the entire bonus B; no insolvency.

Proof of Concept

test/unit/PoCBonusStrip.t.sol (reuses BaseConfidencePoolTest), run forge test --match-contract PoCBonusStrip: both pass — clean path bountyEntitlement == P+B (250e18); sweep+reflag path == P (200e18), whitehat receives only P, bonus B (50e18) stranded at recoveryAddress.

function test_bug_sweepStripsWhitehatBonus() public {
_stake(alice, 100e18); _stake(bob, 100e18); // P = 200e18
_contributeBonus(makeAddr("sponsor"), 50e18); // B = 50e18
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // no poke => riskWindowStart == 0
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // claimsStarted stays false
vm.prank(makeAddr("griefer")); pool.sweepUnclaimedBonus(); // sweeps B, totalBonus -> 0, no claimsStarted latch
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // re-snapshots bonus = 0
assertEq(pool.bountyEntitlement(), 200e18); // BUG: shorted by B (intent 250e18)
vm.prank(attacker); pool.claimAttackerBounty();
assertEq(token.balanceOf(recovery), 50e18); // bonus misrouted
}

Recommended Mitigation

When sweepUnclaimedBonus removes bonus from totalBonus (the riskWindowStart == 0 / no-stakers branch), latch a finality flag (claimsStarted = true, or a dedicated bonusSwept) so a later good-faith CORRUPTED re-flag cannot re-snapshot a depleted pool. The 1-wei-griefer concern does not apply to this branch (it only fires when genuinely-unowed bonus leaves). Alternatively: block the sweep while the outcome is mutable, or derive the good-faith CORRUPTED bountyEntitlement from the pre-sweep balance.

Support

FAQs

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

Give us feedback!