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

setExpiry After pokeRiskWindow Creates riskWindowStart > expiry State Inconsistency

Author Revealed upon completion

Description

Normally, expiry is mutable by the pool sponsor until the first stake (expiryLocked latch), and riskWindowStart is sealed by _markRiskWindowStart which caps the value at expiry to prevent the risk window from opening past the pool deadline. The expiryLocked latch is designed to protect staker reliance on the deadline that feeds the k=2 bonus formula.

However, expiryLocked only latches on the first stake call. An unprivileged user can call pokeRiskWindow to seal riskWindowStart (capped at the current expiry) before any stake occurs. The sponsor can then call setExpiry with a lower value, creating a state where riskWindowStart > expiry. This breaks the invariant that _markRiskWindowStart's cap was designed to enforce.

// ConfidencePool.sol — expiryLocked only latches on stake, not on risk window observation
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
if (!expiryLocked) { // @> only latches here, not when riskWindowStart is sealed
expiryLocked = true;
}
// ...
}
// ConfidencePool.sol — setExpiry has no check against riskWindowStart
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); // @> does not check riskWindowStart != 0
// ...
expiry = uint32(newExpiry);
}
// ConfidencePool.sol — _markRiskWindowStart caps at CURRENT expiry, which can later decrease
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry; // @> cap uses current expiry, not a locked value
riskWindowStart = uint32(t);
// ...
}

Risk

Likelihood:

  • An unprivileged attacker calls pokeRiskWindow during the UNDER_ATTACK registry phase, sealing riskWindowStart = min(now, expiry) before any staker has deposited.

  • The sponsor subsequently lowers expiry via setExpiry (permitted because expiryLocked is still false — no stake has occurred). This is a legitimate sponsor action since the sponsor may not know a third party sealed the risk window.

Impact:

  • When claimExpired auto-resolves, _markRiskWindowEnd caps riskWindowEnd = min(now, newExpiry) < riskWindowStart. Then outcomeFlaggedAt = riskWindowEnd < riskWindowStart.

  • In _bonusShare, every staker's score becomes (riskWindowStart - T)^2 * stake — identical for all stakers, collapsing the k=2 time-weighting to a degenerate amount-weighted split. The bonus distribution mechanism is defeated.

  • The state riskWindowStart > expiry is semantically inconsistent: it implies the risk window opened after the pool expired, which should be impossible.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ExpiryRiskWindowPoC is Test {
ConfidencePool pool;
// Simplified: assumes registry mock returns UNDER_ATTACK then PRODUCTION
function test_expiryBelowRiskWindowStart() public {
// Pool created with expiry = T1 (e.g., block.timestamp + 90 days)
uint256 T1 = block.timestamp + 90 days;
// ... initialize pool with expiry = T1 ...
// Step 1: Attacker (unprivileged) calls pokeRiskWindow
// Registry is UNDER_ATTACK → riskWindowStart = min(now, T1) = now
vm.warp(block.timestamp + 10 days);
pool.pokeRiskWindow();
uint256 sealedRiskWindowStart = pool.riskWindowStart();
assertGt(sealedRiskWindowStart, 0);
// Step 2: Sponsor lowers expiry (allowed — no stake yet, expiryLocked = false)
uint256 T2 = block.timestamp + 20 days; // T2 < sealedRiskWindowStart
pool.setExpiry(T2);
assertLt(pool.expiry(), sealedRiskWindowStart);
// State is now inconsistent: riskWindowStart > expiry
// Step 3: After expiry, claimExpired resolves with riskWindowEnd capped at T2
vm.warp(T2 + 1);
// Registry now PRODUCTION → riskWindowEnd = min(now, T2) = T2
// outcomeFlaggedAt = T2 < riskWindowStart
// All stakers get identical (riskWindowStart - T2)^2 score → degenerate fallback
}
}

Recommended Mitigation

Latch expiryLocked when riskWindowStart is first sealed, not only on the first stake. Alternatively, add a check in setExpiry:

function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (riskWindowStart != 0 && newExpiry < riskWindowStart) revert ExpiryBelowRiskWindow();
// ...
}

Support

FAQs

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

Give us feedback!