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

Post-expiry live-state resolution can retroactively confiscate expired-pool principal

Author Revealed upon completion

Root + Impact

Description

The pool stops accepting stake at expiry and exposes claimExpired() after that timestamp. The risk-window bookkeeping is intended to measure registry risk observed during the pool term, while the 180-day MODERATOR_CORRUPTED_GRACE permits mechanical settlement when the outcome moderator remains unavailable. The contract does not preserve a strict temporal boundary at expiry. A first active-risk observation made after expiry still initializes riskWindowStart because _markRiskWindowStart() replaces the real observation time with expiry. This makes post-expiry risk indistinguishable from risk observed exactly at the term boundary.
An unresolved pool whose risk window opened before expiry is also affected. claimExpired() checks only the registry's current CORRUPTED state and a nonzero riskWindowStart; it does not establish that corruption occurred before expiry. Corruption first occurring after the covered term therefore selects the same bad-faith CORRUPTED outcome as an in-term corruption. Once the grace period has elapsed, anyone can execute the transition and then call claimCorrupted(). The entire pool balance, including third-party principal and bonus, is transferred to recoveryAddress. The repository documents that settlement reads live registry state, but it does not expressly disclose that risk and corruption beginning only after expiry can confiscate principal from an expired pool. This finding assumes expiry is intended to end the covered risk term. A protocol decision that expiry merely opens settlement while later events remain covered would instead make this an economically significant design choice.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
// @> A genuinely post-expiry observation is rewritten as an
// @> observation made exactly at expiry.
if (t > expiry) t = expiry;
// @> This nonzero value later arms the auto-CORRUPTED branch.
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}
function claimExpired() external nonReentrant {
// The registry is read live when settlement eventually occurs.
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
// @> No check proves that risk or corruption occurred by expiry.
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// @> A post-expiry corruption becomes a principal-confiscating outcome.
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
// Otherwise the staker-favorable EXPIRED path executes.
// ...
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
uint256 toSweep = stakeToken.balanceOf(address(this));
// @> All remaining principal and bonus leave the pool.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

An unresolved pool remains open beyond expiry + MODERATOR_CORRUPTED_GRACE, while the pool outcome moderator does not finalize an outcome during the grace period.
The registry first enters UNDER_ATTACK or PROMOTION_REQUESTED after expiry and a permissionless pokeRiskWindow() call records the late risk window at the capped expiry timestamp. Alternatively, the pool observes active risk during its term and the registry first becomes CORRUPTED after expiry, before anybody resolves the pool as EXPIRED. Any account can execute pokeRiskWindow(), claimExpired(), and claimCorrupted(); exploitation does not require pool-owner or outcome-moderator permission once the registry states and grace-period conditions exist. Prompt staker settlement at expiry prevents the later transition, reducing likelihood but creating a race around a safety property that should be enforced by contract state rather than user monitoring.
Impact:

Every staker loses all eligible principal after the pool is mechanically finalized as bad-faith CORRUPTED. The complete bonus balance is transferred with the principal, allowing the recovery destination to receive the pool's entire token balance. The final CORRUPTED outcome sets claimsStarted and prevents stakers from using the EXPIRED claim path that would otherwise return their principal.

Proof of Concept

function testAudit_PostExpiryRiskCanArmCorruptedBackstop() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
uint256 expiryTs = pool.expiry();
// Move beyond both the pool term and the moderator grace period
// before any active risk has been observed.
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// A first risk observation made after the entire term and grace period succeeds.
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs);
// Corruption also occurs only after expiry.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.claimsStarted());
uint256 aliceAfterStake = token.balanceOf(alice);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE);
assertEq(token.balanceOf(alice), aliceAfterStake);
assertEq(token.balanceOf(address(pool)), 0);
}
function testAudit_InTermRiskButPostExpiryCorruptionConfiscatesPrincipal()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
// Open the risk window during the pool term.
_passThroughUnderAttack();
assertLt(pool.riskWindowStart(), pool.expiry());
// No corruption occurs during the term. It is introduced only after
// expiry and after the moderator grace period.
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.claimsStarted());
uint256 aliceAfterStake = token.balanceOf(alice);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE);
assertEq(token.balanceOf(alice), aliceAfterStake);
assertEq(token.balanceOf(address(pool)), 0);
}

Recommended Mitigation

Reject first active-risk observations made after expiry and retain an uncapped observation timestamp for terminal states. Auto-CORRUPTED settlement should require evidence that the terminal state existed during the covered term.

An authoritative transition timestamp supplied by the registry is preferable. A local observation timestamp is conservative because corruption occurring before expiry but first observed afterward would not qualify automatically; such cases can remain subject to independent moderator resolution.

+ uint64 public riskWindowEndObservedAt;
function _markRiskWindowStart() internal {
+ // Risk first observed after the covered term cannot arm the fallback.
+ if (block.timestamp > expiry) return;
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// ...
}
function _markRiskWindowEnd() internal {
+ // Preserve the real observation time before applying any accounting cap.
+ riskWindowEndObservedAt = uint64(block.timestamp);
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
// ...
}
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && riskWindowEndObservedAt != 0
+ && riskWindowEndObservedAt <= expiry
+ ) {
// auto-CORRUPTED resolution
}

Merely requiring riskWindowEnd <= expiry does not fix the issue. The current implementation already caps late observations to expiry, so that condition is true even when corruption is first observed long after the term ended

Support

FAQs

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

Give us feedback!