FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

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();
}
```
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

MODERATOR_CORRUPTED_GRACE anchored to expiry instead of first CORRUPTED observation gives the moderator zero actionable window before permissionless bad-faith settlement

Impact - High The mechanical branch always picks bad-faith, so the whole corpus lands at recoveryAddress and claimsStarted makes it permanent in the same call. Both alternatives it forecloses send the money elsewhere: DESIGN.md #8 lets the moderator flag SURVIVED for an out-of-scope breach, returning everything to stakers, and good-faith CORRUPTED reserves the pool for a named whitehat. Losing the classification decides who gets paid. Likelihood - Low The pool has to sit unresolved through the whole window past expiry, which is the hard part, since claimExpired stays permissionless throughout and would settle it as EXPIRED against the live active-risk state. Any staker with principal waiting has reason to make that call. Once corruption does land late, the moderator's first legal moment to classify and the fallback's first to finalize arrive together, so ordering alone decides it.

Support

FAQs

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

Give us feedback!