Root + Impact
Description
Expected behavior: after the insured term expires without an observed risk window, a later registry observation should not create the predicate for auto-CORRUPTED loss.
Issue: pokeRiskWindow can open riskWindowStart after expiry while leaving the pool unresolved. _markRiskWindowStart caps the late observation to expiry instead of rejecting it, so any caller can make an already-expired pool look like it had an observed risk window. A later CORRUPTED state then lets claimExpired route the whole pool to the auto-CORRUPTED path.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t);
}
function claimExpired() external nonReentrant {
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED;
return;
}
}
The auto-CORRUPTED backstop may be intended, but its predicate should not be creatable after the pool's insured term has already ended.
Risk
Likelihood:
Low. The path requires an unresolved pool to pass expiry with riskWindowStart == 0, a post-expiry active-risk observation, later CORRUPTED registry state, and no moderator action during the grace window.
The trigger is unprivileged because any caller can invoke pokeRiskWindow.
Impact:
Proof of Concept
The native Foundry PoC shows a pool with 150 staked tokens and 30 bonus tokens being converted after expiry:
function testPocPostExpiryPokeCanConvertExpiredPoolIntoAutoCorruptedLoss() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 180 * ONE);
}
Test location:
test/unit/ClaimExpiredRegistryGate.t.sol::testPocPostExpiryPokeCanConvertExpiredPoolIntoAutoCorruptedLoss
Targeted command:
forge test --match-test testPocPostExpiryPokeCanConvertExpiredPoolIntoAutoCorruptedLoss -vvv
Observed output:
Ran 1 test for test/unit/ClaimExpiredRegistryGate.t.sol:ClaimExpiredRegistryGateTest
[PASS] testPocPostExpiryPokeCanConvertExpiredPoolIntoAutoCorruptedLoss() (gas: 624853)
Suite result: ok. 1 passed; 0 failed; 0 skipped
Recommended Mitigation
Do not let pokeRiskWindow or _markRiskWindowStart open the risk window after the pool has expired.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (riskWindowStart == 0 && block.timestamp >= expiry) revert PoolExpired();
_observePoolState();
}
function _markRiskWindowStart() internal {
+ if (block.timestamp >= expiry) return;
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}
Alternatively, route post-expiry observations through the same expiry resolution semantics as claimExpired instead of mutating risk-window state only.