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 {
...
if (!expiryLocked) {
expiryLocked = true;
}
...
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
...
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
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);
}
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);
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;
}