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

`sweepUnclaimedBonus` omits the `claimsStarted` finality latch, so a moderator SURVIVED→CORRUPTED correction misroutes the named whitehat's bonus to `recoveryAddress`

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

* On a good-faith CORRUPTED resolution the named whitehat is entitled to the entire pool including the bonus (`bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus`). Every value-moving path (`claimSurvived/claimCorrupted/claimAttackerBounty/sweepUnclaimedCorrupted/claimExpired`) set the `claimsStarted` finality latch, which closes the moderator `flagOutcome` re-flag window once funds move
* `sweepUnclaimedBonus` is the only value-moving path that does NOT set `claimsStarted`. When `riskWindowStart == 0` it send the entire bonus to `recoveryAddress` while leaving the re-flag window open. If the moderator then correct `SURVIVED → good-faith CORRUPTED`, `flagOutcome` re-snapshots `snapshotTotalBonus = totalBonus == 0` and cap the whitehat's entitlement at principal only the bonus is permanently stranded at `recoveryAddress`
## Risk
**Likelihood**:
* Occur when an agreement reach terminal CORRUPTED with no active-risk observation (`riskWindowStart == 0`), the moderator flags `SURVIVED` (breach judged out-of-scope), any address call the permissionless `sweepUnclaimedBonus()` and the moderator then correct to good-faith `CORRUPTED`. The sweep is self-triggerable by `recoveryAddress`/the sponsor, who receives the misrouted funds
**Impact**:
* The named whitehat, owed the entire pool including bonus, receive principal only; the full bonus (which can exceed principal) is permanently stranded at `recoveryAddress` with no clawback path
## Proof of Concept
Add as `test/PoC_M01.t.sol` and run `forge test --match-contract PoC_M01 -vv`. Passe on the unmodified contract: whitehat get 200e18 not the 280e18 pool; 80e18 stranded at recovery
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract PoC_M01 is BaseConfidencePoolTest {
function test_bonusMisroutedToRecovery() public {
address sponsor = makeAddr("sponsor");
_stake(alice, 100e18);
_stake(bob, 100e18); // principal = 200e18
_contributeBonus(sponsor, 80e18); // bonus = 80e18 -> whole pool = 280e18
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
pool.sweepUnclaimedBonus(); // permissionless
assertEq(token.balanceOf(recovery), 80e18);
assertFalse(pool.claimsStarted()); // value left, latch NOT set
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // correction honored
assertEq(pool.bountyEntitlement(), 200e18); // capped at principal
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 200e18); // whitehat robbed of the 80e18 bonus
assertEq(token.balanceOf(recovery), 80e18); // bonus permanently misrouted
}
}
```

Recommended Mitigation

Reserve the accounted bonus while the outcome can still be corrected (a good-faith CORRUPTED re-flag needs `!claimsStarted` and a CORRUPTED registry), and drop `totalBonus` only by the accounted bonus actually leaving, never by donations. Add `finalizeOutcome()` so the no-claimant case can still release to recovery (moderator any time; permissionless after the grace).
```diff
- uint256 reserved;
- if (totalEligibleStake != 0) {
- reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
- }
+ uint256 bonusRemaining = snapshotTotalBonus - claimedBonus;
+ bool ownedByStakers = riskWindowStart != 0 && totalEligibleStake != 0;
+ bool correctionReachable =
+ !claimsStarted && _getAgreementState() == IAttackRegistry.ContractState.CORRUPTED;
+ bool bonusStillOwed = ownedByStakers || correctionReachable;
+ uint256 reserved = totalEligibleStake;
+ if (bonusStillOwed) {
+ reserved += bonusRemaining;
+ }
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
- if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ if (!bonusStillOwed) {
+ totalBonus -= Math.min(Math.min(amount, bonusRemaining), totalBonus);
}
```
```diff
+ /// @notice Finalize a SURVIVED outcome (closes the re-flag window) so the no-claimant bonus can
+ /// sweep to recoveryAddress. Moderator any time; anyone after expiry + MODERATOR_CORRUPTED_GRACE.
+ function finalizeOutcome() external {
+ if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
+ if (msg.sender != outcomeModerator && block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+ if (!claimsStarted) claimsStarted = true;
+ }
```

Support

FAQs

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

Give us feedback!