## 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
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(); }
@> if (riskWindowEnd == 0 && _isTerminalState(state)) { _markRiskWindowEnd(); }
}
```
```solidity
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
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:
- `_markRiskWindowStart` caps at `expiry` but not at `riskWindowEnd`: https:
## 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);
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");
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");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
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);
}
```