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

[Medium] Reverting first active-risk observation leaves `riskWindowStart` unset, allowing a corrupted pool to auto-resolve as `EXPIRED`

Author Revealed upon completion

[Medium] Reverted first risk observation lets late stake redefine riskWindowStart

Description

ConfidencePool records lifecycle latches in _observePoolState(), but withdraw() and setPoolScope() can revert after calling it. If the first UNDER_ATTACK observation happens inside one of those calls, the EVM rolls back riskWindowStart.

The pool then behaves as if the attack window never started. A later successful call can become the first persisted observation even though risk started much earlier.

This is not just a cosmetic state desync. riskWindowStart is later used by the k=2 time-weighted bonus logic to decide how much of the bonus pool a staker deserves. Once the first persisted observation is delayed, the bonus formula starts measuring from the wrong timestamp.

Risk

A late sponsor/sybil stake can redefine the effective risk window and receive a large bonus share. This breaks the intended k=2 time-weighted bonus formula because late entrants are no longer crushed by the real elapsed risk time.

Example: Alice stakes before risk starts. The registry enters UNDER_ATTACK. The sponsor calls setPoolScope(), _observePoolState() sets riskWindowStart, then the call reverts with ScopePostLockImmutable. Bob stakes 20 days later while still under attack. Bob's stake becomes the first successful observation and receives about half the bonus after SURVIVED.

The same root issue also affects other paths that depend on the first persisted lifecycle observation. For example, a reverted first terminal observation can similarly delay riskWindowEnd, and a pool can momentarily behave as if no active-risk period was ever seen even though the registry already moved to an attack state.

Proof of Concept

// registry is UNDER_ATTACK and riskWindowStart is still zero
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(scope);
assertEq(pool.riskWindowStart(), 0);
vm.warp(riskStart + 20 days);
pool.stake(100e18); // Bob, late
pool.contributeBonus(100e18);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 beforeBal = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertGt(token.balanceOf(bob) - beforeBal, 140e18);

Bob receives principal plus a large bonus despite entering long after the real risk start.

PoC flow:

  1. Alice is the honest early staker.

  2. The registry already moved into UNDER_ATTACK.

  3. The sponsor uses setPoolScope() as the first call that observes this state, but that call reverts after _observePoolState() runs.

  4. Because the entire call frame reverts, riskWindowStart returns to zero.

  5. Bob then stakes much later while the pool is still in the same attack period.

  6. Bob's later stake becomes the first persisted observation and is rewarded as if he entered near the start of the window.

Mitigation

Do not persist lifecycle latches inside calls that can revert after observation. Split observation into read/commit phases, or commit the first active-risk observation through a non-reverting path.

For example, _observePoolState() can be split into a pure/view classification step plus a commit step that is only reached from non-reverting paths:

(bool shouldStart, bool shouldEnd) = _classifyPoolState();
if (shouldStart && riskWindowStart == 0) riskWindowStart = block.timestamp;
if (shouldEnd && riskWindowEnd == 0) riskWindowEnd = block.timestamp;

The important property is that withdraw() and setPoolScope() must not be able to observe UNDER_ATTACK, revert, and silently erase the first risk observation.

Support

FAQs

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

Give us feedback!