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

Permissionless post-expiry pokeRiskWindow() converts a term-survivor's principal refund into a freeze-then-confiscation to recoveryAddress

Author Revealed upon completion

Root + Impact

Description

  • A pool that reaches expiry while its agreement was never in an active-risk state during the underwritten term has, per docs/DESIGN.md §2, "survived the full term the stakers underwrote" and is owed EXPIRED — full principal returned. With riskWindowStart == 0, claimExpired() correctly reads a later CORRUPTED registry through to the staker-favorable EXPIRED branch and refunds principal.

  • pokeRiskWindow() has no expiry guard, and _markRiskWindowStart() caps the timestamp at expiry but still writes a non-zero riskWindowStart. After expiry, during a post-term active-risk interval on the still-live agreement, any unprivileged actor can poke and permanently seal riskWindowStart = expiry != 0, flipping claimExpired's auto-CORRUPTED gate from the EXPIRED default to a freeze-then-sweep of a staker who provably survived their term. DESIGN §6 pre-empts this only for scope-blindness (in-scope vs out-of-scope breach); it never addresses this distinct term-blindness axis (a pool with zero in-term risk).

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState(); // seals riskWindowStart on a POST-expiry observation
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // no expiry guard
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry; // t == expiry, still NON-ZERO
riskWindowStart = uint32(t);
}
// claimExpired() auto-resolution:
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator(); // principal FROZEN up to 180 days
}
outcome = PoolStates.Outcome.CORRUPTED; // then swept to recoveryAddress
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}

Risk

Likelihood:

  • The agreement enters a post-expiry active-risk state (a new attack cycle after the pool's term) and is later marked CORRUPTED — normal, permissionless registry operation on a live agreement.

  • A permissionless actor (or any losing party) calls pokeRiskWindow() during that post-term active-risk interval, sealing riskWindowStart = expiry.

Impact:

  • A staker who survived the full underwritten term has their entire principal frozen for the 180-day MODERATOR_CORRUPTED_GRACE window (claimExpired reverts AgreementCorruptedAwaitingModerator; withdraw is already disabled by the riskWindowStart != 0 latch).

  • Absent a moderator SURVIVED flag within the grace window, any caller finalizes bad-faith CORRUPTED and the term-survivor's principal is swept to the sponsor-controlled recoveryAddress — 100% principal loss for a party owed EXPIRED.

Proof of Concept

function test_F1b_termSurvivorPrincipalFrozen() external {
_stake(alice, 100e18); // Alice's confidence bet
vm.warp(expiry + 1); // reaches expiry PRE-RISK the whole term => owed EXPIRED
assertEq(pool.riskWindowStart(), 0); // precondition: no in-term risk was ever observed
// POST-expiry: a new attack cycle begins, and a permissionless griefer pokes
reg.setAgreementState(UNDER_ATTACK);
vm.prank(griefer);
pool.pokeRiskWindow(); // seals riskWindowStart = expiry (non-zero)
reg.setAgreementState(CORRUPTED);
vm.prank(alice);
vm.expectRevert(); // AgreementCorruptedAwaitingModerator: principal FROZEN
pool.claimExpired();
assertEq(token.balanceOf(alice), 0); // term-survivor receives nothing
// CONTROL: omit the poke and the same later CORRUPTED reads through to EXPIRED, refunding principal.
}

Recommended Mitigation

+ bool private riskObservedBeforeExpiry; // set only when the window is first observed in-term
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
+ if (t <= expiry) riskObservedBeforeExpiry = true;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}
// in claimExpired():
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskObservedBeforeExpiry) {

Support

FAQs

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

Give us feedback!