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

Owner Can Modify `expiry` After EXPIRED Resolution When No Stake Has Locked the Pool

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, once a pool resolves to EXPIRED, the resolution timestamp is recorded in outcomeFlaggedAt. For an EXPIRED resolution, this timestamp is expected to correspond to the pool's expiry value at the time of resolution.

However, setExpiry() only checks expiryLocked, and expiryLocked is set only when a successful stake() occurs. When a pool reaches expiry without any stake, claimExpired() can resolve the pool to EXPIRED while expiryLocked remains false. The owner can then call setExpiry() after resolution and change expiry, leaving outcomeFlaggedAt pointing to the old expiry.

This breaks the expected post-resolution consistency between EXPIRED and its recorded expiry timestamp.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
// @> expiryLocked is only set during stake.
if (!expiryLocked) {
expiryLocked = true;
}
...
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
...
} else {
outcome = PoolStates.Outcome.EXPIRED;
// @> EXPIRED records the current expiry as the resolution timestamp.
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
// @> Only expiryLocked is checked.
// @> outcome and claimsStarted are not checked.
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
uint256 oldExpiry = expiry;
// @> expiry can be changed after EXPIRED resolution when expiryLocked is false.
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

Risk

Likelihood:

  • A pool receives no successful stake before expiry, so expiryLocked remains false.

  • After the pool is mechanically resolved to EXPIRED, the owner calls setExpiry() because the setter does not check outcome or claimsStarted.

Impact:

  • The pool can be in an inconsistent resolved state where outcome == EXPIRED but outcomeFlaggedAt != expiry.

  • Off-chain consumers, indexers, frontends, or accounting systems relying on expiry as the canonical EXPIRED resolution timestamp can observe misleading post-resolution state.

Proof of Concept

function test_PoC_SetExpiryAfterExpiredBreaksOutcomeFlaggedAtEqualsExpiry() public {
uint32 oldExpiry = pool.expiry();
assertFalse(pool.expiryLocked(), "setup: no stake yet, expiry is still mutable");
vm.warp(uint256(oldExpiry) + 1);
// No staker is needed. A non-staker can mechanically resolve to EXPIRED after expiry.
vm.prank(dave);
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED), "pool resolved to EXPIRED");
assertTrue(pool.claimsStarted(), "claimExpired locks value-movement finality");
assertEq(pool.outcomeFlaggedAt(), oldExpiry, "EXPIRED records old expiry as outcomeFlaggedAt");
assertFalse(pool.expiryLocked(), "BUG prerequisite: expiry remains mutable after EXPIRED with no stake");
uint256 newExpiry = block.timestamp + 31 days;
pool.setExpiry(newExpiry);
assertEq(pool.expiry(), uint32(newExpiry), "owner changed expiry after EXPIRED resolution");
assertEq(pool.outcomeFlaggedAt(), oldExpiry, "outcomeFlaggedAt remains the original resolution timestamp");
assertTrue(
pool.outcomeFlaggedAt() != pool.expiry(),
"BUG: EXPIRED resolution timestamp invariant is broken by post-resolution setExpiry"
);
}

Recommended Mitigation

Prevent setExpiry() from being called after the pool has already resolved.

function setExpiry(uint256 newExpiry) external onlyOwner {
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
+ if (claimsStarted) revert OutcomeAlreadySet();
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

Another acceptable mitigation is to lock expiry once the pool is resolved by claimExpired():

if (outcome == PoolStates.Outcome.UNRESOLVED) {
...
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
+ expiryLocked = true;
}

Support

FAQs

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

Give us feedback!