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

`sweepUnclaimedBonus` does not set `claimsStarted`, understating good-faith bounty

Author Revealed upon completion

Description

  • The moderator may correct the outcome before any claim (claimsStarted == false). Every path that moves accounted value (claimSurvived / claimExpired / claimCorrupted / sweepUnclaimedCorrupted) sets claimsStarted = true on value movement, closing the re-flag window.

  • sweepUnclaimedBonus() moves accounted bonus and reduces totalBonus but intentionally does not set claimsStarted. Recovery can sweep the accounted bonus before the moderator re-flags the outcome to good-faith CORRUPTED, making the later snapshot's bountyEntitlement (= snapshotTotalStaked + snapshotTotalBonus) lower than it should be.

// src/ConfidencePool.sol
function flagOutcome(...) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
@> stakeToken.safeTransfer(recoveryAddress, amount); // bonus moved, but outcome not latched
}

Risk

Likelihood:

  • The moderator first flags SURVIVED (or EXPIRED) with riskWindowStart == 0, so bonus is not reserved for stakers

  • Recovery (or any caller) calls sweepUnclaimedBonus() before the moderator re-flags to good-faith CORRUPTED

  • The moderator then re-flags to good-faith CORRUPTED naming an attacker, snapshotting the already-swept totalBonus

Impact:

  • The attacker's bountyEntitlement is permanently understated; the swept bonus portion is unrecoverable by the attacker

  • The value split between recovery and attacker is disturbed by a permissionless sweep, diverging from the bounty size the moderator intended when correcting the outcome

Proof of Concept

function testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus(); // recovery front-runs, gets 50 bonus, claimsStarted still false
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE); // should be 150, actually only 100
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE);
}
forge test --offline --match-test testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement

Recommended Mitigation

if (totalEligibleStake == 0 || riskWindowStart == 0) {
+ uint256 bonusRemoved = amount <= totalBonus ? amount : totalBonus;
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ if (bonusRemoved > 0 && !claimsStarted) claimsStarted = true;
}
- // Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);

Support

FAQs

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

Give us feedback!