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

Post-expiry seal of riskWindowStart redirects the entire bonus pool between stakers and the sponsor's recoveryAddress based purely on settlement-trigger timing

Author Revealed upon completion

Root + Impact

pokeRiskWindow / claimExpired seal riskWindowStart after expiry, flipping the destination of the whole bonus pool between the stakers and the sponsor-controlled recoveryAddress.

Description

  • When a pool reaches expiry, its bonus is split among stakers only if a risk window was observed (riskWindowStart != 0). If none was ever observed, the bonus is instead swept to recoveryAddress — the documented "no observable risk" rule (DESIGN §5). The k=2 reward math anchors on riskWindowStart/riskWindowEnd, which are sealed the first time a pool interaction reads the registry in the matching state.

  • Neither pokeRiskWindow() nor claimExpired()'s _observePoolState() is gated on block.timestamp < expiry. A pool that reaches expiry with riskWindowStart == 0 can still have riskWindowStart sealed (capped to expiry) by the first post-expiry caller that catches an active-risk state. That seal flips _bonusShare from the riskWindowStart == 0 → pay-zero → sweep-to-recovery branch to the globalScore == 0 → amount-weighted → pay-stakers branch. The destination of the entire bonus pool therefore depends on who triggers settlement first, not on what actually happened — falsifying DESIGN §7's claim that the timing residual has "no third-party loss."

// src/ConfidencePool.sol
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> // no `block.timestamp < expiry` guard: a post-expiry poke still seals the window
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry; // capped, but STILL sealed post-expiry
@> riskWindowStart = uint32(t); // riskWindowStart := expiry (nonzero)
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Risk

Likelihood:

  • Triggers whenever a pool reaches expiry with riskWindowStart == 0 — a developer-anticipated state (the code has a dedicated riskWindowStart == 0 path) that arises naturally in low-activity pools: staking clusters pre-attack, withdrawal attempts revert and roll back their observation during an attack, and pokeRiskWindow pays its caller nothing.

  • Realized whenever the registry is in (or transiently enters) an active-risk state at or after expiry and a self-interested staker calls pokeRiskWindow/claimExpired before the pool settles from a benign state — a permissionless race the staker (who gains the bonus) and the sponsor (who gains the sweep) are on opposite sides of.

Impact:

  • The entire bonus pool moves between two distinct parties — the staker set and the sponsor-controlled recoveryAddress — decided by settlement-trigger timing rather than actual borne risk.

  • Stakers who bore real in-term risk can be denied the bonus (swept to the sponsor), or the sponsor can be denied bonus they would receive under the documented no-observed-risk rule.

Proof of Concept

Both tests start from the identical state (UNDER_ATTACK at expiry, riskWindowStart == 0); only the post-expiry trigger differs, yet the whole bonus lands in different hands.

// test/poc/PostExpiryRiskWindowSeal.t.sol
function testPostExpiryPokeSealsRiskWindowAndRedirectsBonusToStakers() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry());
assertEq(pool.riskWindowStart(), 0);
pool.pokeRiskWindow(); // post-expiry seal (missing guard)
assertEq(pool.riskWindowStart(), pool.expiry());
vm.prank(alice); pool.claimExpired();
vm.prank(bob); pool.claimExpired();
assertEq(token.balanceOf(alice), 150 * ONE); // principal + half bonus
assertEq(token.balanceOf(bob), 150 * ONE);
assertEq(token.balanceOf(recovery), 0); // recovery got NOTHING
}
function testPostExpiryBenignObservationRedirectsBonusToRecovery() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION); // benign first-obs
vm.prank(alice); pool.claimExpired(); // SURVIVED, riskWindowStart stays 0
vm.prank(bob); pool.claimSurvived();
assertEq(token.balanceOf(alice), 100 * ONE); // principal ONLY
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 * ONE); // recovery took the ENTIRE bonus
}

Recommended Mitigation

Do not open the risk window once the term is over — the "no observable risk" rule should hold after expiry:

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowStart == 0 && _isActiveRiskState(state) && block.timestamp < expiry) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

This one-line guard also fixes the principal-sweep variant (companion finding). Sealing already-nonzero windows is unaffected.

Support

FAQs

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

Give us feedback!