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

Pre-claim `sweepUnclaimedBonus` permanently shrinks a corrected good-faith bounty

Author Revealed upon completion

Summary

sweepUnclaimedBonus moves the full bonus to recoveryAddress without latching claimsStarted. If the moderator later corrects a SURVIVED flag to good-faith CORRUPTED, the re-snapshot uses the reduced totalBonus, so the whitehat's bounty permanently excludes the swept bonus — breaking the DESIGN §4 promise that the pre-claim correction window is lossless.

Description

Under a moderator SURVIVED flag with riskWindowStart == 0, the bonus is unreserved and swept to recoveryAddress, zeroing totalBonus, but claimsStarted is intentionally left false (src/ConfidencePool.sol:499-506):

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // -> 0
}
stakeToken.safeTransfer(recoveryAddress, amount); // claimsStarted stays false

Because the window stays open, a correction to good-faith CORRUPTED re-snapshots the reduced bonus:

snapshotTotalBonus = totalBonus; // L358 — reduced
bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus; // L362 — missing swept bonus

This window opens only via a moderator flagOutcome(SURVIVED) — the claimExpired auto-resolve paths latch claimsStarted (L549, L575) and are immune.

Risk

Likelihood: Low — requires a moderator SURVIVED → CORRUPTED correction with a permissionless sweep interleaved.

Impact: Medium — the bonus portion of a good-faith bounty is permanently diverted from the whitehat to the sponsor-controlled recoveryAddress.

Proof of Concept

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

function testPoC_preClaimBonusSweepShrinksCorrectedBounty() external {
_stake(alice, 1000 * ONE);
_contributeBonus(carol, 300 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // riskWindowStart == 0
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // does NOT latch claimsStarted
pool.sweepUnclaimedBonus(); // full 300 bonus -> recovery, totalBonus -> 0
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat); // re-snapshots reduced totalBonus
assertEq(pool.bountyEntitlement(), 1000 * ONE); // principal only, NOT 1300
assertLt(pool.bountyEntitlement(), 1300 * ONE);
}

Result (-vvvv):

[PASS] testPoC_preClaimBonusSweepShrinksCorrectedBounty()
├─ flagOutcome(1, false, 0x0) // SURVIVED, claimsStarted false
├─ sweepUnclaimedBonus()
│ ├─ emit Transfer(from: pool, to: recovery, value: 3e20) // bonus leaves the pool
│ └─ emit BonusSwept(..., amount: 3e20)
├─ flagOutcome(2, true, whitehat) // corrected CORRUPTED
└─ bountyEntitlement() → 1e21 // 1000, not 1300

Recommended Mitigation

Latch claimsStarted when the sweep decrements accounted totalBonus (value-movement finality). A pure donation/dust sweep still does not latch, so a 1-wei donation cannot foreclose the re-flag window.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Accounted bonus has left the pool: this IS value-movement finality. Latch it so a
+ // later re-flag cannot re-snapshot a reduced totalBonus and shrink the bounty.
+ if (!claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!