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

Auto-CORRUPTED grace window is anchored to `expiry`, not to when corruption became observable, so a corruption that turns terminal long after `expiry` gives the moderator zero window to name a good-faith attacker

Author Revealed upon completion

## Description
`claimExpired` provides a permissionless auto-CORRUPTED backstop for an absent moderator. To protect the moderator's exclusive right to resolve a *good-faith* CORRUPTED (naming a whitehat attacker for the bounty), the backstop defers for a grace period — but that grace is measured from **`expiry`**, not from when the corruption actually became observable (`riskWindowEnd`)
```solidity
// src/ConfidencePool.sol:540 (claimExpired — auto-CORRUPTED branch)
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) { // anchored to expiry, not to corruption time
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED; // bad-faith (address(0) moderator)
...
claimsStarted = true; // latches finality; moderator can no longer re-flag good-faith
return;
}
```
If an agreement becomes terminal `CORRUPTED` **more than `MODERATOR_CORRUPTED_GRACE` (180 days) after `expiry`**, the grace is already fully elapsed at the moment corruption is first observable. The very first `claimExpired` immediately auto-resolves **bad-faith** CORRUPTED and latches `claimsStarted`, so the moderator gets **zero** window to name a good-faith attacker. The whitehat bounty path (`claimAttackerBounty`) is therefore unavailable (`bountyEntitlement == 0`); the pool sweeps to `recoveryAddress` via `claimCorrupted`.
No staker principal is lost or misdirected relative to a correct bad-faith resolution (a CORRUPTED agreement forfeits staker principal to recovery in either case). The only affected party is the **whitehat bounty claimant** — a third party who would have been named had the grace been measured from the corruption event.
**Root cause (GitHub permalinks):**
- Grace anchored to `expiry` in `claimExpired`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L540
- `claimExpired` auto-CORRUPTED latches `claimsStarted`, foreclosing a later good-faith flag: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L549
- `flagOutcome` re-flag gate on `claimsStarted`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L327

## Risk
**Likelihood:**
- Requires a corruption that becomes terminal **> 180 days after `expiry`** AND a whitehat the moderator would otherwise have named. Under the DAO-moderator trust model the moderator resolves promptly, so this is an edge of an edge.
**Impact:**
- **No staker principal loss or misdirection**: a truly CORRUPTED agreement forfeits principal to `recoveryAddress` under both good-faith and bad-faith resolution.
- The good-faith **whitehat bounty path is denied** for a late-terminal corruption; the would-be-named attacker (third party) receives nothing.

Proof of Concept

```solidity
function test_HS5B_lateCorruption_zeroModeratorWindow() public {
_stake(alice, 100 * ONE);
// Observe a risk window so the auto-CORRUPTED branch is eligible.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// Corruption becomes terminal far past expiry + grace (grace is measured from expiry, not here).
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// First caller immediately forces bad-faith CORRUPTED; no good-faith/attacker window.
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "auto bad-faith CORRUPTED");
assertTrue(pool.claimsStarted(), "locked; moderator cannot name a good-faith attacker");
assertEq(pool.bountyEntitlement(), 0, "no good-faith bounty path available");
// A late good-faith flag now reverts.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, makeAddr("wh"));
}
```
**Outcome (as run):**
```
[PASS] test_HS5B_lateCorruption_zeroModeratorWindow() (gas: 463743)
```
The test confirms a late-terminal corruption auto-resolves bad-faith CORRUPTED with `bountyEntitlement == 0` and a foreclosed good-faith re-flag.

Recommended Mitigation

Anchor the auto-CORRUPTED grace to the **corruption-observation moment** (`riskWindowEnd`) rather than `expiry`, so the moderator always receives a full window relative to when corruption actually became terminal:
```diff
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ // Grace measured from corruption observation, not the pool's own expiry, so a late-terminal
+ // corruption still grants the moderator a full good-faith window.
+ uint256 graceAnchor = riskWindowEnd != 0 ? riskWindowEnd : expiry;
+ if (block.timestamp < graceAnchor + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
```

Support

FAQs

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

Give us feedback!