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

`riskWindowStart <= riskWindowEnd` is never enforced: a non-monotonic (rewinding) registry can seal the risk-window start *after* the end, inverting the k=2 time coordinates

Author Revealed upon completion

## Description
The pool derives the k=2 bonus interval from two markers sealed by observing the external attack registry: `riskWindowStart` (first active-risk observation) and `riskWindowEnd` (first terminal observation). `riskWindowStart` becomes the lower bound (effective entry floor) and `riskWindowEnd` becomes `T`, the upper bound in the score `amount × (T − entry)²`. The whole design assumes `riskWindowStart <= riskWindowEnd`.
`_observePoolState` seals the two markers **independently**, and nothing enforces their ordering:
```solidity
// src/ConfidencePool.sol — _observePoolState()
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) { _markRiskWindowStart(); } // seals START
@> if (riskWindowEnd == 0 && _isTerminalState(state)) { _markRiskWindowEnd(); } // seals END (independently)
}
```
```solidity
// src/ConfidencePool.sol — _markRiskWindowStart(): caps at expiry, but NOT at riskWindowEnd
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry; // capped at expiry only — no `riskWindowEnd` clamp
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t; // eager reset
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
```
If the registry reaches a **terminal** state first (e.g. `NEW_DEPLOYMENT → CORRUPTED`, no active-risk observed) `riskWindowEnd` seals while `riskWindowStart` stays `0`. If the registry then **rewinds** to an active-risk state (`CORRUPTED → UNDER_ATTACK`) and any interaction observes it, `_markRiskWindowStart` seals `riskWindowStart` at a **later** timestamp than `riskWindowEnd`. The invariant `riskWindowStart <= riskWindowEnd` is broken. During a SURVIVED/CORRUPTED resolution `T = riskWindowEnd`, so effective entries (floored to `riskWindowStart`) become *greater* than `T`, i.e. `(T − entry)` is negative.
The consequence is **benign** — the score uses the *square* `(T − entry)²`, which is non-negative regardless of sign, and `_bonusShare`'s underflow guards keep every term ≥ 0 — so there is **no panic and no over-payment**. The finding is that a documented, load-bearing invariant of the accounting engine is **not enforced in code**, relying entirely on the (trusted) registry being monotonic. It is reported as a defense-in-depth / robustness gap.
**Root cause (GitHub permalinks):**
- `_observePoolState` seals start/end independently, no ordering check: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L784-L799
- `_markRiskWindowStart` caps at `expiry` but not at `riskWindowEnd`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L801-L817

## Risk
**Likelihood:**
- Occurs when the external `IAttackRegistry` transitions non-monotonically — reaching a terminal state (`CORRUPTED`/`PRODUCTION`) and later reporting an active-risk state (`UNDER_ATTACK`/`PROMOTION_REQUESTED`) — which the protocol's trust model assumes never happens (registry is a trusted, monotonic singleton). It is therefore an out-of-adversarial-model trigger.
- Requires an interaction (`pokeRiskWindow` or any gated entrypoint) to observe each of the two out-of-order states.
**Impact:**
- A documented internal invariant (`riskWindowStart <= riskWindowEnd`, and by extension the coordinate ordering the k=2 formula assumes) is silently violated.
- the k=2 score is a square, so bonus shares stay non-negative and bounded by the pot; claims do not revert. Impact is limited to the correctness/robustness of the accounting under a misbehaving oracle.

Proof of Concept

## Proof of Concept
`test/poc/PoCSpecFramework.t.sol`. The test forces `riskWindowStart > riskWindowEnd` via a registry rewind and asserts the math stays sound (no revert, no over-payment).
```solidity
function test_spec_riskWindowOrderingViolation_isBenign() public {
_contributeBonus(dave, 100 ether);
_stake(alice, 100 ether);
// Terminal FIRST (CORRUPTED) with no active-risk observed → seals riskWindowEnd, start stays 0.
vm.warp(BASE_TIMESTAMP + 2 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
uint32 end = pool.riskWindowEnd();
assertEq(uint256(pool.riskWindowStart()), 0, "start not sealed by terminal state");
// REWIND: registry back to active-risk → seals riskWindowStart LATER than riskWindowEnd.
vm.warp(BASE_TIMESTAMP + 8 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint32 start = pool.riskWindowStart();
assertGt(start, end, "INVARIANT VIOLATED (out-of-model rewind): riskWindowStart > riskWindowEnd");
// Resolve SURVIVED with T = riskWindowEnd < riskWindowStart.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Claim MUST NOT panic; score = stake*(T - start)^2 >= 0 despite T < start.
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - 100 ether;
assertLe(aliceBonus, 100 ether, "no overpay; total bonus <= pot despite the ordering violation");
}
```
**Outcome (as run):**
```
Ran 1 test for test/poc/PoCSpecFramework.t.sol:PoCSpecFramework
[PASS] test_spec_riskWindowOrderingViolation_isBenign() (gas: 533400)
```
The test passes: `riskWindowStart > riskWindowEnd` is reachable, the invariant is violated, yet the claim does not revert and the payout never exceeds the pot .

Recommended Mitigation

Enforce the ordering when sealing the start: never record a `riskWindowStart` later than an already-observed `riskWindowEnd`. This is a one-line, defense-in-depth clamp that makes the accounting robust to a non-monotonic registry without changing any legitimate path.
```diff
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
+ // Defense-in-depth against a non-monotonic registry: never seal a start after a terminal
+ // moment that was already observed, preserving riskWindowStart <= riskWindowEnd.
+ if (riskWindowEnd != 0 && t > riskWindowEnd) t = riskWindowEnd;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
```

Support

FAQs

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

Give us feedback!