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

Post-expiry pokeRiskWindow() can make an expired pool auto-resolve as CORRUPTED

Author Revealed upon completion

Root + Impact

Normal behavior: once a pool reaches expiry, stakers should be able to call claimExpired() and recover their stake plus any owed bonus when the agreement has not been corrupted during the pool term. DESIGN.md states that resolving EXPIRED while the registry is still active-risk is correct because the agreement survived the term the stakers underwrote.

The issue is that pokeRiskWindow() can still be called after expiry while the pool is unresolved. If the registry is UNDER_ATTACK after the pool has already expired, anyone can call pokeRiskWindow() and set riskWindowStart to expiry. This makes the pool eligible for the auto-CORRUPTED backstop later, even though the observed risk started after the pool term ended.

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);
}

Later, claimExpired() checks only whether riskWindowStart != 0. Since pokeRiskWindow() set it after expiry, the pool can auto-resolve as bad-faith CORRUPTED and sweep all funds to recoveryAddress.

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED;
@> corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
@> claimsStarted = true;
return;
}

Risk

Likelihood:

  • The pool expires before any active-risk state is observed.

  • The registry is later observed in UNDER_ATTACK or PROMOTION_REQUESTED.

  • Any permissionless caller calls pokeRiskWindow() before stakers call claimExpired().

  • The registry later reaches CORRUPTED, and the moderator does not resolve before the grace backstop.

Impact:

  • Stakers permanently lose all unclaimed principal and bonus.

  • The full pool balance is swept to recoveryAddress through the bad-faith CORRUPTED path.

  • The pool settles based on post-expiry risk, even though stakers only underwrote risk until expiry.

Proof of Concept

Add this test to test/unit/ClaimExpiredRegistryGate.t.sol.

function testAudit_PostExpiryPokeCanTurnExpiredPoolIntoAutoCorrupted() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
uint256 expiryTs = pool.expiry();
// The pool term has already ended.
vm.warp(expiryTs + 1);
// Active-risk is observed only after expiry.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Anyone can call pokeRiskWindow instead of resolving EXPIRED.
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
// Later, the agreement becomes CORRUPTED after the pool term.
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);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}

Run:

forge test --match-test testAudit_PostExpiryPokeCanTurnExpiredPoolIntoAutoCorrupted -vvv

Expected result:

[PASS] testAudit_PostExpiryPokeCanTurnExpiredPoolIntoAutoCorrupted()

This shows that 150 tokens of staker principal plus 30 bonus tokens are swept to recoveryAddress, while both stakers receive zero.

Recommended Mitigation

Do not allow pokeRiskWindow() to open a risk window after the pool has expired. Post-expiry resolution should go through claimExpired().

+ error PoolExpired();
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) revert PoolExpired();
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Alternatively, prevent _markRiskWindowStart() from setting riskWindowStart after expiry.

function _markRiskWindowStart() internal {
+ if (block.timestamp >= expiry) return;
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
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!